diff --git a/bill/components/profile.py b/bill/components/profile.py index 933187e..bcbad0e 100644 --- a/bill/components/profile.py +++ b/bill/components/profile.py @@ -29,6 +29,7 @@ ProfileDraft, ProfileLink, ServerProfileMode, + WizardStage, WorkerAPIError, ) @@ -69,28 +70,64 @@ (DmStatus.AFTER_TRIBUTE, "After Tribute", "DMs open after tribute"), (DmStatus.CLOSED, "Closed", "Not accepting DMs"), ) +PROFILE_COLOR_OPTIONS = ( + ("Blue", 0x5865F2), + ("Purple", 0x9B59B6), + ("Rose", 0xE0568A), + ("Red", 0xE74C3C), + ("Orange", 0xE67E22), + ("Gold", 0xD4A72C), + ("Emerald", 0x2EAD78), + ("Teal", 0x2AA198), +) PROFILE_WIZARD_BUTTON_ACTIONS = ( "start", "publish", "restart", - "identity", - "links", + "continue", + "back", + "use-global", + "bio", + "skip-bio", + "aliases", + "link-social", + "link-payment", "import", "visibility", "complete-links", "skip-links", "throne", + "confirm-throne", "skip-throne", + "check-throne", "rotate", + "custom-color", + "edit-orientation", + "edit-pronouns", + "edit-titles", + "edit-dm", + "edit-bio", + "edit-color", + "edit-links", + "edit-throne", + "edit-details", + "identity", + "links", ) PROFILE_WIZARD_SELECT_ACTIONS = ( "orientation", + "pronouns", + "honourifics", + "labels", + "dm-status", + "profile-color", + "stats", + "link-select", + "creator-select", "identity-pronouns", "identity-honourifics", "identity-labels", "identity-dm-status", - "link-select", - "creator-select", ) _PROFILE_WIZARD_ACTIONS = frozenset( (*PROFILE_WIZARD_BUTTON_ACTIONS, *PROFILE_WIZARD_SELECT_ACTIONS) @@ -154,20 +191,6 @@ def _caps(orientation: Orientation | None) -> tuple[bool, bool, bool, bool, bool return True, True, True, True, True -def _csv(value: str, allowed: Iterable[str], field: str) -> list[str]: - lookup = {entry.casefold(): entry for entry in allowed} - result: list[str] = [] - for entry in (item.strip() for item in value.split(",")): - if not entry: - continue - normalized = lookup.get(entry.casefold()) - if normalized is None: - raise ValueError(f"{field} has an unrecognized value: {entry}") - if normalized not in result: - result.append(normalized) - return result - - def _summary(draft: ProfileDraft, key: DraftStepKey) -> str: if key is DraftStepKey.ORIENTATION: return ORIENTATION_LABELS.get(draft.governing_orientation, "Chosen orientation") @@ -185,12 +208,184 @@ def _summary(draft: ProfileDraft, key: DraftStepKey) -> str: return "Ready to publish" +def _is_linked(draft: ProfileDraft) -> bool: + return ( + draft.target_scope is DraftScope.SERVER + and draft.server_mode is ServerProfileMode.LINKED + ) + + +def wizard_stages( + orientation: Orientation | None, + *, + linked: bool = False, +) -> tuple[WizardStage, ...]: + stages = [WizardStage.PRONOUNS] + if not linked: + stages.insert(0, WizardStage.ORIENTATION) + honourifics, labels, aliases, _, stats = _caps(orientation) + if honourifics: + stages.append(WizardStage.HONOURIFICS) + if labels: + stages.append(WizardStage.SUBMISSIVE_LABELS) + stages.extend( + ( + WizardStage.DM_STATUS, + WizardStage.BIO, + WizardStage.PROFILE_COLOR, + WizardStage.LINKS, + ) + ) + if orientation is not None and _caps(orientation)[3] and not linked: + stages.append(WizardStage.THRONE) + if aliases or stats: + stages.append(WizardStage.DETAILS) + stages.append(WizardStage.REVIEW) + return tuple(stages) + + +def _legacy_stage(draft: ProfileDraft) -> WizardStage: + current = draft.next_step or draft.current_step or DraftStepKey.REVIEW + return { + DraftStepKey.ORIENTATION: WizardStage.ORIENTATION, + DraftStepKey.IDENTITY: WizardStage.PRONOUNS, + DraftStepKey.LINKS: WizardStage.LINKS, + DraftStepKey.THRONE: WizardStage.THRONE, + DraftStepKey.REVIEW: WizardStage.REVIEW, + }[current] + + +def _stage(draft: ProfileDraft) -> WizardStage: + stage = draft.wizard_stage or _legacy_stage(draft) + stages = wizard_stages(draft.governing_orientation, linked=_is_linked(draft)) + return stage if stage in stages else stages[-1] + + +def _throne_verification_state(draft: ProfileDraft) -> str | None: + substep = draft.wizard_substep + if substep is None or substep == "review": + return None + return substep.removeprefix("review:") + + +def _returns_to_review(draft: ProfileDraft) -> bool: + substep = draft.wizard_substep + return substep == "review" or bool(substep and substep.startswith("review:")) + + +def _throne_substep(draft: ProfileDraft, state: str) -> str: + return f"review:{state}" if _returns_to_review(draft) else state + + +def _stage_title(stage: WizardStage) -> str: + return { + WizardStage.ORIENTATION: "Orientation", + WizardStage.PRONOUNS: "Pronouns", + WizardStage.HONOURIFICS: "Titles and honourifics", + WizardStage.SUBMISSIVE_LABELS: "Submissive labels", + WizardStage.DM_STATUS: "DM status", + WizardStage.BIO: "Bio", + WizardStage.PROFILE_COLOR: "Profile colour", + WizardStage.LINKS: "Links", + WizardStage.THRONE: "Connect Throne", + WizardStage.DETAILS: "Aliases and stats", + WizardStage.REVIEW: "Review and publish", + }[stage] + + +def _colour_name(value: int | None) -> str: + if value is None: + return "No colour" + return next( + (name for name, preset in PROFILE_COLOR_OPTIONS if preset == value), + f"#{value:06X}", + ) + + +def _effective_colour(draft: ProfileDraft) -> int | None: + if _is_linked(draft) and "profile_color" not in draft.document.overridden_fields: + return draft.resolved_profile_color + return draft.document.profile_color + + +def _colour_choice_label(draft: ProfileDraft) -> str: + if _is_linked(draft) and "profile_color" not in draft.document.overridden_fields: + return f"Use global colour ({_colour_name(draft.resolved_profile_color)})" + return _colour_name(draft.document.profile_color) + + def _button( draft: ProfileDraft, label: str, action: str, style: discord.ButtonStyle ) -> discord.ui.Button: return discord.ui.Button(label=label, custom_id=wizard_custom_id(draft, action), style=style) +def _add_navigation( + container: discord.ui.Container, + draft: ProfileDraft, + *, + inherit: bool = False, +) -> None: + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) + controls = [ + _button(draft, "Back", "back", discord.ButtonStyle.secondary), + _button(draft, "Continue", "continue", discord.ButtonStyle.primary), + ] + if inherit and _is_linked(draft): + controls.insert( + 0, + _button(draft, "Use global setting", "use-global", discord.ButtonStyle.secondary), + ) + container.add_item( + discord.ui.ActionRow(*controls) + ) + + +def _review_preview( + draft: ProfileDraft, + presentation: MemberPresentation, +) -> discord.ui.Container: + color = _effective_colour(draft) + container = discord.ui.Container( + accent_color=None if color is None else discord.Color(color) + ) + container.add_item(discord.ui.TextDisplay("-# Profile preview")) + orientation = ORIENTATION_LABELS.get(draft.governing_orientation, "Not selected") + status = ( + draft.document.dm_status.value.replace("_", " ").title() + if draft.document.dm_status + else "Use global setting" + ) + container.add_item( + _member_section( + presentation, + current_label=f"{orientation} · DMs: {status}", + progress_label="Preview", + scope_label=_colour_name(color), + ) + ) + identity = ( + *draft.document.selections.pronouns, + *draft.document.selections.honourifics, + *draft.document.selections.submissive_labels, + ) + summary = [] + if identity: + summary.append(f"> **Identity:** {safe_text(', '.join(identity), limit=250)}") + if draft.document.bio: + summary.append(f"> {safe_text(draft.document.bio, limit=300)}") + if draft.document.aliases: + summary.append( + f"> **Aliases:** {safe_text(', '.join(draft.document.aliases), limit=200)}" + ) + summary.append(f"> **Links:** {len(draft.document.links)} saved") + if draft.document.throne_creator_id: + summary.append("> **Throne:** Connected") + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) + container.add_item(discord.ui.TextDisplay("\n".join(summary))) + return container + + def profile_intro_view(draft: ProfileDraft) -> discord.ui.View: """Build the normal, durable DM introduction shown before Components V2.""" view = discord.ui.View(timeout=None) @@ -208,11 +403,12 @@ def _member_section( presentation: MemberPresentation, *, current_label: str, + progress_label: str, scope_label: str, ) -> discord.ui.Section | discord.ui.TextDisplay: title = f"### {safe_text(presentation.display_name, limit=80)}" metadata = ( - f"-# Current step: {safe_text(current_label, limit=80)}", + f"-# {safe_text(progress_label, limit=80)} · {safe_text(current_label, limit=80)}", f"-# Profile: {safe_text(scope_label, limit=80)} · progress saves automatically", ) if presentation.avatar_url: @@ -227,16 +423,6 @@ def _member_section( return discord.ui.TextDisplay("\n".join((title, *metadata))) -def _current_step_label(step: DraftStepKey) -> str: - return { - DraftStepKey.ORIENTATION: "Choose orientation", - DraftStepKey.IDENTITY: "Identity", - DraftStepKey.LINKS: "Links", - DraftStepKey.THRONE: "Throne", - DraftStepKey.REVIEW: "Review and publish", - }[step] - - def profile_wizard_view( draft: ProfileDraft, *, @@ -244,7 +430,9 @@ def profile_wizard_view( ) -> discord.ui.LayoutView: """Render the sole editable V2 wizard message from the latest Worker state.""" presentation = presentation or MemberPresentation("Bill member") - current = draft.next_step or draft.current_step or DraftStepKey.REVIEW + current = _stage(draft) + stages = wizard_stages(draft.governing_orientation, linked=_is_linked(draft)) + position = stages.index(current) + 1 scope_label = ( "Global" if draft.target_scope is DraftScope.GLOBAL @@ -255,99 +443,142 @@ def profile_wizard_view( ) ) view = discord.ui.LayoutView(timeout=None) - container = discord.ui.Container(accent_color=discord.Color.green()) + container = discord.ui.Container() container.add_item(discord.ui.TextDisplay("-# Bill Profile Setup")) container.add_item( _member_section( presentation, - current_label=_current_step_label(current), + current_label=_stage_title(current), + progress_label=f"Step {position} of {len(stages)}", scope_label=scope_label, ) ) container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) - completed = [ - f"-# **{step.key.value.title()}**: {_summary(draft, step.key)} (Complete)" - for step in draft.steps - if step.status == "completed" - ] - if completed: - container.add_item(discord.ui.TextDisplay("\n".join(completed))) - container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) - if current is DraftStepKey.ORIENTATION: + if current is WizardStage.ORIENTATION: container.add_item( discord.ui.TextDisplay( - "Choose the orientation that best fits this profile. It controls which " - "identity, payment, and Throne options appear later." + "Choose the orientation that best fits this profile. This only changes which " + "relevant setup screens Bill shows next." ) ) container.add_item(discord.ui.ActionRow(OrientationSelect(draft))) - elif current is DraftStepKey.IDENTITY: - linked = ( - draft.target_scope is DraftScope.SERVER - and draft.server_mode is ServerProfileMode.LINKED - ) + elif current is WizardStage.PRONOUNS: container.add_item( discord.ui.TextDisplay( - "Choose a DM status and the labels you want to show, then save any optional " - "bio, aliases, and public send-stat preference." - + ( - " Use global setting keeps this server's DM status linked to your global " - "profile." - if linked - else "" - ) + "Choose the pronouns shown on your profile. Your saved choice is selected when " + "you return to this screen." ) ) - container.add_item(discord.ui.ActionRow(DmStatusSelect(draft))) container.add_item( discord.ui.ActionRow(IdentitySelect(draft, "pronouns", PRONOUNS, "Choose pronouns")) ) - honourifics, labels, _, _, _ = _caps(draft.governing_orientation) - if honourifics: - container.add_item( - discord.ui.ActionRow( - IdentitySelect( - draft, - "honourifics", - HONOURIFICS, - "Choose honourifics", - ) - ) + _add_navigation(container, draft, inherit=True) + elif current is WizardStage.HONOURIFICS: + container.add_item( + discord.ui.TextDisplay( + "Choose any titles or honourifics you want displayed. Leave the menu empty if " + "you do not use one." ) - if labels: - container.add_item( - discord.ui.ActionRow( - IdentitySelect( - draft, - "labels", - SUBMISSIVE_LABELS, - "Choose submissive labels", - ) - ) + ) + container.add_item( + discord.ui.ActionRow( + IdentitySelect(draft, "honourifics", HONOURIFICS, "Choose titles") ) + ) + _add_navigation(container, draft, inherit=True) + elif current is WizardStage.SUBMISSIVE_LABELS: + container.add_item( + discord.ui.TextDisplay( + "Choose any submissive labels you want displayed. Leave the menu empty if none " + "fit." + ) + ) container.add_item( discord.ui.ActionRow( + IdentitySelect(draft, "labels", SUBMISSIVE_LABELS, "Choose labels") + ) + ) + _add_navigation(container, draft, inherit=True) + elif current is WizardStage.DM_STATUS: + explanation = "Choose how people should approach your DMs." + if _is_linked(draft): + explanation += " **Use global setting** keeps this server profile linked." + container.add_item(discord.ui.TextDisplay(explanation)) + container.add_item(discord.ui.ActionRow(DmStatusSelect(draft))) + _add_navigation(container, draft) + elif current is WizardStage.BIO: + saved = safe_text(draft.document.bio, limit=300) if draft.document.bio else "No bio saved" + container.add_item( + discord.ui.TextDisplay( + "Add a short public bio, edit the saved one, or skip this optional screen.\n\n" + f"> {saved}" + ) + ) + bio_controls = [ + _button( + draft, + "Edit bio" if draft.document.bio else "Add bio", + "bio", + discord.ButtonStyle.primary, + ), + _button(draft, "Skip", "skip-bio", discord.ButtonStyle.secondary), + _button(draft, "Back", "back", discord.ButtonStyle.secondary), + ] + if _is_linked(draft): + bio_controls.insert( + 1, _button( draft, - "Save identity details", - "identity", - discord.ButtonStyle.primary, - ) + "Use global bio", + "use-global", + discord.ButtonStyle.secondary, + ), + ) + container.add_item(discord.ui.ActionRow(*bio_controls)) + elif current is WizardStage.PROFILE_COLOR: + container.add_item( + discord.ui.TextDisplay( + "Choose the accent used on your published profile card. Setup stays neutral. " + f"Current choice: **{_colour_choice_label(draft)}**." + ) + ) + container.add_item(discord.ui.ActionRow(ProfileColorSelect(draft))) + container.add_item( + discord.ui.ActionRow( + _button(draft, "Custom hex", "custom-color", discord.ButtonStyle.secondary), + _button(draft, "Back", "back", discord.ButtonStyle.secondary), + _button(draft, "Continue", "continue", discord.ButtonStyle.primary), ) ) - elif current is DraftStepKey.LINKS: + elif current is WizardStage.LINKS: _, _, _, payment, _ = _caps(draft.governing_orientation) + summary = "\n".join( + f"> **{safe_text(link.public_label, limit=50)}** · {safe_text(link.platform, limit=40)}" + for link in draft.document.links[:8] + ) container.add_item( discord.ui.TextDisplay( - "Add social or payment links one at a time, or import a supported public " - "link page. Only enabled HTTPS links are shown publicly." + "Add one link manually or import a supported public page. Only enabled HTTPS " + "links are published." + + (f"\n\n**Saved links**\n{summary}" if summary else "\n\n> No links saved yet") ) ) links = [ - _button(draft, "Add link", "links", discord.ButtonStyle.primary), + _button(draft, "Add social link", "link-social", discord.ButtonStyle.primary), _button(draft, "Import page", "import", discord.ButtonStyle.secondary), - _button(draft, "Done", "complete-links", discord.ButtonStyle.success), + _button(draft, "Continue", "complete-links", discord.ButtonStyle.success), + _button(draft, "Back", "back", discord.ButtonStyle.secondary), ] + if payment: + links.insert( + 1, + _button( + draft, + "Add payment link", + "link-payment", + discord.ButtonStyle.primary, + ), + ) if not draft.document.links: links.append(_button(draft, "Skip", "skip-links", discord.ButtonStyle.secondary)) if ( @@ -357,54 +588,163 @@ def profile_wizard_view( links.append( _button(draft, "Inherited visibility", "visibility", discord.ButtonStyle.secondary) ) - container.add_item(discord.ui.ActionRow(*links)) + for index in range(0, len(links), 5): + container.add_item(discord.ui.ActionRow(*links[index : index + 5])) if draft.document.links: container.add_item(discord.ui.ActionRow(LinkSelect(draft, payment=payment))) - elif current is DraftStepKey.THRONE: - container.add_item( - discord.ui.TextDisplay( - "Connect a Throne creator, select one already saved to your account, rotate " - "its private webhook, or skip this step." + elif current is WizardStage.THRONE: + connected = draft.document.throne_creator_id is not None + verified = _throne_verification_state(draft) == "verified" + if verified: + copy = ( + "Throne has confirmed the private webhook connection. You can continue or rotate " + "the webhook if you need a new one." ) - ) - controls = [ - _button(draft, "Connect Throne", "throne", discord.ButtonStyle.primary), - _button(draft, "Skip", "skip-throne", discord.ButtonStyle.secondary), - ] - if draft.document.throne_creator_id: - controls.insert( - 1, _button(draft, "Rotate webhook", "rotate", discord.ButtonStyle.danger) + elif connected: + copy = ( + "Finish the connection in Throne: open your creator settings, find webhooks, " + "paste and save the private URL Bill showed you, run **Test Webhook**, then " + "return here and press **Check Connection**." + ) + elif draft.throne_pending is not None: + copy = ( + "Bill found this Throne creator:\n\n" + f"> **@{safe_text(draft.throne_pending.handle, limit=80)}**\n\n" + "Confirm this is the profile you intended before Bill attaches it or issues a " + "private webhook." + ) + else: + copy = ( + "Connect Throne through a guided private verification, reuse an owned creator, " + "or skip for now." ) + container.add_item( + discord.ui.TextDisplay(copy) + ) + if draft.throne_pending is not None and not connected: + controls = [ + _button( + draft, + "Yes, connect this handle", + "confirm-throne", + discord.ButtonStyle.success, + ), + _button(draft, "Try another handle", "throne", discord.ButtonStyle.secondary), + _button(draft, "Skip for now", "skip-throne", discord.ButtonStyle.secondary), + _button(draft, "Back", "back", discord.ButtonStyle.secondary), + ] + elif connected: + controls = [ + _button(draft, "Check Connection", "check-throne", discord.ButtonStyle.primary), + _button(draft, "Rotate webhook", "rotate", discord.ButtonStyle.danger), + _button(draft, "Skip for now", "skip-throne", discord.ButtonStyle.secondary), + _button(draft, "Back", "back", discord.ButtonStyle.secondary), + ] + if verified: + controls.insert( + 0, _button(draft, "Continue", "continue", discord.ButtonStyle.success) + ) + else: + controls = [ + _button(draft, "Enter Throne profile", "throne", discord.ButtonStyle.primary), + _button(draft, "Skip for now", "skip-throne", discord.ButtonStyle.secondary), + _button(draft, "Back", "back", discord.ButtonStyle.secondary), + ] container.add_item(discord.ui.ActionRow(*controls)) - if draft.throne_prefill and draft.throne_prefill.owned_creators: + if not connected and draft.throne_prefill and draft.throne_prefill.owned_creators: options = [ - discord.SelectOption(label=safe_text(creator.handle, limit=80), value=creator.id) + discord.SelectOption( + label=safe_text(creator.handle, limit=80), + value=creator.id, + description="Already verified" if creator.id == ( + draft.throne_prefill.existing_registration_creator_id + ) else "Owned Throne creator", + ) for creator in draft.throne_prefill.owned_creators[:25] ] container.add_item(discord.ui.ActionRow(ThroneCreatorSelect(draft, options))) + elif current is WizardStage.DETAILS: + _, _, aliases, _, stats = _caps(draft.governing_orientation) + details = [] + if aliases: + alias_summary = safe_text(", ".join(draft.document.aliases), limit=200) or "None" + details.append( + f"> **Aliases:** {alias_summary}" + ) + if stats: + details.append( + "> **Public send stats:** " + + ("Shown" if draft.document.public_send_stats else "Hidden") + ) + container.add_item( + discord.ui.TextDisplay( + "Choose the optional details relevant to this orientation.\n\n" + + "\n".join(details) + ) + ) + if stats: + container.add_item(discord.ui.ActionRow(StatsSelect(draft))) + controls = [] + if aliases: + controls.append( + _button(draft, "Edit aliases", "aliases", discord.ButtonStyle.secondary) + ) + if _is_linked(draft): + controls.append( + _button( + draft, + "Use global details", + "use-global", + discord.ButtonStyle.secondary, + ) + ) + controls.extend( + ( + _button(draft, "Back", "back", discord.ButtonStyle.secondary), + _button(draft, "Continue", "continue", discord.ButtonStyle.primary), + ) + ) + container.add_item(discord.ui.ActionRow(*controls)) else: container.add_item( discord.ui.TextDisplay( - "Review the completed sections above. You can edit any section now; " - "nothing becomes public until you choose **Publish**. Change your DM status " - "below, including restoring the global setting for a linked profile." + "Review the compact preview below. Use an Edit control to revisit one section; " + "nothing becomes public until you choose **Publish**. You can also change your " + "DM status below, including restoring the global setting for a linked profile." ) ) container.add_item(discord.ui.ActionRow(DmStatusSelect(draft))) edits = [ - _button(draft, "Edit identity", "identity", discord.ButtonStyle.secondary), - _button(draft, "Edit links", "links", discord.ButtonStyle.secondary), + _button(draft, "Edit identity", "edit-pronouns", discord.ButtonStyle.secondary), + _button(draft, "Edit DMs", "edit-dm", discord.ButtonStyle.secondary), + _button(draft, "Edit bio", "edit-bio", discord.ButtonStyle.secondary), + _button(draft, "Edit colour", "edit-color", discord.ButtonStyle.secondary), ] - if ( - draft.target_scope is DraftScope.SERVER - and draft.server_mode is ServerProfileMode.LINKED - ): - edits.append( - _button(draft, "Inherited visibility", "visibility", discord.ButtonStyle.secondary) + if not _is_linked(draft): + edits.insert( + 0, + _button( + draft, + "Edit orientation", + "edit-orientation", + discord.ButtonStyle.secondary, + ), ) - if draft.governing_orientation is not Orientation.SUBMISSIVE: - edits.append(_button(draft, "Edit Throne", "throne", discord.ButtonStyle.secondary)) - container.add_item(discord.ui.ActionRow(*edits)) + container.add_item(discord.ui.ActionRow(*edits[:5])) + more_edits = [ + _button(draft, "Edit titles", "edit-titles", discord.ButtonStyle.secondary), + _button(draft, "Edit links", "edit-links", discord.ButtonStyle.secondary), + ] + if _caps(draft.governing_orientation)[3] and not _is_linked(draft): + more_edits.append( + _button(draft, "Edit Throne", "edit-throne", discord.ButtonStyle.secondary) + ) + if _caps(draft.governing_orientation)[2] or _caps(draft.governing_orientation)[4]: + more_edits.append( + _button(draft, "Edit details", "edit-details", discord.ButtonStyle.secondary) + ) + container.add_item(discord.ui.ActionRow(*more_edits)) + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) container.add_item( discord.ui.ActionRow( _button(draft, "Publish", "publish", discord.ButtonStyle.success), @@ -412,6 +752,8 @@ def profile_wizard_view( ) ) view.add_item(container) + if current is WizardStage.REVIEW: + view.add_item(_review_preview(draft, presentation)) return view @@ -422,6 +764,79 @@ def _wizard_for( return profile_wizard_view(draft, presentation=member_presentation(interaction.user)) +def _adjacent_stage( + draft: ProfileDraft, + *, + forward: bool, +) -> WizardStage: + stages = wizard_stages(draft.governing_orientation, linked=_is_linked(draft)) + current = _stage(draft) + if forward and _returns_to_review(draft): + return WizardStage.REVIEW + index = stages.index(current) + offset = 1 if forward else -1 + return stages[max(0, min(len(stages) - 1, index + offset))] + + +async def _send_ephemeral( + interaction: discord.Interaction[discord.Client], + content: str, + *, + view: discord.ui.View | None = None, +) -> None: + if interaction.response.is_done(): + await interaction.followup.send(content, view=view, ephemeral=True) + else: + await interaction.response.send_message(content, view=view, ephemeral=True) + + +async def _send_throne_webhook_url( + interaction: discord.Interaction[discord.Client], + webhook_url: str, +) -> None: + await interaction.followup.send( + _throne_webhook_instructions(webhook_url), + ephemeral=True, + ) + + +async def _move_wizard( + bot: BillBot, + interaction: discord.Interaction[discord.Client], + draft: ProfileDraft, + stage: WizardStage, + *, + substep: str | None = None, +) -> ProfileDraft | None: + try: + return await bot.require_worker().set_draft_wizard_stage( + draft.id, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + stage=stage, + substep=substep, + ) + except WorkerAPIError as exc: + await _send_ephemeral(interaction, f"Bill could not move the profile wizard: {exc}") + return None + + +def _validate_continue(draft: ProfileDraft) -> None: + current = _stage(draft) + if current is WizardStage.PRONOUNS and not ( + draft.document.selections.pronouns + or (_is_linked(draft) and "pronouns" not in draft.document.overridden_fields) + ): + raise ValueError("choose at least one pronoun") + if current is WizardStage.DM_STATUS and not ( + draft.document.dm_status is not None + or (_is_linked(draft) and "dm_status" not in draft.document.overridden_fields) + ): + raise ValueError("choose a DM status") + if current is WizardStage.THRONE and _throne_verification_state(draft) != "verified": + raise ValueError("verify the Throne connection or choose Skip for now") + + async def _load_draft( bot: BillBot, interaction: discord.Interaction[discord.Client], @@ -429,18 +844,18 @@ async def _load_draft( owner: str, origin: str, revision: int, + *, + allow_stale_revision: bool = False, ) -> ProfileDraft | None: if str(interaction.user.id) != owner: - await interaction.response.send_message( - "That profile control belongs to someone else.", ephemeral=True - ) + await _send_ephemeral(interaction, "That profile control belongs to someone else.") return None try: draft = await bot.require_worker().get_draft(draft_id, owner_user_id=interaction.user.id) except WorkerAPIError: - await interaction.response.send_message( + await _send_ephemeral( + interaction, "That profile control is no longer available. Please use `/profile` to resume it.", - ephemeral=True, ) return None # DMs have no guild_id. Comparing the encoded guild with the durable draft @@ -450,13 +865,16 @@ async def _load_draft( or draft.origin_guild_id != origin or (interaction.guild_id is not None and str(interaction.guild_id) != origin) ): - await interaction.response.send_message( - "That profile control belongs to a different profile session.", ephemeral=True + await _send_ephemeral( + interaction, "That profile control belongs to a different profile session." ) return None - if draft.revision != revision or draft.status.value != "active": - await interaction.response.send_message( - "That profile control is stale. Please use the latest wizard message.", ephemeral=True + if ( + (draft.revision != revision and not allow_stale_revision) + or draft.status.value != "active" + ): + await _send_ephemeral( + interaction, "That profile control is stale. Please use the latest wizard message." ) return None return draft @@ -468,7 +886,11 @@ def __init__(self, draft: ProfileDraft) -> None: custom_id=wizard_custom_id(draft, "orientation"), placeholder="Choose an orientation", options=[ - discord.SelectOption(label=label, value=value.value) + discord.SelectOption( + label=label, + value=value.value, + default=draft.governing_orientation is value, + ) for value, label in ORIENTATION_LABELS.items() ], ) @@ -489,9 +911,9 @@ def __init__( else: selected = set(draft.document.selections.submissive_labels) super().__init__( - custom_id=wizard_custom_id(draft, f"identity-{field}"), + custom_id=wizard_custom_id(draft, field), placeholder=placeholder, - min_values=0, + min_values=1 if field == "pronouns" else 0, max_values=len(choices), options=[ discord.SelectOption(label=choice, value=choice, default=choice in selected) @@ -531,7 +953,7 @@ def __init__(self, draft: ProfileDraft) -> None: ) ) super().__init__( - custom_id=wizard_custom_id(draft, "identity-dm-status"), + custom_id=wizard_custom_id(draft, "dm-status"), placeholder="Choose a DM status", min_values=1, max_values=1, @@ -539,6 +961,81 @@ def __init__(self, draft: ProfileDraft) -> None: ) +class ProfileColorSelect(discord.ui.Select): + def __init__(self, draft: ProfileDraft) -> None: + linked = _is_linked(draft) + overridden = set(draft.document.overridden_fields) + inherited = linked and "profile_color" not in overridden + options = [ + discord.SelectOption( + label=name, + value=f"{value:06x}", + default=not inherited and draft.document.profile_color == value, + ) + for name, value in PROFILE_COLOR_OPTIONS + ] + options.append( + discord.SelectOption( + label="No colour", + value="none", + description="Publish a neutral profile card", + default=not inherited + and draft.document.profile_color is None + and (not linked or "profile_color" in overridden), + ) + ) + if linked: + options.append( + discord.SelectOption( + label="Use global colour", + value="inherit", + description="Follow the global profile colour", + default=inherited, + ) + ) + super().__init__( + custom_id=wizard_custom_id(draft, "profile-color"), + placeholder="Choose a profile colour", + min_values=1, + max_values=1, + options=options, + ) + + +class StatsSelect(discord.ui.Select): + def __init__(self, draft: ProfileDraft) -> None: + linked = _is_linked(draft) + overridden = set(draft.document.overridden_fields) + inherited = linked and "public_send_stats" not in overridden + options = [ + discord.SelectOption( + label="Show send stats", + value="show", + default=not inherited and draft.document.public_send_stats, + ), + discord.SelectOption( + label="Hide send stats", + value="hide", + default=not inherited and not draft.document.public_send_stats, + ), + ] + if linked: + options.append( + discord.SelectOption( + label="Use global setting", + value="inherit", + default=inherited, + ) + ) + super().__init__( + custom_id=wizard_custom_id(draft, "stats"), + placeholder="Choose public send stats", + min_values=1, + max_values=1, + options=options, + ) + + class LinkSelect(discord.ui.Select): def __init__(self, draft: ProfileDraft, *, payment: bool) -> None: options = [ @@ -608,57 +1105,204 @@ async def from_custom_id( async def callback(self, interaction: discord.Interaction[discord.Client]) -> None: bot = cast("BillBot", interaction.client) + legacy_action = self.action in {"identity", "links"} + modal_actions = { + "bio", + "aliases", + "custom-color", + "link-social", + "link-payment", + "import", + "throne", + } + await interaction.response.defer() draft = await _load_draft( - bot, interaction, self.draft_id, self.owner, self.guild, self.revision + bot, + interaction, + self.draft_id, + self.owner, + self.guild, + self.revision, + allow_stale_revision=legacy_action, ) if draft is None: return message = interaction.message if self.action == "start": if message is None: - await interaction.response.send_message( - "Please reopen your profile setup with `/profile`.", ephemeral=True + await _send_ephemeral( + interaction, "Please reopen your profile setup with `/profile`." ) return - await interaction.response.edit_message( + await interaction.edit_original_response( content=None, view=_wizard_for(interaction, draft), ) return + if legacy_action: + if message is None: + await _send_ephemeral(interaction, "Please reopen your wizard with `/profile`.") + return + target = ( + WizardStage.PRONOUNS if self.action == "identity" else WizardStage.LINKS + ) + updated = await _move_wizard( + bot, + interaction, + draft, + target, + substep="legacy", + ) + if updated is not None: + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) + return if self.action == "publish": try: await bot.require_worker().publish_draft( draft.id, owner_user_id=interaction.user.id, expected_revision=draft.revision ) except WorkerAPIError as exc: - await interaction.response.send_message( - f"Bill could not publish this profile: {exc}", ephemeral=True + await _send_ephemeral( + interaction, f"Bill could not publish this profile: {exc}" + ) + return + await interaction.edit_original_response( + view=None, content="Your Bill profile is published." + ) + return + if self.action == "restart": + await _send_ephemeral( + interaction, + "Restart this private draft? This replaces unsaved progress.", + view=RestartConfirmView(draft), + ) + return + if message is None: + await _send_ephemeral(interaction, "Please reopen your wizard with `/profile`.") + return + if self.action in modal_actions: + await _send_ephemeral( + interaction, + "Your editor is ready. Open it below.", + view=ProfileModalLauncherView(draft, message, self.action), + ) + return + if self.action in {"continue", "back"}: + try: + if self.action == "continue": + _validate_continue(draft) + working = draft + if self.action == "continue" and _stage(draft) is WizardStage.THRONE: + status = await bot.require_worker().get_throne_status( + draft.id, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + ) + if not status.verified: + raise ValueError( + "the Throne webhook was rotated or has not verified yet; " + "run Test Webhook and check the connection again" + ) + if self.action == "continue" and _stage(draft) in { + WizardStage.PROFILE_COLOR, + WizardStage.DETAILS, + WizardStage.THRONE, + }: + step = ( + DraftStepKey.THRONE + if _stage(draft) is WizardStage.THRONE + else DraftStepKey.IDENTITY + ) + values = ( + { + "throne_creator_id": draft.document.throne_creator_id, + "preferred_payment_link_id": ( + draft.document.preferred_payment_link_id + ), + } + if step is DraftStepKey.THRONE + else _identity_step_values(draft, complete=True) + ) + working = await bot.require_worker().update_draft_step( + draft.id, + step=step, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + values=values, + ) + target = _adjacent_stage(working, forward=self.action == "continue") + updated = await bot.require_worker().set_draft_wizard_stage( + working.id, + owner_user_id=interaction.user.id, + expected_revision=working.revision, + stage=target, + ) + except (ValueError, WorkerAPIError) as exc: + await _send_ephemeral(interaction, f"Bill could not continue: {exc}") + return + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) + return + if self.action == "use-global": + try: + values = _inherit_current_values(draft) + saved = await bot.require_worker().update_draft_step( + draft.id, + step=DraftStepKey.IDENTITY, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + values=values, + ) + if _stage(draft) is WizardStage.BIO: + updated = await bot.require_worker().set_draft_wizard_stage( + saved.id, + owner_user_id=interaction.user.id, + expected_revision=saved.revision, + stage=_adjacent_stage(saved, forward=True), + ) + else: + updated = saved + except (ValueError, WorkerAPIError) as exc: + await _send_ephemeral( + interaction, f"Bill could not restore the global setting: {exc}" ) return - await interaction.response.edit_message( - view=None, content="Your Bill profile is published." - ) + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) return - if self.action == "restart": - await interaction.response.send_message( - "Restart this private draft? This replaces unsaved progress.", - view=RestartConfirmView(draft), - ephemeral=True, + edit_stages = { + "edit-orientation": WizardStage.ORIENTATION, + "edit-pronouns": WizardStage.PRONOUNS, + "edit-titles": ( + WizardStage.HONOURIFICS + if _caps(draft.governing_orientation)[0] + else WizardStage.SUBMISSIVE_LABELS + ), + "edit-dm": WizardStage.DM_STATUS, + "edit-bio": WizardStage.BIO, + "edit-color": WizardStage.PROFILE_COLOR, + "edit-links": WizardStage.LINKS, + "edit-throne": WizardStage.THRONE, + "edit-details": WizardStage.DETAILS, + } + if self.action in edit_stages: + updated = await _move_wizard( + bot, + interaction, + draft, + edit_stages[self.action], + substep="review", ) + if updated is not None: + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) return - if message is None: - await interaction.response.send_message( - "Please reopen your wizard with `/profile`.", ephemeral=True + if self.action == "skip-bio": + updated = await _move_wizard( + bot, + interaction, + draft, + _adjacent_stage(draft, forward=True), ) - return - if self.action == "identity": - await interaction.response.send_modal(IdentityModal(draft, message)) - return - if self.action == "links": - await interaction.response.send_modal(LinkModal(draft, message)) - return - if self.action == "import": - await interaction.response.send_modal(LinkImportModal(draft, message)) + if updated is not None: + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) return if self.action == "visibility": try: @@ -667,43 +1311,89 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No user_id=interaction.user.id, ) except WorkerAPIError as exc: - await interaction.response.send_message( - f"Bill could not load inherited links: {exc}", ephemeral=True + await _send_ephemeral( + interaction, f"Bill could not load inherited links: {exc}" ) return if lookup.profile is None or not lookup.profile.links: - await interaction.response.send_message( - "Your global profile has no links to configure here.", ephemeral=True + await _send_ephemeral( + interaction, + "Your global profile has no links to configure here.", ) return - await interaction.response.send_message( + await _send_ephemeral( + interaction, "Choose global links to hide in this server.", view=InheritedLinkVisibilityView(draft, lookup.profile.links, message), - ephemeral=True, ) return if self.action in {"complete-links", "skip-links"}: try: - updated = await bot.require_worker().update_draft_step( + saved = await bot.require_worker().update_draft_step( draft.id, step=DraftStepKey.LINKS, owner_user_id=interaction.user.id, expected_revision=draft.revision, values=_links_step_values(draft), ) + updated = await bot.require_worker().set_draft_wizard_stage( + saved.id, + owner_user_id=interaction.user.id, + expected_revision=saved.revision, + stage=_adjacent_stage(saved, forward=True), + ) except WorkerAPIError as exc: - await interaction.response.send_message( - f"Bill could not save links: {exc}", ephemeral=True + await _send_ephemeral( + interaction, f"Bill could not save links: {exc}" ) return - await interaction.response.edit_message(view=_wizard_for(interaction, updated)) + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) return - if self.action == "throne": - await interaction.response.send_modal(ThroneModal(draft, message)) + if self.action == "confirm-throne": + try: + attached = await bot.require_worker().attach_throne( + draft.id, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + confirm_pending=True, + ) + if attached.webhook_url is not None: + await _send_throne_webhook_url(interaction, attached.webhook_url) + status = None + else: + status = await bot.require_worker().get_throne_status( + attached.draft.id, + owner_user_id=interaction.user.id, + expected_revision=attached.draft.revision, + ) + updated = await bot.require_worker().set_draft_wizard_stage( + attached.draft.id, + owner_user_id=interaction.user.id, + expected_revision=attached.draft.revision, + stage=WizardStage.THRONE, + substep=( + _throne_substep(draft, "verified") + if status is not None and status.verified + else _throne_substep(draft, "awaiting_verification") + ), + ) + except WorkerAPIError as exc: + await _send_ephemeral( + interaction, + f"Bill could not connect that Throne profile: {exc}", + ) + return + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) + if status is not None and status.verified: + await interaction.followup.send( + f"**@{safe_text(status.handle or draft.throne_pending.handle, limit=80)}** " + "is already connected and verified.", + ephemeral=True, + ) return if self.action == "skip-throne": try: - updated = await bot.require_worker().update_draft_step( + skipped = await bot.require_worker().update_draft_step( draft.id, step=DraftStepKey.THRONE, owner_user_id=interaction.user.id, @@ -713,45 +1403,73 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No "preferred_payment_link_id": draft.document.preferred_payment_link_id, }, ) + updated = await bot.require_worker().set_draft_wizard_stage( + skipped.id, + owner_user_id=interaction.user.id, + expected_revision=skipped.revision, + stage=_adjacent_stage(skipped, forward=True), + ) + except WorkerAPIError as exc: + await _send_ephemeral( + interaction, f"Bill could not skip Throne: {exc}" + ) + return + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) + return + if self.action == "check-throne": + try: + status = await bot.require_worker().get_throne_status( + draft.id, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + ) + if not status.verified: + await _send_ephemeral( + interaction, + "Throne has not confirmed the connection yet. Check that you saved the " + "private webhook URL, run **Test Webhook** in Throne, then try " + "**Check Connection** again.", + ) + return + updated = await bot.require_worker().set_draft_wizard_stage( + draft.id, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + stage=WizardStage.THRONE, + substep=_throne_substep(draft, "verified"), + ) except WorkerAPIError as exc: - await interaction.response.send_message( - f"Bill could not skip Throne: {exc}", ephemeral=True + await _send_ephemeral( + interaction, + f"Bill could not check the Throne connection: {exc}", ) return - await interaction.response.edit_message(view=_wizard_for(interaction, updated)) + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) return if self.action == "rotate": try: rotated = await bot.require_worker().rotate_throne( draft.id, owner_user_id=interaction.user.id, expected_revision=draft.revision ) - updated = await bot.require_worker().update_draft_step( + if rotated.webhook_url is None: + raise WorkerAPIError("Worker did not return the rotated webhook URL") + await _send_throne_webhook_url(interaction, rotated.webhook_url) + updated = await bot.require_worker().set_draft_wizard_stage( rotated.draft.id, - step=DraftStepKey.THRONE, owner_user_id=interaction.user.id, expected_revision=rotated.draft.revision, - values={ - "throne_creator_id": rotated.draft.document.throne_creator_id, - "preferred_payment_link_id": ( - rotated.draft.document.preferred_payment_link_id - ), - }, + stage=WizardStage.THRONE, + substep=_throne_substep(draft, "awaiting_verification"), ) except WorkerAPIError as exc: - await interaction.response.send_message( - f"Bill could not rotate that webhook: {exc}", ephemeral=True + await _send_ephemeral( + interaction, f"Bill could not rotate that webhook: {exc}" ) return - await interaction.response.edit_message(view=_wizard_for(interaction, updated)) - if rotated.webhook_url: - await interaction.followup.send( - "Your new private Throne webhook URL (save it now):\n" - f"```text\n{rotated.webhook_url}\n```", - ephemeral=True, - ) + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) return - await interaction.response.send_message( - "That action is no longer available. Use the latest wizard.", ephemeral=True + await _send_ephemeral( + interaction, "That action is no longer available. Use the latest wizard." ) @@ -796,62 +1514,113 @@ async def from_custom_id( async def callback(self, interaction: discord.Interaction[discord.Client]) -> None: bot = cast("BillBot", interaction.client) + legacy_stages = { + "identity-pronouns": WizardStage.PRONOUNS, + "identity-honourifics": WizardStage.HONOURIFICS, + "identity-labels": WizardStage.SUBMISSIVE_LABELS, + "identity-dm-status": WizardStage.DM_STATUS, + } + legacy_action = self.action in legacy_stages + await interaction.response.defer() draft = await _load_draft( - bot, interaction, self.draft_id, self.owner, self.guild, self.revision + bot, + interaction, + self.draft_id, + self.owner, + self.guild, + self.revision, + allow_stale_revision=legacy_action, ) - if draft is None or not self.item.values: + if draft is None: + return + if legacy_action: + if interaction.message is None: + await _send_ephemeral(interaction, "Please reopen your wizard with `/profile`.") + return + available = wizard_stages( + draft.governing_orientation, + linked=_is_linked(draft), + ) + requested = legacy_stages[self.action] + target = requested if requested in available else _stage(draft) + updated = await _move_wizard( + bot, + interaction, + draft, + target, + substep="legacy", + ) + if updated is not None: + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) + return + if not self.item.values and self.action not in {"honourifics", "labels"}: + await _send_ephemeral(interaction, "Choose an option before continuing.") return if self.action == "orientation": try: - updated = await bot.require_worker().update_draft_step( + saved = await bot.require_worker().update_draft_step( draft.id, step=DraftStepKey.ORIENTATION, owner_user_id=interaction.user.id, expected_revision=draft.revision, values={"orientation": Orientation(self.item.values[0]).value}, ) + updated = await bot.require_worker().set_draft_wizard_stage( + saved.id, + owner_user_id=interaction.user.id, + expected_revision=saved.revision, + stage=WizardStage.PRONOUNS, + ) except (ValueError, WorkerAPIError) as exc: - await interaction.response.send_message( - f"Bill could not save that orientation: {exc}", ephemeral=True + await _send_ephemeral( + interaction, f"Bill could not save that orientation: {exc}" ) return - await interaction.response.edit_message(view=_wizard_for(interaction, updated)) + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) return - if self.action.startswith("identity-"): - field = self.action.removeprefix("identity-") + if self.action in { + "pronouns", + "honourifics", + "labels", + "dm-status", + "profile-color", + "stats", + }: try: updated = await bot.require_worker().update_draft_step( draft.id, step=DraftStepKey.IDENTITY, owner_user_id=interaction.user.id, expected_revision=draft.revision, - values=_partial_identity_values(draft, field, tuple(self.item.values)), + values=_partial_identity_values( + draft, + self.action, + tuple(self.item.values), + ), ) except (ValueError, WorkerAPIError) as exc: - await interaction.response.send_message( + await _send_ephemeral( + interaction, f"Bill could not save that identity selection: {exc}", - ephemeral=True, ) return - await interaction.response.edit_message(view=_wizard_for(interaction, updated)) + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) return message = interaction.message if message is None: - await interaction.response.send_message( - "Please reopen your wizard with `/profile`.", ephemeral=True - ) + await _send_ephemeral(interaction, "Please reopen your wizard with `/profile`.") return if self.action == "link-select": link = next( (item for item in draft.document.links if item.id == self.item.values[0]), None ) if link is None: - await interaction.response.send_message( - "That link no longer exists.", ephemeral=True - ) + await _send_ephemeral(interaction, "That link no longer exists.") return - await interaction.response.send_message( - "Manage this link.", view=LinkManagerView(draft, link.id, message), ephemeral=True + await _send_ephemeral( + interaction, + "Manage this link.", + view=LinkManagerView(draft, link.id, message), ) return creator_id = self.item.values[0] @@ -862,26 +1631,36 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No expected_revision=draft.revision, existing_creator_id=creator_id, ) - updated = await bot.require_worker().update_draft_step( + if attached.webhook_url is not None: + await _send_throne_webhook_url(interaction, attached.webhook_url) + status = None + else: + status = await bot.require_worker().get_throne_status( + attached.draft.id, + owner_user_id=interaction.user.id, + expected_revision=attached.draft.revision, + ) + updated = await bot.require_worker().set_draft_wizard_stage( attached.draft.id, - step=DraftStepKey.THRONE, owner_user_id=interaction.user.id, expected_revision=attached.draft.revision, - values={ - "throne_creator_id": attached.draft.document.throne_creator_id, - "preferred_payment_link_id": attached.draft.document.preferred_payment_link_id, - }, + stage=WizardStage.THRONE, + substep=( + _throne_substep(draft, "verified") + if status is not None and status.verified + else _throne_substep(draft, "awaiting_verification") + ), ) except WorkerAPIError as exc: - await interaction.response.send_message( - f"Bill could not connect that creator: {exc}", ephemeral=True + await _send_ephemeral( + interaction, f"Bill could not connect that creator: {exc}" ) return - await interaction.response.edit_message(view=_wizard_for(interaction, updated)) - if attached.webhook_url: + await interaction.edit_original_response(view=_wizard_for(interaction, updated)) + if status is not None and status.verified: await interaction.followup.send( - "Your private Throne webhook URL (save it now):\n" - f"```text\n{attached.webhook_url}\n```", + f"**@{safe_text(status.handle or 'Throne creator', limit=80)}** is already " + "connected and verified. You can continue.", ephemeral=True, ) @@ -889,84 +1668,6 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No ProfileSelectDynamic = _ProfileSelectDynamic -def _identity_values( - draft: ProfileDraft, - pronouns: str, - honourifics: str, - labels: str, - aliases: str, - stats_raw: str, - bio_raw: str, -) -> dict[str, object]: - orientation = draft.governing_orientation - if orientation is None: - raise ValueError("choose an orientation first") - honourific_available, label_available, aliases_available, _, stats_available = _caps( - orientation - ) - stats_raw = stats_raw.strip() - bio_raw = bio_raw.strip() - linked = ( - draft.target_scope is DraftScope.SERVER and draft.server_mode is ServerProfileMode.LINKED - ) - existing_overrides = set(draft.document.overridden_fields) - if linked and "dm_status" not in existing_overrides: - status = None - elif draft.document.dm_status is not None: - status = draft.document.dm_status.value - elif linked: - raise ValueError("choose a DM status or Use global setting from the menu") - else: - raise ValueError("choose a DM status from the menu before saving identity") - if stats_raw.casefold() == "inherit" and linked: - stats: bool | None = None - elif stats_raw.casefold() in {"on", "yes", "true"}: - stats = True - elif stats_raw.casefold() in {"off", "no", "false"}: - stats = False - else: - raise ValueError("stats must be on, off, or inherit") - bio_overridden = not linked or bool(bio_raw) - bio = None if bio_raw == "-" else (bio_raw or None) - parsed_pronouns = _csv(pronouns, PRONOUNS, "pronouns") - parsed_honourifics = ( - _csv(honourifics, HONOURIFICS, "honourifics") if honourific_available else [] - ) - parsed_labels = _csv(labels, SUBMISSIVE_LABELS, "submissive labels") if label_available else [] - parsed_aliases = ( - [] - if aliases.strip() == "-" - else [entry.strip() for entry in aliases.split(",") if entry.strip()] - ) - values: dict[str, object] = { - "pronouns": parsed_pronouns, - "honourifics": parsed_honourifics, - "submissive_labels": parsed_labels, - "dm_status": status, - "bio": bio, - "public_send_stats": bool(stats) if stats_available else False, - "aliases": parsed_aliases if aliases_available else [], - } - if linked: - overrides: list[str] = [] - if pronouns.strip() or "pronouns" in existing_overrides: - overrides.append("pronouns") - if honourific_available and (honourifics.strip() or "honourifics" in existing_overrides): - overrides.append("honourifics") - if label_available and (labels.strip() or "submissive_labels" in existing_overrides): - overrides.append("submissive_labels") - if "dm_status" in existing_overrides: - overrides.append("dm_status") - if bio_overridden: - overrides.append("bio") - if aliases_available and (aliases.strip() or "aliases" in existing_overrides): - overrides.append("aliases") - if stats_available and stats is not None: - overrides.append("public_send_stats") - values["overrides"] = overrides - return values - - def _partial_identity_values( draft: ProfileDraft, field: str, @@ -988,6 +1689,8 @@ def _partial_identity_values( if not stats_available: overrides.discard("public_send_stats") status = draft.document.dm_status.value if draft.document.dm_status else None + profile_color = draft.document.profile_color + public_send_stats = draft.document.public_send_stats if field == "dm-status": if len(selected) != 1: raise ValueError("choose one DM status") @@ -1012,6 +1715,42 @@ def _partial_identity_values( "labels": "submissive_labels", }[field] ) + elif field == "profile-color": + if len(selected) != 1: + raise ValueError("choose one profile colour") + choice = selected[0] + if choice == "inherit": + if not linked: + raise ValueError("only linked profiles can inherit a profile colour") + profile_color = None + overrides.discard("profile_color") + elif choice == "none": + profile_color = None + if linked: + overrides.add("profile_color") + else: + try: + profile_color = int(choice, 16) + except ValueError as exc: + raise ValueError("choose a valid profile colour") from exc + if not 0 <= profile_color <= 0xFFFFFF: + raise ValueError("choose a valid profile colour") + if linked: + overrides.add("profile_color") + elif field == "stats": + if len(selected) != 1: + raise ValueError("choose one send-stat setting") + choice = selected[0] + if choice == "inherit": + if not linked: + raise ValueError("only linked profiles can inherit send stats") + overrides.discard("public_send_stats") + elif choice in {"show", "hide"}: + public_send_stats = choice == "show" + if linked: + overrides.add("public_send_stats") + else: + raise ValueError("choose a valid send-stat setting") else: raise ValueError("choose a valid identity field") pronouns = selected if field == "pronouns" else draft.document.selections.pronouns @@ -1023,8 +1762,9 @@ def _partial_identity_values( "submissive_labels": list(labels), "dm_status": status, "bio": draft.document.bio, - "public_send_stats": draft.document.public_send_stats, + "public_send_stats": public_send_stats, "aliases": list(draft.document.aliases), + "profile_color": profile_color, "complete": False, } if field == "dm-status": @@ -1034,6 +1774,56 @@ def _partial_identity_values( return values +def _identity_step_values( + draft: ProfileDraft, + *, + complete: bool, +) -> dict[str, object]: + values: dict[str, object] = { + "pronouns": list(draft.document.selections.pronouns), + "honourifics": list(draft.document.selections.honourifics), + "submissive_labels": list(draft.document.selections.submissive_labels), + "dm_status": draft.document.dm_status.value if draft.document.dm_status else None, + "bio": draft.document.bio, + "public_send_stats": draft.document.public_send_stats, + "aliases": list(draft.document.aliases), + "profile_color": draft.document.profile_color, + "complete": complete, + } + if _is_linked(draft): + values["overrides"] = list(draft.document.overridden_fields) + return values + + +def _inherit_current_values(draft: ProfileDraft) -> dict[str, object]: + if not _is_linked(draft): + raise ValueError("only linked server profiles can use a global setting") + values = _identity_step_values(draft, complete=False) + overrides = set(draft.document.overridden_fields) + stage = _stage(draft) + if stage is WizardStage.PRONOUNS: + values["pronouns"] = [] + overrides.discard("pronouns") + elif stage is WizardStage.HONOURIFICS: + values["honourifics"] = [] + overrides.discard("honourifics") + elif stage is WizardStage.SUBMISSIVE_LABELS: + values["submissive_labels"] = [] + overrides.discard("submissive_labels") + elif stage is WizardStage.BIO: + values["bio"] = None + overrides.discard("bio") + elif stage is WizardStage.DETAILS: + values["aliases"] = [] + values["public_send_stats"] = False + overrides.discard("aliases") + overrides.discard("public_send_stats") + else: + raise ValueError("this screen has its own global-setting choice") + values["overrides"] = sorted(overrides) + return values + + def _links_step_values( draft: ProfileDraft, *, hidden_inherited_link_ids: Iterable[str] | None = None ) -> dict[str, object]: @@ -1115,6 +1905,7 @@ async def save( hidden_ids: tuple[str, ...], ) -> None: bot = cast("BillBot", interaction.client) + await interaction.response.defer() try: updated = await bot.require_worker().update_draft_step( self.draft.id, @@ -1127,13 +1918,13 @@ async def save( ), ) except WorkerAPIError as exc: - await interaction.response.edit_message( + await interaction.edit_original_response( content=f"Bill could not save inherited visibility: {exc}", view=None, ) return await self.message.edit(view=_wizard_for(interaction, updated)) - await interaction.response.edit_message( + await interaction.edit_original_response( content=f"Hidden {len(hidden_ids)} inherited link(s) in this server.", view=None, ) @@ -1147,44 +1938,91 @@ async def keep_all( await self.save(interaction, ()) -class IdentityModal(discord.ui.Modal, title="Profile identity"): +def _throne_webhook_instructions(webhook_url: str) -> str: + return ( + "Your private Throne webhook URL is shown once below. Do not share it.\n\n" + "1. Open your Throne creator settings.\n" + "2. Find the webhooks section.\n" + "3. Paste this URL and save it.\n" + "4. Run **Test Webhook** in Throne.\n" + "5. Return to Bill and press **Check Connection**.\n" + f"```text\n{webhook_url}\n```" + ) + + +class BioModal(discord.ui.Modal, title="Profile bio"): def __init__(self, draft: ProfileDraft, message: discord.Message) -> None: super().__init__() self.draft, self.message = draft, message - self.aliases = discord.ui.TextInput( - label="Aliases, comma separated (- clears)", - default=", ".join(draft.document.aliases), + self.bio = discord.ui.TextInput( + label="Public bio", + default=draft.document.bio or "", required=False, - max_length=200, - ) - linked = ( - draft.target_scope is DraftScope.SERVER - and draft.server_mode is ServerProfileMode.LINKED - ) - overridden = set(draft.document.overridden_fields) - stats_default = ( - "inherit" - if linked and "public_send_stats" not in overridden - else ("on" if draft.document.public_send_stats else "off") - ) - bio_default = (draft.document.bio or "-") if not linked or "bio" in overridden else "" - self.stats = discord.ui.TextInput( - label="Public send stats: on/off/inherit", - default=stats_default, - required=True, - max_length=7, + max_length=300, + style=discord.TextStyle.paragraph, ) - self.bio = discord.ui.TextInput( - label="Bio (- clears; blank inherits when linked)", - default=bio_default, + self.add_item(self.bio) + + async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: + await interaction.response.defer(ephemeral=True) + bot = cast("BillBot", interaction.client) + overrides = set(self.draft.document.overridden_fields) + value = self.bio.value.strip() or None + if _is_linked(self.draft): + overrides.add("bio") + values = _identity_step_values(self.draft, complete=False) + values["bio"] = value + if _is_linked(self.draft): + values["overrides"] = sorted(overrides) + try: + saved = await bot.require_worker().update_draft_step( + self.draft.id, + step=DraftStepKey.IDENTITY, + owner_user_id=interaction.user.id, + expected_revision=self.draft.revision, + values=values, + ) + updated = await bot.require_worker().set_draft_wizard_stage( + saved.id, + owner_user_id=interaction.user.id, + expected_revision=saved.revision, + stage=_adjacent_stage(saved, forward=True), + ) + except WorkerAPIError as exc: + await interaction.followup.send(f"Bill could not save that bio: {exc}", ephemeral=True) + return + await self.message.edit(view=_wizard_for(interaction, updated)) + await interaction.followup.send("Bio saved.", ephemeral=True) + + +class AliasModal(discord.ui.Modal, title="Profile aliases"): + def __init__(self, draft: ProfileDraft, message: discord.Message) -> None: + super().__init__() + self.draft, self.message = draft, message + self.aliases = discord.ui.TextInput( + label="Aliases, one per line", + default="\n".join(draft.document.aliases), required=False, max_length=300, style=discord.TextStyle.paragraph, ) - for field in (self.aliases, self.stats, self.bio): - self.add_item(field) + self.add_item(self.aliases) async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: + await interaction.response.defer(ephemeral=True) + aliases = tuple( + dict.fromkeys( + line.strip() + for line in self.aliases.value.splitlines() + if line.strip() + ) + ) + values = _identity_step_values(self.draft, complete=False) + values["aliases"] = list(aliases) + if _is_linked(self.draft): + overrides = set(self.draft.document.overridden_fields) + overrides.add("aliases") + values["overrides"] = sorted(overrides) bot = cast("BillBot", interaction.client) try: updated = await bot.require_worker().update_draft_step( @@ -1192,31 +2030,86 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N step=DraftStepKey.IDENTITY, owner_user_id=interaction.user.id, expected_revision=self.draft.revision, - values=_identity_values( - self.draft, - ", ".join(self.draft.document.selections.pronouns), - ", ".join(self.draft.document.selections.honourifics), - ", ".join(self.draft.document.selections.submissive_labels), - self.aliases.value, - self.stats.value, - self.bio.value, - ), + values=values, ) - except (ValueError, WorkerAPIError) as exc: + except WorkerAPIError as exc: + await interaction.followup.send( + f"Bill could not save those aliases: {exc}", + ephemeral=True, + ) + return + await self.message.edit(view=_wizard_for(interaction, updated)) + await interaction.followup.send("Aliases saved.", ephemeral=True) + + +class ProfileColorModal(discord.ui.Modal, title="Custom profile colour"): + def __init__(self, draft: ProfileDraft, message: discord.Message) -> None: + super().__init__() + self.draft, self.message = draft, message + default = ( + f"#{draft.document.profile_color:06X}" + if draft.document.profile_color is not None + else "" + ) + self.color = discord.ui.TextInput( + label="Hex colour (#RRGGBB or RRGGBB)", + default=default, + min_length=6, + max_length=7, + ) + self.add_item(self.color) + + async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: + value = self.color.value.strip() + if re.fullmatch(r"#?[0-9A-Fa-f]{6}", value) is None: await interaction.response.send_message( - f"Bill could not save identity: {exc}", ephemeral=True + "Enter exactly six hexadecimal digits, with an optional leading `#`.", + ephemeral=True, + ) + return + color = int(value.removeprefix("#"), 16) + values = _identity_step_values(self.draft, complete=False) + values["profile_color"] = color + if _is_linked(self.draft): + overrides = set(self.draft.document.overridden_fields) + overrides.add("profile_color") + values["overrides"] = sorted(overrides) + await interaction.response.defer(ephemeral=True) + bot = cast("BillBot", interaction.client) + try: + updated = await bot.require_worker().update_draft_step( + self.draft.id, + step=DraftStepKey.IDENTITY, + owner_user_id=interaction.user.id, + expected_revision=self.draft.revision, + values=values, + ) + except WorkerAPIError as exc: + await interaction.followup.send( + f"Bill could not save that colour: {exc}", + ephemeral=True, ) return await self.message.edit(view=_wizard_for(interaction, updated)) - await interaction.response.send_message("Identity saved.", ephemeral=True) + await interaction.followup.send(f"Saved custom colour **#{color:06X}**.", ephemeral=True) class LinkModal(discord.ui.Modal, title="Add a profile link"): def __init__( - self, draft: ProfileDraft, message: discord.Message, link_id: str | None = None + self, + draft: ProfileDraft, + message: discord.Message, + *, + link_type: LinkType, + link_id: str | None = None, ) -> None: super().__init__() - self.draft, self.message, self.link_id = draft, message, link_id + self.draft, self.message, self.link_id, self.link_type = ( + draft, + message, + link_id, + link_type, + ) link = next((item for item in draft.document.links if item.id == link_id), None) self.label = discord.ui.TextInput( label="Public label", default=link.public_label if link else "", max_length=40 @@ -1224,11 +2117,6 @@ def __init__( self.url = discord.ui.TextInput( label="HTTPS URL", default=link.normalized_url if link else "", max_length=500 ) - self.kind = discord.ui.TextInput( - label="Type: social or payment", - default=link.link_type.value if link else "social", - max_length=7, - ) self.platform = discord.ui.TextInput( label="Platform (optional)", default=link.platform if link else "", @@ -1237,19 +2125,18 @@ def __init__( ) self.add_item(self.label) self.add_item(self.url) - self.add_item(self.kind) self.add_item(self.platform) async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: + await interaction.response.defer(ephemeral=True) bot = cast("BillBot", interaction.client) try: - kind = LinkType(self.kind.value.strip().casefold()) common = { "owner_user_id": interaction.user.id, "expected_revision": self.draft.revision, "public_label": self.label.value, "normalized_url": self.url.value, - "link_type": kind, + "link_type": self.link_type, "platform": self.platform.value or None, } updated = await ( @@ -1258,13 +2145,13 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N else bot.require_worker().add_link(self.draft.id, **common) ) except (ValueError, WorkerAPIError) as exc: - await interaction.response.send_message( + await interaction.followup.send( f"Bill could not save that link: {exc}", ephemeral=True ) return await self.message.edit(view=_wizard_for(interaction, updated)) - await interaction.response.send_message( - "Link saved. Choose **Done** when your links are ready.", ephemeral=True + await interaction.followup.send( + "Link saved. Choose **Continue** when your links are ready.", ephemeral=True ) @@ -1278,6 +2165,7 @@ def __init__(self, draft: ProfileDraft, message: discord.Message) -> None: self.add_item(self.url) async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: + await interaction.response.defer(ephemeral=True) bot = cast("BillBot", interaction.client) try: result = await bot.require_worker().create_link_import( @@ -1287,7 +2175,7 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N source_url=self.url.value, ) except WorkerAPIError as exc: - await interaction.response.send_message( + await interaction.followup.send( f"Bill could not import that page: {exc}", ephemeral=True ) return @@ -1299,7 +2187,7 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N ) or "No public links found" ) - await interaction.response.send_message( + await interaction.followup.send( f"Imported candidates: {labels}", view=ImportConfirmView( result.draft, @@ -1319,36 +2207,81 @@ def __init__(self, draft: ProfileDraft, message: discord.Message) -> None: self.add_item(self.throne) async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: + await interaction.response.defer(ephemeral=True) bot = cast("BillBot", interaction.client) try: - attached = await bot.require_worker().attach_throne( + resolved = await bot.require_worker().resolve_throne( self.draft.id, owner_user_id=interaction.user.id, expected_revision=self.draft.revision, throne_input=self.throne.value, ) - updated = await bot.require_worker().update_draft_step( - attached.draft.id, - step=DraftStepKey.THRONE, - owner_user_id=interaction.user.id, - expected_revision=attached.draft.revision, - values={ - "throne_creator_id": attached.draft.document.throne_creator_id, - "preferred_payment_link_id": attached.draft.document.preferred_payment_link_id, - }, - ) except WorkerAPIError as exc: - await interaction.response.send_message( + await interaction.followup.send( f"Bill could not connect that Throne account: {exc}", ephemeral=True ) return - await self.message.edit(view=_wizard_for(interaction, updated)) - text = "Throne connected." - if attached.webhook_url: - text += ( - f" Your private webhook URL (save it now):\n```text\n{attached.webhook_url}\n```" - ) - await interaction.response.send_message(text, ephemeral=True) + await self.message.edit(view=_wizard_for(interaction, resolved.draft)) + suffix = ( + " It is already verified; confirm it to continue." + if resolved.already_verified + else " Confirm it before Bill creates the private connection." + ) + await interaction.followup.send( + f"Found **@{safe_text(resolved.handle, limit=80)}**.{suffix}", + ephemeral=True, + ) + + +class ProfileModalLauncherView(discord.ui.View): + """Keeps modal defaults while acknowledging the Worker-backed draft reload first.""" + + def __init__( + self, + draft: ProfileDraft, + message: discord.Message, + action: str, + ) -> None: + super().__init__(timeout=180) + self.draft, self.message, self.action = draft, message, action + labels = { + "bio": "Open bio editor", + "aliases": "Open alias editor", + "custom-color": "Open colour editor", + "link-social": "Open social-link editor", + "link-payment": "Open payment-link editor", + "import": "Open import form", + "throne": "Open Throne form", + } + button = discord.ui.Button(label=labels[action], style=discord.ButtonStyle.primary) + button.callback = self.open_modal + self.add_item(button) + + async def interaction_check(self, interaction: discord.Interaction[discord.Client]) -> bool: + if str(interaction.user.id) == self.draft.owner_user_id: + return True + await interaction.response.send_message( + "Only the profile owner can open this editor.", ephemeral=True + ) + return False + + async def open_modal(self, interaction: discord.Interaction[discord.Client]) -> None: + modal: discord.ui.Modal + if self.action == "bio": + modal = BioModal(self.draft, self.message) + elif self.action == "aliases": + modal = AliasModal(self.draft, self.message) + elif self.action == "custom-color": + modal = ProfileColorModal(self.draft, self.message) + elif self.action == "link-social": + modal = LinkModal(self.draft, self.message, link_type=LinkType.SOCIAL) + elif self.action == "link-payment": + modal = LinkModal(self.draft, self.message, link_type=LinkType.PAYMENT) + elif self.action == "import": + modal = LinkImportModal(self.draft, self.message) + else: + modal = ThroneModal(self.draft, self.message) + await interaction.response.send_modal(modal) class ImportConfirmView(discord.ui.View): @@ -1372,6 +2305,7 @@ async def confirm( self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button ) -> None: bot = cast("BillBot", interaction.client) + await interaction.response.defer() try: result = await bot.require_worker().confirm_link_import( self.draft.id, @@ -1381,11 +2315,11 @@ async def confirm( candidate_ids=self.candidate_ids, ) except WorkerAPIError as exc: - await interaction.response.edit_message( + await interaction.edit_original_response( content=f"Bill could not confirm these links: {exc}", view=None ) return - await interaction.response.edit_message( + await interaction.edit_original_response( content=f"Added {result.added_link_count} link(s).", view=None ) await self.message.edit(view=_wizard_for(interaction, result.draft)) @@ -1408,13 +2342,22 @@ def __init__(self, draft: ProfileDraft, link_id: str, message: discord.Message) async def edit( self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button ) -> None: - await interaction.response.send_modal(LinkModal(self.draft, self.message, self.link_id)) + link = next(item for item in self.draft.document.links if item.id == self.link_id) + await interaction.response.send_modal( + LinkModal( + self.draft, + self.message, + link_type=link.link_type, + link_id=self.link_id, + ) + ) @discord.ui.button(label="Remove", style=discord.ButtonStyle.danger) async def remove( self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button ) -> None: bot = cast("BillBot", interaction.client) + await interaction.response.defer() try: updated = await bot.require_worker().delete_link( self.draft.id, @@ -1423,12 +2366,12 @@ async def remove( expected_revision=self.draft.revision, ) except WorkerAPIError as exc: - await interaction.response.edit_message( + await interaction.edit_original_response( content=f"Bill could not remove that link: {exc}", view=None ) return await self.message.edit(view=_wizard_for(interaction, updated)) - await interaction.response.edit_message(content="Link removed.", view=None) + await interaction.edit_original_response(content="Link removed.", view=None) @discord.ui.button(label="Prefer payment", style=discord.ButtonStyle.secondary) async def preferred( @@ -1441,6 +2384,7 @@ async def preferred( ) return bot = cast("BillBot", interaction.client) + await interaction.response.defer() try: updated = await bot.require_worker().edit_link( self.draft.id, @@ -1456,12 +2400,12 @@ async def preferred( preferred=True, ) except WorkerAPIError as exc: - await interaction.response.edit_message( + await interaction.edit_original_response( content=f"Bill could not prefer that link: {exc}", view=None ) return await self.message.edit(view=_wizard_for(interaction, updated)) - await interaction.response.edit_message( + await interaction.edit_original_response( content="Preferred payment link updated.", view=None ) @@ -1476,6 +2420,7 @@ async def confirm( self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button ) -> None: bot = cast("BillBot", interaction.client) + await interaction.response.defer() try: draft = await bot.require_worker().restart_draft( self.draft.id, @@ -1483,11 +2428,11 @@ async def confirm( expected_revision=self.draft.revision, ) except WorkerAPIError as exc: - await interaction.response.edit_message( + await interaction.edit_original_response( content=f"Bill could not restart this draft: {exc}", view=None ) return - await interaction.response.edit_message( + await interaction.edit_original_response( content="Draft restarted. Use `/profile` to reopen its private wizard.", view=None ) try: diff --git a/bill/components/public_profile.py b/bill/components/public_profile.py index 346faa7..b8e8b5b 100644 --- a/bill/components/public_profile.py +++ b/bill/components/public_profile.py @@ -64,11 +64,14 @@ def profile_links_view( *, kind: LinkType, presentation: MemberPresentation, + profile_color: int | None = None, ) -> discord.ui.LayoutView: """Render one viewer-safe link detail surface with compact HTTPS button rows.""" title = "Payment Links" if kind is LinkType.PAYMENT else "Socials" view = discord.ui.LayoutView(timeout=180) - container = discord.ui.Container() + container = discord.ui.Container( + accent_color=None if profile_color is None else discord.Color(profile_color) + ) container.add_item(discord.ui.TextDisplay(f"-# Bill Profile · {title}")) container.add_item( _profile_section( @@ -97,7 +100,9 @@ def public_profile_view( ) -> discord.ui.LayoutView: """Render public data only; webhook identifiers and URLs never enter this view.""" view = discord.ui.LayoutView(timeout=None) - container = discord.ui.Container(accent_color=discord.Color.blurple()) + container = discord.ui.Container( + accent_color=None if profile.profile_color is None else discord.Color(profile.profile_color) + ) container.add_item(discord.ui.TextDisplay("-# Bill Profile")) container.add_item( _profile_section( @@ -231,6 +236,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No links, kind=self.kind, presentation=presentation, + profile_color=result.profile.profile_color, ), ephemeral=True, ) diff --git a/bill/components/setup.py b/bill/components/setup.py index 04ec230..61bf03f 100644 --- a/bill/components/setup.py +++ b/bill/components/setup.py @@ -99,7 +99,7 @@ def setup_view( ) -> discord.ui.LayoutView: presentation = presentation or GuildPresentation("This server", "Server administrator") view = discord.ui.LayoutView(timeout=None) - container = discord.ui.Container(accent_color=discord.Color.blurple()) + container = discord.ui.Container() container.add_item(discord.ui.TextDisplay("-# Bill Server Setup")) title = f"### {_escape(presentation.guild_name)}" current_step = ( @@ -129,16 +129,20 @@ def setup_view( container.add_item( discord.ui.TextDisplay( f"> **Posting channel:** <#{session.selected_channel_id}>\n" - "> Bill is ready to post new Throne sends for this server." + "> Bill is ready to post new Throne sends for this server. Confirming setup " + "does not change any other server settings." ) ) elif session.selected_channel_id: container.add_item( discord.ui.TextDisplay( f"> **Selected channel:** <#{session.selected_channel_id}>\n" - "> Confirm to make this the public destination for new Throne sends." + "> Confirm to save this as the public destination for new Throne sends. Bill " + "must be able to view the channel, send messages, embed links, and read message " + "history." ) ) + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) container.add_item( discord.ui.ActionRow( discord.ui.Button( @@ -152,7 +156,8 @@ def setup_view( container.add_item( discord.ui.TextDisplay( "Choose the public text channel where Bill should post new Throne sends. " - "You can review the choice before confirming." + "Bill needs **View Channel**, **Send Messages**, **Embed Links**, and " + "**Read Message History** there. You can review the choice before confirming." ) ) container.add_item( diff --git a/bill/worker_client.py b/bill/worker_client.py index afcdd11..3de8ac3 100644 --- a/bill/worker_client.py +++ b/bill/worker_client.py @@ -63,6 +63,20 @@ class DraftStepKey(StrEnum): REVIEW = "review" +class WizardStage(StrEnum): + ORIENTATION = "orientation" + PRONOUNS = "pronouns" + HONOURIFICS = "honourifics" + SUBMISSIVE_LABELS = "submissive_labels" + DM_STATUS = "dm_status" + BIO = "bio" + PROFILE_COLOR = "profile_color" + LINKS = "links" + THRONE = "throne" + DETAILS = "details" + REVIEW = "review" + + class LinkType(StrEnum): SOCIAL = "social" PAYMENT = "payment" @@ -147,6 +161,7 @@ class PublicProfile: send_stats: tuple[SendStat, ...] | None version: int published_at: str | None + profile_color: int | None = None @dataclass(frozen=True, slots=True) @@ -174,6 +189,7 @@ class DraftDocument: hidden_inherited_link_ids: tuple[str, ...] throne_creator_id: str | None preferred_payment_link_id: str | None + profile_color: int | None = None @dataclass(frozen=True, slots=True) @@ -188,6 +204,12 @@ class ThronePrefill: existing_registration_creator_id: str | None +@dataclass(frozen=True, slots=True) +class ThronePending: + handle: str + expires_at: str | None + + @dataclass(frozen=True, slots=True) class ProfileDraft: id: str @@ -209,6 +231,10 @@ class ProfileDraft: updated_at: str | None published_at: str | None dm_status_selected: bool = False + wizard_stage: WizardStage | None = None + wizard_substep: str | None = None + throne_pending: ThronePending | None = None + resolved_profile_color: int | None = None @dataclass(frozen=True, slots=True) @@ -260,6 +286,20 @@ class ThroneDraftResult: webhook_state: str +@dataclass(frozen=True, slots=True) +class ThroneResolveResult: + draft: ProfileDraft + handle: str + already_verified: bool + + +@dataclass(frozen=True, slots=True) +class ThroneDraftStatus: + handle: str | None + verified: bool + verified_at: str | None + + @dataclass(frozen=True, slots=True) class GuildSetupSession: id: str @@ -320,6 +360,15 @@ def _integer(value: object, field: str) -> int: raise WorkerAPIError(f"Worker returned an invalid {field}") from exc +def _optional_color(value: object, field: str = "profile_color") -> int | None: + if value is None: + return None + color = _integer(value, field) + if not 0 <= color <= 0xFFFFFF: + raise WorkerAPIError(f"Worker returned an invalid {field}") + return color + + def _enum(enum_type: type[StrEnum], value: object, field: str) -> StrEnum: try: return enum_type(_string(value, field)) @@ -444,6 +493,26 @@ async def update_draft_step( ) return self._parse_draft(data.get("draft")) + async def set_draft_wizard_stage( + self, + draft_id: str, + *, + owner_user_id: int | str, + expected_revision: int, + stage: WizardStage, + substep: str | None = None, + ) -> ProfileDraft: + values: dict[str, JSONValue] = { + "stage": stage.value, + "substep": substep, + } + data = await self._request( + "PUT", + f"/v1/profile-drafts/{draft_id}/wizard-stage", + json=self._mutation(owner_user_id, expected_revision, values), + ) + return self._parse_draft(data.get("draft")) + async def restart_draft( self, draft_id: str, *, owner_user_id: int | str, expected_revision: int ) -> ProfileDraft: @@ -593,6 +662,7 @@ async def attach_throne( expected_revision: int, throne_input: str | None = None, existing_creator_id: str | None = None, + confirm_pending: bool = False, rotate_webhook: bool = False, ) -> ThroneDraftResult: data = await self._request( @@ -604,14 +674,36 @@ async def attach_throne( { "throne_input": throne_input, "existing_creator_id": existing_creator_id, + "confirm_pending": confirm_pending, "rotate_webhook": rotate_webhook, }, ), ) - return ThroneDraftResult( - await self.get_draft(draft_id, owner_user_id=owner_user_id), - _optional_string(data.get("webhook_url")), - _string(data.get("webhook_state"), "webhook_state"), + return self._parse_throne_result(data) + + async def resolve_throne( + self, + draft_id: str, + *, + owner_user_id: int | str, + expected_revision: int, + throne_input: str, + ) -> ThroneResolveResult: + data = await self._request( + "POST", + f"/v1/profile-drafts/{draft_id}/throne/resolve", + json=self._mutation( + owner_user_id, + expected_revision, + {"throne_input": throne_input}, + ), + ) + handle = _string(data.get("handle"), "Throne handle") + already_verified = _bool(data.get("already_verified"), "already_verified") + return ThroneResolveResult( + self._parse_draft(data.get("draft")), + handle, + already_verified, ) async def rotate_throne( @@ -622,10 +714,24 @@ async def rotate_throne( f"/v1/profile-drafts/{draft_id}/throne/rotate", json=self._mutation(owner_user_id, expected_revision), ) - return ThroneDraftResult( - await self.get_draft(draft_id, owner_user_id=owner_user_id), - _optional_string(data.get("webhook_url")), - _string(data.get("webhook_state"), "webhook_state"), + return self._parse_throne_result(data) + + async def get_throne_status( + self, + draft_id: str, + *, + owner_user_id: int | str, + expected_revision: int, + ) -> ThroneDraftStatus: + data = await self._request( + "GET", + f"/v1/profile-drafts/{draft_id}/throne/status" + f"?owner_user_id={_snowflake(owner_user_id)}&expected_revision={expected_revision}", + ) + return ThroneDraftStatus( + _optional_string(data.get("handle")), + _bool(data.get("verified"), "verified"), + _optional_string(data.get("verified_at")), ) async def start_guild_setup( @@ -813,6 +919,7 @@ def _parse_profile(value: object) -> PublicProfile: stats, _integer(data.get("version"), "version"), _optional_string(data.get("published_at")), + _optional_color(data.get("profile_color")), ) @staticmethod @@ -821,6 +928,14 @@ def _parse_draft(value: object) -> ProfileDraft: document = _record(data.get("document"), "draft document") prefill = data.get("throne_prefill") parsed_prefill = None if prefill is None else WorkerClient._parse_prefill(prefill) + pending = data.get("throne_pending") + parsed_pending = None + if pending is not None: + pending_data = _record(pending, "pending Throne confirmation") + parsed_pending = ThronePending( + _string(pending_data.get("handle"), "pending Throne handle"), + _optional_string(pending_data.get("expires_at")), + ) current = data.get("current_step") next_step = data.get("next_step") governing = data.get("governing_orientation") @@ -856,12 +971,17 @@ def _parse_draft(value: object) -> ProfileDraft: _strings(document.get("hidden_inherited_link_ids"), "hidden_inherited_link_ids"), _optional_string(document.get("throne_creator_id")), _optional_string(document.get("preferred_payment_link_id")), + _optional_color(document.get("profile_color")), ), parsed_prefill, _optional_string(data.get("created_at")), _optional_string(data.get("updated_at")), _optional_string(data.get("published_at")), _bool(data.get("dm_status_selected"), "dm_status_selected"), + _nullable_enum(WizardStage, data.get("wizard_stage"), "wizard_stage"), + _optional_string(data.get("wizard_substep")), + parsed_pending, + _optional_color(data.get("resolved_profile_color"), "resolved_profile_color"), ) @staticmethod diff --git a/docs/codebase-guide.md b/docs/codebase-guide.md index 6fbec5c..c79bf3d 100644 --- a/docs/codebase-guide.md +++ b/docs/codebase-guide.md @@ -7,9 +7,10 @@ interaction state, D1 state, and Throne delivery behavior consistent. 1. `bill/settings.py`, then `worker/src/env.ts`: the same home-guild and API boundary configuration is validated on both sides. -2. `worker/migrations/0001_init.sql`, `0002_profile_system.sql`, and - `0003_links_and_setup.sql`: existing send tracking first, then additive - profile documents/drafts, then link imports/setup sessions/attribution. +2. `worker/migrations/0001_init.sql` through the additive `0004` profile-wizard + migration: existing send tracking first, then profile documents/drafts, link + imports/setup sessions/attribution, and finally profile colour plus resumable + wizard stage state. 3. `worker/src/profile/contracts.ts` and `documentStore.ts`: fixed values, validation limits, immutable document snapshots, and guarded batch writes. 4. `draftService.ts`, `resolver.ts`, and `publishService.ts`: durable editing, @@ -49,6 +50,9 @@ overrides, publication history, and drafts. `0003` adds static link imports, public guild setup sessions, nullable future-send attribution, and `profile_managed` registration provenance. Existing registrations default to legacy provenance and are never silently replaced by a profile. +Migration `0004` is additive over populated `0001`-`0003` databases. It adds the +nullable RGB accent and durable wizard stage/substep state, with deterministic +backfill from each active draft's existing step and document state. A published document is never edited. Starting an edit clones the currently applicable snapshot into a private draft document. A root points to one @@ -79,25 +83,34 @@ an equivalent rollback tripwire. ```mermaid stateDiagram-v2 [*] --> Orientation - Orientation --> Identity - Identity --> Links + Orientation --> Pronouns + Pronouns --> Titles + Titles --> Labels + Pronouns --> Labels + Titles --> DMStatus + Labels --> DMStatus + DMStatus --> Bio + Bio --> Colour + Colour --> Links Links --> Throne: Dom/me or switch - Links --> Review: Submissive - Throne --> Review + Links --> Details: Submissive + Throne --> Details: Switch + Throne --> Review: Dom/me + Details --> Review Review --> Published: Publish CAS succeeds - Review --> Identity: Edit identity + Review --> Pronouns: Edit identity Review --> Links: Edit links Review --> Throne: Edit Throne ``` -Linked server drafts omit orientation and Throne because both come from the -live global profile. Completed sections render collapsed summaries, but D1 step -rows—not the rendered message—decide what is complete. Custom IDs bind the -draft/session, revision, action, user, and guild context. Dynamic persistent -items route interactions after bot restarts; each callback still reloads and -re-authorizes the durable record. UUIDs, snowflakes, and revisions use -reversible compact encodings so this context remains within Discord's -100-character custom-ID limit. +Linked server drafts omit orientation and Throne because both come from the live +global profile. D1 step rows own logical completion and the wizard stage/substep +owns the exact screen. +Custom IDs bind the draft/session, revision, action, user, and guild context. +Dynamic persistent items route interactions after bot restarts; each callback +still reloads and re-authorizes the durable record. UUIDs, snowflakes, and +revisions use reversible compact encodings so this context remains within +Discord's 100-character custom-ID limit. ## Global, linked, and independent resolution @@ -106,6 +119,9 @@ The home guild reads `global_profiles`. Another guild requires a Linked roots read the latest global document, apply only fields with explicit override markers, remove explicitly hidden inherited links, add local links, and choose the first valid visible payment fallback deterministically. +`profile_color` is sparse in the same way: no override means inherit, an +override with `null` means deliberately publish without an accent, and an RGB +integer means use that local colour. This design prevents global edits from becoming stale copies while still allowing a member to clear a field or hide one link in a particular server. @@ -174,7 +190,7 @@ order is: 1. Set Worker `BILL_HOME_GUILD_ID`. 2. `cd worker && npx wrangler d1 migrations apply bill --remote` 3. `npx wrangler deploy` -4. Set the same bot `BILL_HOME_GUILD_ID` and restart the bot. +4. Restart the bot only after the Worker deployment is live. See `docs/deployment.md` for the complete host setup and required secrets. diff --git a/docs/deployment.md b/docs/deployment.md index 572bb9e..af59584 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -38,9 +38,20 @@ npx wrangler d1 migrations apply bill --remote npx wrangler deploy ``` -The exact live order is: Worker `BILL_HOME_GUILD_ID`, D1 migrations, Worker -deploy, bot `BILL_HOME_GUILD_ID`, bot restart. Migrations `0002` and `0003` are -additive over populated `0001`; do not edit or re-run `0001` manually. +For the guided-profile release, the exact live order is: + +```bash +cd worker +npx wrangler d1 migrations apply bill --remote +npx wrangler deploy +sudo systemctl restart bill-bot +``` + +The remote additive migration must complete before the Worker uses its new +columns, and the bot must not restart until that Worker deployment is live. +Migrations `0002`, `0003`, and `0004` are additive over populated `0001`; do not +edit, reset, or manually re-run `0001`-`0003`. Preserve the configured production +D1 ID and `usebill.dev` route above. Use a long randomly generated bot API token. Set `THRONE_PUBLIC_KEY_PEM` to Throne's current Ed25519 public key. @@ -85,8 +96,9 @@ the send channel. In each server: channel-selection flow. 2. Members run `/profile` to create the applicable profile. 3. Dom/me and switch profiles may connect Throne in the private DM wizard. -4. Add any newly issued private webhook URL to Throne. -5. Use Throne's webhook test and confirm that a real supported event posts once. +4. Follow Bill's private numbered instructions to save any newly issued webhook + URL in Throne. +5. Run Throne's **Test Webhook**, return to Bill, and press **Check Connection**. Never paste a webhook URL into a public channel, issue, log, or deployment output. Bill can show a new URL only on initial issue or explicit rotation. diff --git a/docs/profiles.md b/docs/profiles.md index db5b54e..2d4a18e 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -24,8 +24,14 @@ member never start setup. Drafts are private D1 records. Each mutation carries the last observed revision; stale, foreign-user, wrong-guild, and completed controls fail safely. -Fixed identity selections and the DM-status menu are persisted without -completing the identity step, so a restart can reconstruct partial progress. +The wizard edits one Components V2 message in place and durably stores its exact +stage and substep before rerendering. The guided order is orientation, pronouns, +conditional titles/labels, DM status, bio, profile colour, links, conditional +Throne and/or aliases/stats, then review. Conditional stages change the displayed +`Step X of Y` total. Back, Continue, resume, and review edits therefore reconstruct +the same screen after a restart instead of inferring progress from Discord. +Fixed selections are persisted without prematurely completing their logical +profile section. Global and independent profiles must deliberately choose Open, By Request, After Tribute, or Closed before saving identity. A linked server profile may instead choose **Use global setting**, which removes its server-specific DM @@ -43,6 +49,21 @@ Throne. Submissive and switch profiles support up to three aliases and the public send-stat preference. Switch profiles support both honourifics and submissive labels. +Published profile cards may use Bill's documented Blue, Purple, Rose, Red, +Orange, Gold, Emerald, or Teal presets, a strict six-digit custom RGB hex value, +or no colour. Setup containers remain neutral. A linked server profile inherits +the global colour unless the member explicitly selects a local colour or +deliberately clears it to **No colour**. + +Throne connection is a verified flow rather than a success-shaped attachment. +Bill resolves the submitted profile, asks the member to confirm the handle, and +then issues or reuses the private webhook. The owner sees a numbered set of +generic Throne navigation instructions and the URL once. **Check Connection** +queries the Worker for `webhook_verified_at`; an unverified test remains on the +same screen with troubleshooting, while a verified test unlocks Continue. An +already-owned verified creator can continue without rotating its secret, and +Skip remains available. + ## Links and privacy Profiles resolve at most twelve enabled links. Labels are at most 40 characters diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 59a0087..f990e0c 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -12,18 +12,27 @@ from bill.components.profile import ( DM_STATUS_OPTIONS, ORIENTATION_LABELS, + PROFILE_COLOR_OPTIONS, PROFILE_WIZARD_BUTTON_ACTIONS, PROFILE_WIZARD_SELECT_ACTIONS, + AliasModal, + BioModal, DmStatusSelect, - IdentityModal, + LinkModal, MemberPresentation, + ProfileColorModal, + ProfileColorSelect, + ProfileModalLauncherView, ProfileSelectDynamic, ProfileWizardDynamic, - _identity_values, + StatsSelect, + _adjacent_stage, _partial_identity_values, + _validate_continue, profile_intro_view, profile_wizard_view, wizard_custom_id, + wizard_stages, ) from bill.components.public_profile import profile_links_view, public_profile_view from bill.components.setup import GuildPresentation, setup_custom_id, setup_view @@ -44,6 +53,9 @@ PublicProfile, SendStat, ServerProfileMode, + ThroneDraftResult, + ThronePending, + WizardStage, WorkerClient, ) @@ -142,14 +154,10 @@ def _all_items(view: discord.ui.LayoutView) -> list[discord.ui.Item[Any]]: def _rows(view: discord.ui.LayoutView) -> list[discord.ui.ActionRow[Any]]: - return [ - item - for item in _all_items(view) - if isinstance(item, discord.ui.ActionRow) - ] + return [item for item in _all_items(view) if isinstance(item, discord.ui.ActionRow)] -def _profile(*, empty: bool = False) -> PublicProfile: +def _profile(*, empty: bool = False, profile_color: int | None = None) -> PublicProfile: return PublicProfile( DraftScope.GLOBAL, None, @@ -189,6 +197,7 @@ def _profile(*, empty: bool = False) -> PublicProfile: None if empty else (SendStat("USD", 2, 1234), SendStat("EUR", 1, 500)), 1, None, + profile_color, ) @@ -228,6 +237,7 @@ async def test_profile_lookup_is_parsed_into_frozen_contracts() -> None: "send_stats": None, "version": 4, "published_at": "2026-01-01T00:00:00Z", + "profile_color": 0x5865F2, }, }, } @@ -238,6 +248,7 @@ async def test_profile_lookup_is_parsed_into_frozen_contracts() -> None: assert lookup.profile is not None assert lookup.profile.orientation is Orientation.DOMME + assert lookup.profile.profile_color == 0x5865F2 assert lookup.profile.links[0].link_type is LinkType.PAYMENT assert session.last_kwargs["headers"]["Authorization"] == "Bearer secret" @@ -293,9 +304,7 @@ def test_orientation_wizard_uses_v2_container_and_all_four_options() -> None: assert len(ORIENTATION_LABELS) == 4 encoded = str(view.to_components()) assert "-# Bill Profile Setup" in encoded - sections = [ - item for item in _all_items(view) if isinstance(item, discord.ui.Section) - ] + sections = [item for item in _all_items(view) if isinstance(item, discord.ui.Section)] assert len(sections) == 1 assert isinstance(sections[0].accessory, discord.ui.Thumbnail) assert "bill:p:rdraft_1:1:2:3:orientation" in encoded @@ -324,9 +333,7 @@ def test_public_profile_escapes_bio_and_exposes_only_safe_link_controls() -> Non assert "Aliases" in encoded assert "Throne" in encoded assert "USD" in encoded and "EUR" in encoded - assert len( - [item for item in _all_items(view) if isinstance(item, discord.ui.Separator)] - ) == 2 + assert len([item for item in _all_items(view) if isinstance(item, discord.ui.Separator)]) == 2 section = next(item for item in _all_items(view) if isinstance(item, discord.ui.Section)) assert isinstance(section.accessory, discord.ui.Thumbnail) @@ -350,9 +357,7 @@ def test_public_profile_hides_empty_sections_and_viewer_edit_control() -> None: ): assert hidden not in encoded assert not any(isinstance(item, discord.ui.Thumbnail) for item in _all_items(view)) - assert len( - [item for item in _all_items(view) if isinstance(item, discord.ui.Separator)] - ) == 1 + assert len([item for item in _all_items(view) if isinstance(item, discord.ui.Separator)]) == 1 def test_public_profile_viewer_keeps_link_controls_without_owner_edit() -> None: @@ -433,8 +438,7 @@ def test_every_profile_wizard_state_has_compact_v2_structure(step: DraftStepKey) encoded = str(view.to_components()) assert "-# Bill Profile Setup" in encoded - assert "Current step" in encoded - assert "Orientation" in encoded + assert "Step " in encoded assert any(isinstance(item, discord.ui.Section) for item in _all_items(view)) assert len(_all_items(view)) <= 40 assert all(len(row.children) <= 5 for row in _rows(view)) @@ -462,7 +466,7 @@ def test_profile_wizard_collapses_throne_state_without_exposing_creator_id() -> encoded = str(profile_wizard_view(state).to_components()) - assert "Throne connected" in encoded + assert "Throne:** Connected" in encoded assert "private_creator_id" not in encoded @@ -486,11 +490,47 @@ def test_review_exposes_revision_bound_dm_status_editor_alongside_identity_modal ] assert len(dm_selects) == 1 - assert dm_selects[0].custom_id == wizard_custom_id(state, "identity-dm-status") + assert dm_selects[0].custom_id == wizard_custom_id(state, "dm-status") assert [option.value for option in dm_selects[0].options if option.default] == ["open"] assert len(edit_buttons) == 1 +def test_throne_confirmation_screen_survives_restart_without_secret_material() -> None: + state = replace( + draft(next_step=DraftStepKey.THRONE), + current_step=DraftStepKey.THRONE, + governing_orientation=Orientation.DOMME, + wizard_stage=WizardStage.THRONE, + wizard_substep="confirm", + throne_pending=ThronePending("resolvedqueen", "2026-08-23T12:00:00Z"), + ) + + encoded = str(profile_wizard_view(state).to_components()) + + assert "@resolvedqueen" in encoded + assert "Yes, connect this handle" in encoded + assert "Try another handle" in encoded + assert "confirmation_token" not in encoded + assert "/t/" not in encoded + + +def test_verified_throne_edit_preserves_return_to_review() -> None: + state = replace( + identity_draft(), + current_step=DraftStepKey.THRONE, + next_step=DraftStepKey.THRONE, + governing_orientation=Orientation.DOMME, + wizard_stage=WizardStage.THRONE, + wizard_substep="review:verified", + document=replace(identity_draft().document, throne_creator_id="creator"), + ) + + _validate_continue(state) + + assert _adjacent_stage(state, forward=True) is WizardStage.REVIEW + assert "confirmed" in str(profile_wizard_view(state).to_components()).casefold() + + def test_profile_intro_start_control_is_persistent_and_disjoint() -> None: view = profile_intro_view(draft()) button = view.children[0] @@ -519,6 +559,10 @@ class StartResponse: def __init__(self) -> None: self.content: str | None = "unchanged" self.view: discord.ui.LayoutView | None = None + self.deferred = False + + async def defer(self) -> None: + self.deferred = True async def edit_message( self, @@ -535,6 +579,7 @@ async def edit_message( guild_id=None, message=object(), response=response, + edit_original_response=response.edit_message, ) item = discord.ui.Button( label="Start", @@ -545,11 +590,281 @@ async def edit_message( await dynamic.callback(interaction) # type: ignore[arg-type] assert loaded == [("draft_1", 1)] + assert response.deferred assert response.content is None assert response.view is not None assert "-# Bill Profile Setup" in str(response.view.to_components()) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("action", "expected_stage"), + (("identity", WizardStage.PRONOUNS), ("links", WizardStage.LINKS)), +) +async def test_legacy_button_actions_reload_stale_draft_and_redirect( + action: str, + expected_stage: WizardStage, +) -> None: + state = replace(identity_draft(), revision=8, wizard_stage=WizardStage.REVIEW) + moves: list[tuple[int, WizardStage, str | None]] = [] + rendered: list[discord.ui.LayoutView] = [] + + class Worker: + async def get_draft(self, draft_id: str, *, owner_user_id: int) -> ProfileDraft: + assert (draft_id, owner_user_id) == (state.id, 1) + return state + + async def set_draft_wizard_stage( + self, + draft_id: str, + *, + owner_user_id: int, + expected_revision: int, + stage: WizardStage, + substep: str | None, + ) -> ProfileDraft: + assert (draft_id, owner_user_id) == (state.id, 1) + moves.append((expected_revision, stage, substep)) + return replace(state, revision=9, wizard_stage=stage, wizard_substep=substep) + + class Bot: + def require_worker(self) -> Worker: + return Worker() + + class DynamicResponse: + async def defer(self) -> None: + return None + + async def edit_original_response(*, view: discord.ui.LayoutView) -> None: + rendered.append(view) + + interaction = SimpleNamespace( + client=Bot(), + user=SimpleNamespace(id=1, display_name="Display Name", display_avatar=None), + guild_id=None, + message=object(), + response=DynamicResponse(), + edit_original_response=edit_original_response, + ) + item = discord.ui.Button(label="Legacy", custom_id=wizard_custom_id(state, action)) + dynamic = ProfileWizardDynamic(item, state.id, "1", state.origin_guild_id, 3, action) + + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert moves == [(8, expected_stage, "legacy")] + assert len(rendered) == 1 + + +@pytest.mark.asyncio +async def test_legacy_identity_select_redirects_without_replaying_old_values() -> None: + state = replace(identity_draft(), revision=8, wizard_stage=WizardStage.REVIEW) + moves: list[tuple[int, WizardStage]] = [] + + class Worker: + async def get_draft(self, draft_id: str, *, owner_user_id: int) -> ProfileDraft: + assert (draft_id, owner_user_id) == (state.id, 1) + return state + + async def set_draft_wizard_stage( + self, + draft_id: str, + *, + owner_user_id: int, + expected_revision: int, + stage: WizardStage, + substep: str | None, + ) -> ProfileDraft: + assert (draft_id, owner_user_id, substep) == (state.id, 1, "legacy") + moves.append((expected_revision, stage)) + return replace(state, revision=9, wizard_stage=stage) + + async def update_draft_step(self, *_: object, **__: object) -> ProfileDraft: + raise AssertionError("legacy selections must not replay obsolete mutations") + + class Bot: + def require_worker(self) -> Worker: + return Worker() + + class DynamicResponse: + async def defer(self) -> None: + return None + + async def edit_original_response(*, view: discord.ui.LayoutView) -> None: + assert isinstance(view, discord.ui.LayoutView) + + interaction = SimpleNamespace( + client=Bot(), + user=SimpleNamespace(id=1, display_name="Display Name", display_avatar=None), + guild_id=None, + message=object(), + response=DynamicResponse(), + edit_original_response=edit_original_response, + ) + action = "identity-dm-status" + item = discord.ui.Select( + custom_id=wizard_custom_id(state, action), + options=[discord.SelectOption(label="Old choice", value="closed")], + ) + item._values = [] + dynamic = ProfileSelectDynamic(item, state.id, "1", state.origin_guild_id, 3, action) + + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert moves == [(8, WizardStage.DM_STATUS)] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ("confirm-throne", "rotate")) +async def test_one_time_webhook_url_is_sent_before_followup_worker_calls(action: str) -> None: + state = replace( + identity_draft(), + current_step=DraftStepKey.THRONE, + next_step=DraftStepKey.THRONE, + governing_orientation=Orientation.DOMME, + wizard_stage=WizardStage.THRONE, + wizard_substep="review", + throne_pending=ThronePending("creator", None), + ) + mutated = replace(state, revision=4) + events: list[str] = [] + private_messages: list[str] = [] + + class Worker: + async def get_draft(self, draft_id: str, *, owner_user_id: int) -> ProfileDraft: + events.append("load") + return state + + async def attach_throne(self, *_: object, **__: object) -> ThroneDraftResult: + assert action == "confirm-throne" + events.append("mutate") + return ThroneDraftResult(mutated, "https://usebill.dev/t/creator/one-time", "issued") + + async def rotate_throne(self, *_: object, **__: object) -> ThroneDraftResult: + assert action == "rotate" + events.append("mutate") + return ThroneDraftResult(mutated, "https://usebill.dev/t/creator/one-time", "rotated") + + async def get_throne_status(self, *_: object, **__: object) -> None: + raise AssertionError("a newly issued URL already implies an unverified secret") + + async def set_draft_wizard_stage( + self, *_: object, **kwargs: object + ) -> ProfileDraft: + assert events == ["defer", "load", "mutate", "url"] + assert kwargs["substep"] == "review:awaiting_verification" + events.append("stage") + return replace(mutated, revision=5, wizard_substep="review:awaiting_verification") + + class Bot: + def require_worker(self) -> Worker: + return Worker() + + class DynamicResponse: + async def defer(self) -> None: + events.append("defer") + + class Followup: + async def send( + self, + content: str, + *, + ephemeral: bool, + view: discord.ui.View | None = None, + ) -> None: + assert ephemeral and view is None + private_messages.append(content) + events.append("url") + + async def edit_original_response(*, view: discord.ui.LayoutView) -> None: + assert isinstance(view, discord.ui.LayoutView) + events.append("edit") + + interaction = SimpleNamespace( + client=Bot(), + user=SimpleNamespace(id=1, display_name="Display Name", display_avatar=None), + guild_id=None, + message=object(), + response=DynamicResponse(), + followup=Followup(), + edit_original_response=edit_original_response, + ) + item = discord.ui.Button(label="Throne", custom_id=wizard_custom_id(state, action)) + dynamic = ProfileWizardDynamic(item, state.id, "1", state.origin_guild_id, 3, action) + + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert events == ["defer", "load", "mutate", "url", "stage", "edit"] + assert len(private_messages) == 1 + assert private_messages[0].count("https://usebill.dev/t/creator/one-time") == 1 + + +@pytest.mark.asyncio +async def test_modal_actions_acknowledge_before_loading_and_preserve_defaults() -> None: + state = replace( + identity_draft(), + wizard_stage=WizardStage.BIO, + document=replace(identity_draft().document, bio="Saved bio"), + ) + events: list[str] = [] + launcher: ProfileModalLauncherView | None = None + + class Worker: + async def get_draft(self, draft_id: str, *, owner_user_id: int) -> ProfileDraft: + assert (draft_id, owner_user_id) == (state.id, 1) + assert events == ["defer"] + events.append("load") + return state + + class Bot: + def require_worker(self) -> Worker: + return Worker() + + class DynamicResponse: + async def defer(self) -> None: + events.append("defer") + + def is_done(self) -> bool: + return True + + class Followup: + async def send( + self, + _content: str, + *, + view: discord.ui.View | None, + ephemeral: bool, + ) -> None: + nonlocal launcher + assert ephemeral + assert isinstance(view, ProfileModalLauncherView) + launcher = view + + interaction = SimpleNamespace( + client=Bot(), + user=SimpleNamespace(id=1), + guild_id=None, + message=object(), + response=DynamicResponse(), + followup=Followup(), + ) + item = discord.ui.Button(label="Edit bio", custom_id=wizard_custom_id(state, "bio")) + dynamic = ProfileWizardDynamic(item, state.id, "1", state.origin_guild_id, 3, "bio") + + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert events == ["defer", "load"] + assert launcher is not None + + class ModalResponse: + async def send_modal(self, modal: discord.ui.Modal) -> None: + assert isinstance(modal, BioModal) + assert modal.bio.default == "Saved bio" + + await launcher.open_modal( # type: ignore[arg-type] + SimpleNamespace(response=ModalResponse()) + ) + + @pytest.mark.parametrize( ("status", "step", "channel_id", "expected"), [ @@ -600,48 +915,6 @@ def test_setup_view_gracefully_handles_missing_guild_icon() -> None: assert not any(isinstance(item, discord.ui.Thumbnail) for item in _all_items(view)) -@pytest.mark.parametrize( - ("orientation", "honourifics", "labels", "aliases", "stats"), - [ - (Orientation.DOMME, ["Goddess"], [], [], False), - (Orientation.SUBMISSIVE, [], ["Pet"], ["name"], True), - (Orientation.SWITCH_DOMME, ["Mistress"], ["Brat"], ["name"], True), - (Orientation.SWITCH_SUBMISSIVE, ["Mommy"], ["Sub"], ["name"], True), - ], -) -def test_identity_payload_obeys_each_orientation_capability( - orientation: Orientation, - honourifics: list[str], - labels: list[str], - aliases: list[str], - stats: bool, -) -> None: - values = _identity_values( - replace(identity_draft(status=DmStatus.OPEN), governing_orientation=orientation), - "She/Her", - ",".join(honourifics), - ",".join(labels), - ",".join(aliases), - "on", - "hello", - ) - - assert values["honourifics"] == honourifics - assert values["submissive_labels"] == labels - assert values["aliases"] == aliases - assert values["public_send_stats"] is stats - - -def test_linked_identity_can_inherit_every_field_sparsely() -> None: - linked = identity_draft(scope=DraftScope.SERVER, mode=ServerProfileMode.LINKED) - - values = _identity_values(linked, "", "", "", "", "inherit", "") - - assert values["overrides"] == [] - assert values["dm_status"] is None - assert values["bio"] is None - - def test_dm_status_menu_has_exact_options_and_no_implicit_global_default() -> None: select = DmStatusSelect(identity_draft()) @@ -662,9 +935,7 @@ def test_non_linked_dm_status_menu_defaults_only_to_saved_choice( scope: DraftScope, mode: ServerProfileMode | None, ) -> None: - select = DmStatusSelect( - identity_draft(status=DmStatus.AFTER_TRIBUTE, scope=scope, mode=mode) - ) + select = DmStatusSelect(identity_draft(status=DmStatus.AFTER_TRIBUTE, scope=scope, mode=mode)) assert len(select.options) == 4 assert [option.value for option in select.options if option.default] == ["after_tribute"] @@ -699,9 +970,7 @@ def test_linked_dm_status_menu_defaults_to_inheritance_or_explicit_override() -> ) assert [option.value for option in inherited.options if option.default] == ["inherit"] assert [option.value for option in overridden.options if option.default] == ["closed"] - assert "Use global setting keeps this server's DM status linked" in str( - profile_wizard_view(linked).to_components() - ) + assert "Use global setting" in str(profile_wizard_view(linked).to_components()) def test_other_partial_identity_selection_does_not_default_dm_status() -> None: @@ -735,6 +1004,7 @@ def test_dm_status_partial_mutation_preserves_other_identity_fields() -> None: "bio": "Existing bio", "public_send_stats": True, "aliases": ["alias"], + "profile_color": None, "complete": False, "dm_status_selected": True, } @@ -756,26 +1026,9 @@ def test_linked_inherit_partial_removes_only_dm_status_override() -> None: assert values["dm_status_selected"] is True -def test_identity_completion_requires_non_linked_dm_status() -> None: - with pytest.raises(ValueError, match="choose a DM status from the menu"): - _identity_values(identity_draft(), "", "", "", "", "off", "") - - -def test_identity_modal_contains_no_dm_status_or_pipe_delimited_input() -> None: - modal = IdentityModal(identity_draft(status=DmStatus.OPEN), object()) # type: ignore[arg-type] - labels = [item.label for item in modal.children if isinstance(item, discord.ui.TextInput)] - - assert labels == [ - "Aliases, comma separated (- clears)", - "Public send stats: on/off/inherit", - "Bio (- clears; blank inherits when linked)", - ] - assert all("DM status" not in label and "|" not in label for label in labels) - - @pytest.mark.asyncio async def test_dm_status_select_persists_revision_bound_partial_mutation() -> None: - state = identity_draft() + state = replace(identity_draft(), wizard_stage=WizardStage.DM_STATUS) calls: list[dict[str, object]] = [] class Worker: @@ -796,21 +1049,26 @@ def require_worker(self) -> Worker: return Worker() class SelectResponse: + async def defer(self) -> None: + return None + async def edit_message(self, *, view: discord.ui.LayoutView) -> None: assert "Closed" in str(view.to_components()) + response = SelectResponse() interaction = SimpleNamespace( client=Bot(), user=SimpleNamespace(id=1, display_name="Display Name", display_avatar=None), guild_id=None, - response=SelectResponse(), + response=response, + edit_original_response=response.edit_message, ) item = discord.ui.Select( - custom_id=wizard_custom_id(state, "identity-dm-status"), + custom_id=wizard_custom_id(state, "dm-status"), options=[discord.SelectOption(label="Closed", value="closed")], ) item._values = ["closed"] - dynamic = ProfileSelectDynamic(item, "draft_1", "1", "2", 3, "identity-dm-status") + dynamic = ProfileSelectDynamic(item, "draft_1", "1", "2", 3, "dm-status") await dynamic.callback(interaction) # type: ignore[arg-type] @@ -828,6 +1086,7 @@ async def edit_message(self, *, view: discord.ui.LayoutView) -> None: "bio": None, "public_send_stats": False, "aliases": [], + "profile_color": None, "complete": False, "dm_status_selected": True, }, @@ -872,24 +1131,29 @@ def require_worker(self) -> Worker: return Worker() class SelectResponse: + async def defer(self) -> None: + return None + async def edit_message(self, *, view: discord.ui.LayoutView) -> None: dm_select = next( item for item in view.walk_children() if isinstance(item, DmStatusSelect) ) assert [option.value for option in dm_select.options if option.default] == ["inherit"] + response = SelectResponse() interaction = SimpleNamespace( client=Bot(), user=SimpleNamespace(id=1, display_name="Display Name", display_avatar=None), guild_id=None, - response=SelectResponse(), + response=response, + edit_original_response=response.edit_message, ) item = discord.ui.Select( - custom_id=wizard_custom_id(state, "identity-dm-status"), + custom_id=wizard_custom_id(state, "dm-status"), options=[discord.SelectOption(label="Use global setting", value="inherit")], ) item._values = ["inherit"] - dynamic = ProfileSelectDynamic(item, "draft_1", "1", "2", 3, "identity-dm-status") + dynamic = ProfileSelectDynamic(item, "draft_1", "1", "2", 3, "dm-status") await dynamic.callback(interaction) # type: ignore[arg-type] @@ -907,6 +1171,7 @@ async def edit_message(self, *, view: discord.ui.LayoutView) -> None: "bio": None, "public_send_stats": False, "aliases": [], + "profile_color": None, "complete": False, "dm_status_selected": True, "overrides": ["bio"], @@ -933,22 +1198,44 @@ def require_worker(self) -> Worker: return Worker() class StaleResponse: + def __init__(self) -> None: + self.deferred = False + + async def defer(self) -> None: + self.deferred = True + + def is_done(self) -> bool: + return self.deferred + async def send_message(self, content: str, *, ephemeral: bool) -> None: assert ephemeral messages.append(content) + class Followup: + async def send( + self, + content: str, + *, + view: discord.ui.View | None = None, + ephemeral: bool, + ) -> None: + assert view is None + assert ephemeral + messages.append(content) + interaction = SimpleNamespace( client=Bot(), user=SimpleNamespace(id=1), guild_id=None, response=StaleResponse(), + followup=Followup(), ) item = discord.ui.Select( - custom_id=wizard_custom_id(state, "identity-dm-status"), + custom_id=wizard_custom_id(state, "dm-status"), options=[discord.SelectOption(label="Closed", value="closed")], ) item._values = ["closed"] - dynamic = ProfileSelectDynamic(item, "draft_1", "1", "2", 3, "identity-dm-status") + dynamic = ProfileSelectDynamic(item, "draft_1", "1", "2", 3, "dm-status") await dynamic.callback(interaction) # type: ignore[arg-type] @@ -1040,3 +1327,230 @@ def test_realistic_persistent_ids_fit_discord_limit() -> None: for action in (*PROFILE_WIZARD_BUTTON_ACTIONS, *PROFILE_WIZARD_SELECT_ACTIONS) ) assert len(setup_custom_id(setup, "complete")) <= 100 + + +@pytest.mark.parametrize( + ("orientation", "expected"), + [ + ( + Orientation.DOMME, + ( + WizardStage.ORIENTATION, + WizardStage.PRONOUNS, + WizardStage.HONOURIFICS, + WizardStage.DM_STATUS, + WizardStage.BIO, + WizardStage.PROFILE_COLOR, + WizardStage.LINKS, + WizardStage.THRONE, + WizardStage.REVIEW, + ), + ), + ( + Orientation.SUBMISSIVE, + ( + WizardStage.ORIENTATION, + WizardStage.PRONOUNS, + WizardStage.SUBMISSIVE_LABELS, + WizardStage.DM_STATUS, + WizardStage.BIO, + WizardStage.PROFILE_COLOR, + WizardStage.LINKS, + WizardStage.DETAILS, + WizardStage.REVIEW, + ), + ), + ( + Orientation.SWITCH_DOMME, + tuple(WizardStage), + ), + ( + Orientation.SWITCH_SUBMISSIVE, + tuple(WizardStage), + ), + ], +) +def test_conditional_wizard_stage_sequences( + orientation: Orientation, + expected: tuple[WizardStage, ...], +) -> None: + assert wizard_stages(orientation) == expected + + +def test_linked_wizard_inherits_orientation_but_keeps_conditional_sequence() -> None: + stages = wizard_stages(Orientation.DOMME, linked=True) + + assert WizardStage.ORIENTATION not in stages + assert stages[0] is WizardStage.PRONOUNS + assert WizardStage.THRONE not in stages + + +@pytest.mark.parametrize("stage", list(WizardStage)) +def test_every_guided_stage_is_neutral_restart_safe_and_within_component_limits( + stage: WizardStage, +) -> None: + state = replace( + draft(), + governing_orientation=Orientation.SWITCH_DOMME, + wizard_stage=stage, + document=replace( + draft().document, + dm_status=DmStatus.OPEN, + selections=ProfileSelections(("They/Them",), (), ()), + ), + ) + + view = profile_wizard_view(state, presentation=MemberPresentation("Member")) + encoded = str(view.to_components()) + containers = [item for item in view.children if isinstance(item, discord.ui.Container)] + + assert f"Step {wizard_stages(Orientation.SWITCH_DOMME).index(stage) + 1} of " in encoded + assert containers[0].accent_color is None + assert all(len(row.children) <= 5 for row in _rows(view)) + assert len(_all_items(view)) <= 40 + assert all( + len(item.custom_id or "") <= 100 + for item in _all_items(view) + if isinstance(item, (discord.ui.Button, discord.ui.Select)) + ) + + +def test_bill_palette_is_named_strict_rgb_and_includes_neutral_choice() -> None: + assert [name for name, _ in PROFILE_COLOR_OPTIONS] == [ + "Blue", + "Purple", + "Rose", + "Red", + "Orange", + "Gold", + "Emerald", + "Teal", + ] + assert all(0 <= value <= 0xFFFFFF for _, value in PROFILE_COLOR_OPTIONS) + select = ProfileColorSelect( + replace( + identity_draft(), + wizard_stage=WizardStage.PROFILE_COLOR, + ) + ) + assert select.options[-1].label == "No colour" + + +def test_linked_colour_distinguishes_inherit_explicit_clear_and_override() -> None: + linked = replace( + identity_draft(scope=DraftScope.SERVER, mode=ServerProfileMode.LINKED), + wizard_stage=WizardStage.PROFILE_COLOR, + ) + inherited = ProfileColorSelect(linked) + cleared = ProfileColorSelect( + replace( + linked, + document=replace( + linked.document, + overridden_fields=("profile_color",), + ), + ) + ) + blue = ProfileColorSelect( + replace( + linked, + document=replace( + linked.document, + profile_color=0x5865F2, + overridden_fields=("profile_color",), + ), + ) + ) + + assert [option.value for option in inherited.options if option.default] == ["inherit"] + assert [option.value for option in cleared.options if option.default] == ["none"] + assert [option.value for option in blue.options if option.default] == ["5865f2"] + + +def test_linked_review_uses_resolved_global_colour_and_hides_inapplicable_edits() -> None: + linked = replace( + identity_draft(scope=DraftScope.SERVER, mode=ServerProfileMode.LINKED), + wizard_stage=WizardStage.REVIEW, + resolved_profile_color=0xE0568A, + ) + + view = profile_wizard_view(linked, presentation=MemberPresentation("Member")) + encoded = str(view.to_components()) + containers = [item for item in view.children if isinstance(item, discord.ui.Container)] + + assert containers[-1].accent_color == discord.Color(0xE0568A) + assert "Edit orientation" not in encoded + assert "Edit Throne" not in encoded + + +def test_free_text_is_confined_to_focused_modals() -> None: + state = replace( + identity_draft(status=DmStatus.OPEN), + wizard_stage=WizardStage.DETAILS, + ) + bio = BioModal(state, object()) # type: ignore[arg-type] + aliases = AliasModal(state, object()) # type: ignore[arg-type] + color = ProfileColorModal(state, object()) # type: ignore[arg-type] + link = LinkModal( + state, + object(), # type: ignore[arg-type] + link_type=LinkType.SOCIAL, + ) + + assert len(bio.children) == 1 + assert len(aliases.children) == 1 + assert len(color.children) == 1 + assert all( + "social or payment" not in str(getattr(item, "label", "")).casefold() + for item in link.children + ) + text_fields = (*bio.children, *aliases.children) + assert not any("|" in str(getattr(item, "label", "")) for item in text_fields) + + +def test_stats_are_a_menu_not_free_text() -> None: + state = replace( + identity_draft(status=DmStatus.OPEN), + wizard_stage=WizardStage.DETAILS, + ) + select = StatsSelect(state) + + assert [(option.label, option.value) for option in select.options] == [ + ("Show send stats", "show"), + ("Hide send stats", "hide"), + ] + + +def test_review_preview_and_public_profile_use_selected_accent_only() -> None: + state = replace( + identity_draft(status=DmStatus.OPEN), + governing_orientation=Orientation.SWITCH_DOMME, + wizard_stage=WizardStage.REVIEW, + document=replace(identity_draft().document, profile_color=0x2EAD78), + ) + review = profile_wizard_view(state) + public = public_profile_view( + _profile(profile_color=0x2EAD78), + guild_id=2, + owner_view=False, + presentation=MemberPresentation("Member"), + ) + + review_containers = [item for item in review.children if isinstance(item, discord.ui.Container)] + public_container = next( + item for item in public.children if isinstance(item, discord.ui.Container) + ) + assert review_containers[0].accent_color is None + assert review_containers[1].accent_color == discord.Color(0x2EAD78) + assert public_container.accent_color == discord.Color(0x2EAD78) + + +def test_server_setup_container_remains_neutral() -> None: + session = GuildSetupSession( + "setup", "2", "1", "active", "select_channel", None, 4, None, None, None, None, None + ) + view = setup_view(session) + container = next(item for item in view.children if isinstance(item, discord.ui.Container)) + + assert container.accent_color is None + assert "View Channel" in str(view.to_components()) diff --git a/tests/test_worker_client.py b/tests/test_worker_client.py index 9467c99..e3a0c6c 100644 --- a/tests/test_worker_client.py +++ b/tests/test_worker_client.py @@ -32,6 +32,50 @@ def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse: return self.response +def draft_payload() -> dict[str, object]: + return { + "id": "draft-1", + "owner_user_id": "123", + "origin_guild_id": "456", + "target_scope": "global", + "guild_id": None, + "server_mode": None, + "status": "active", + "revision": 4, + "base_version": 0, + "current_step": "throne", + "next_step": "throne", + "steps": [{"key": "throne", "status": "pending", "completed_at": None}], + "dm_status_selected": True, + "governing_orientation": "domme", + "document": { + "dm_status": "open", + "bio": None, + "public_send_stats": False, + "selections": { + "pronouns": ["She/Her"], + "honourifics": [], + "submissive_labels": [], + }, + "aliases": [], + "links": [], + "overridden_fields": [], + "hidden_inherited_link_ids": [], + "throne_creator_id": "creator", + "preferred_payment_link_id": None, + "profile_color": None, + }, + "throne_prefill": None, + "created_at": None, + "updated_at": None, + "published_at": None, + "wizard_stage": "throne", + "wizard_substep": "awaiting_verification", + "throne_pending": None, + "resolved_profile_color": None, + } + + @pytest.mark.asyncio async def test_configure_guild_serializes_snowflakes_and_auth() -> None: session = FakeSession( @@ -98,3 +142,75 @@ async def test_invalid_snowflake_is_rejected_before_request() -> None: await client.get_guild_config("not-a-number") assert session.calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ("attach_throne", "rotate_throne")) +async def test_throne_mutations_return_embedded_draft_without_followup_get( + operation: str, +) -> None: + session = FakeSession( + FakeResponse( + status=200, + payload={ + "ok": True, + "data": { + "draft": draft_payload(), + "webhook_url": "https://usebill.dev/t/creator/one-time", + "webhook_state": "rotated", + }, + }, + ) + ) + client = WorkerClient( + base_url="https://usebill.dev", + api_token="secret", + session=session, # type: ignore[arg-type] + ) + + result = await getattr(client, operation)( + "draft-1", + owner_user_id="123", + expected_revision=3, + ) + + assert result.draft.id == "draft-1" + assert result.draft.owner_user_id == "123" + assert result.draft.document.selections.pronouns == ("She/Her",) + assert result.webhook_url == "https://usebill.dev/t/creator/one-time" + assert len(session.calls) == 1 + + +@pytest.mark.asyncio +async def test_throne_resolution_returns_embedded_draft_without_followup_get() -> None: + payload = draft_payload() + payload["wizard_substep"] = "review:confirm" + session = FakeSession( + FakeResponse( + status=200, + payload={ + "ok": True, + "data": { + "draft": payload, + "handle": "creator", + "already_verified": False, + }, + }, + ) + ) + client = WorkerClient( + base_url="https://usebill.dev", + api_token="secret", + session=session, # type: ignore[arg-type] + ) + + result = await client.resolve_throne( + "draft-1", + owner_user_id="123", + expected_revision=3, + throne_input="creator", + ) + + assert result.draft.wizard_substep == "review:confirm" + assert result.handle == "creator" + assert len(session.calls) == 1 diff --git a/worker/migrations/0004_profile_color_and_wizard_stage.sql b/worker/migrations/0004_profile_color_and_wizard_stage.sql new file mode 100644 index 0000000..57c66f2 --- /dev/null +++ b/worker/migrations/0004_profile_color_and_wizard_stage.sql @@ -0,0 +1,73 @@ +-- Profile accent colour and durable wizard resume position. +-- +-- Strictly additive over 0001-0003: only new nullable columns via `ALTER +-- TABLE ... ADD COLUMN` (SQLite/D1 rewrites no existing row data for +-- this), exactly like 0003's `sends.sender_discord_user_id` and +-- `domme_registrations.profile_managed` columns. Nothing in 0001, 0002, or +-- 0003 is dropped, renamed, or otherwise altered, and this file itself must +-- never be edited once shipped -- any further schema change belongs in a +-- later numbered migration. +PRAGMA foreign_keys = ON; + +-- A document's optional accent colour, shown on its published profile card. +-- Stored as a plain sRGB integer (0x000000-0xFFFFFF) rather than a hex +-- string so range validation is a trivial numeric CHECK and every reader +-- gets the same representation the bot's preset swatches use. NULL means +-- "no colour" (a neutral card), which is itself a valid, deliberate choice +-- -- not merely "unset" -- exactly like `bio`/`dm_status` already work on +-- this table. On a `linked` server overlay document, this column is +-- inherited from the global document unless the existing sparse +-- `profile_document_overrides` mechanism (0002) carries a +-- `field_name = 'profile_color'` row for it; that row, not this column's +-- nullability, is what distinguishes "inherit" from "deliberately cleared +-- to no colour" for an overlay (see resolver.ts). Bot-facing custom hex +-- entry is validated client-side; this CHECK is the Worker's own storage +-- guarantee regardless of what a caller sends. +ALTER TABLE profile_documents + ADD COLUMN profile_color INTEGER + CHECK (profile_color IS NULL OR (profile_color >= 0 AND profile_color <= 16777215)); + +-- Durable, revision-bound bookmark of exactly which screen of the private +-- wizard a draft was last showing, so a Discord message rebuilt after a bot +-- restart (or a second device) can resume on the same micro-screen instead +-- of only the coarse `current_step`. Both columns are plain nullable TEXT, +-- not CHECK-constrained against a fixed vocabulary, so the bot's evolving +-- stage/substep vocabulary (enforced by the Worker's typed contracts, see +-- `contracts.ts`'s `WIZARD_STAGES`) never forces a schema migration -- +-- exactly the same reasoning 0002 already documents for +-- `profile_document_selections.value`. Every draft that already exists at +-- the moment this migration runs gets NULL for both columns; a NULL +-- `wizard_stage` is itself meaningful ("no bookmark recorded yet") and lets +-- the bot deterministically fall back to deriving a stage from the +-- existing `current_step`/document state, so old active drafts keep +-- resuming correctly without this migration having to backfill a guess. +ALTER TABLE profile_drafts ADD COLUMN wizard_stage TEXT; +ALTER TABLE profile_drafts ADD COLUMN wizard_substep TEXT; + +-- Staged, unconfirmed Throne identity for the wizard's "is this you?" screen. +-- +-- Connecting Throne is a *confirmed* flow, not a success-shaped attachment: +-- the Worker resolves the owner's submitted username/URL, shows them the +-- handle it found, and only creates the `throne_creators` row and issues the +-- one-time webhook secret after they confirm that handle. Those columns are +-- what make "resolved but not yet confirmed" a durable state rather than +-- something held in bot memory: nothing about the creator exists in 0001's +-- `throne_creators` table until confirmation, so an abandoned or mistyped +-- resolution can never leave behind a live webhook route, a secret, or a +-- creator row somebody else's profile would then collide with. +-- +-- Only the *hash* of the confirmation capability is stored, exactly like +-- `throne_creators.route_secret_hash` (0001): the plaintext token is +-- returned once, to the resolving request, and a database dump therefore +-- never yields a usable one. `pending_throne_expires_at` keeps the staged +-- identity short-lived so a stale confirmation cannot be replayed days +-- later against a handle that has since changed hands. All five columns are +-- nullable and cleared together the moment a confirmation succeeds (or the +-- draft is restarted), so a draft with `pending_throne_token_hash IS NULL` +-- simply has nothing awaiting confirmation -- which is exactly the state +-- every draft that already exists when this migration runs is left in. +ALTER TABLE profile_drafts ADD COLUMN pending_throne_token_hash TEXT; +ALTER TABLE profile_drafts ADD COLUMN pending_throne_public_creator_id TEXT; +ALTER TABLE profile_drafts ADD COLUMN pending_throne_handle TEXT; +ALTER TABLE profile_drafts ADD COLUMN pending_throne_profile_url TEXT; +ALTER TABLE profile_drafts ADD COLUMN pending_throne_expires_at TEXT; diff --git a/worker/src/index.ts b/worker/src/index.ts index 541bb29..63bf901 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -12,11 +12,17 @@ import { handlePublishDraft, handlePutDraftStep, handleRestartDraft, + handleSetDraftWizardStage, handleStartDraft, } from "./routes/profileDrafts.js"; import { handleAddLink, handleDeleteLink, handleEditLink } from "./routes/profileLinks.js"; import { handleConfirmLinkImport, handleCreateLinkImport } from "./routes/profileLinkImports.js"; -import { handleAttachDraftThrone, handleRotateDraftThrone } from "./routes/profileThrone.js"; +import { + handleAttachDraftThrone, + handleGetDraftThroneStatus, + handleResolveDraftThrone, + handleRotateDraftThrone, +} from "./routes/profileThrone.js"; import { handleCompleteGuildSetupSession, handleCreateGuildSetupSession, @@ -55,6 +61,7 @@ router.get("/v1/guilds/:guildId/profiles/:userId", withAuth(handleGetProfile)); router.post("/v1/profile-drafts/start", withAuth(handleStartDraft)); router.get("/v1/profile-drafts/:draftId", withAuth(handleGetDraft)); router.put("/v1/profile-drafts/:draftId/steps/:stepKey", withAuth(handlePutDraftStep)); +router.put("/v1/profile-drafts/:draftId/wizard-stage", withAuth(handleSetDraftWizardStage)); router.post("/v1/profile-drafts/:draftId/restart", withAuth(handleRestartDraft)); router.post("/v1/profile-drafts/:draftId/publish", withAuth(handlePublishDraft)); router.post("/v1/profile-drafts/:draftId/link-imports", withAuth(handleCreateLinkImport)); @@ -62,8 +69,10 @@ router.post("/v1/profile-drafts/:draftId/link-imports/:importId/confirm", withAu router.post("/v1/profile-drafts/:draftId/links", withAuth(handleAddLink)); router.put("/v1/profile-drafts/:draftId/links/:linkId", withAuth(handleEditLink)); router.delete("/v1/profile-drafts/:draftId/links/:linkId", withAuth(handleDeleteLink)); +router.post("/v1/profile-drafts/:draftId/throne/resolve", withAuth(handleResolveDraftThrone)); router.post("/v1/profile-drafts/:draftId/throne", withAuth(handleAttachDraftThrone)); router.post("/v1/profile-drafts/:draftId/throne/rotate", withAuth(handleRotateDraftThrone)); +router.get("/v1/profile-drafts/:draftId/throne/status", withAuth(handleGetDraftThroneStatus)); router.post("/v1/guild-setup-sessions", withAuth(handleCreateGuildSetupSession)); router.get("/v1/guild-setup-sessions/:sessionId", withAuth(handleGetGuildSetupSession)); router.put("/v1/guild-setup-sessions/:sessionId/channel", withAuth(handleSetGuildSetupChannel)); diff --git a/worker/src/profile/contracts.ts b/worker/src/profile/contracts.ts index e52a9f6..c26dc56 100644 --- a/worker/src/profile/contracts.ts +++ b/worker/src/profile/contracts.ts @@ -55,8 +55,31 @@ export const LIMITS = { linkLabelMaxChars: 40, linkUrlMaxChars: 500, linkMaxCount: 12, + profileColorMax: 0xffffff, + wizardSubstepMaxChars: 40, } as const; +/** + * Tasteful named presets offered by the wizard's colour picker, plus the + * implicit "No colour" choice (represented as `null`, not a thirteenth + * entry here). These are documentation/UX convenience only -- the Worker's + * one storage rule is the RGB range check in `parseOptionalColor`, so a + * caller-entered custom hex value outside this list is just as valid as a + * preset. Custom hex *format* validation (e.g. rejecting `#zzzzzz`) is + * bot-facing UI concern; by the time a value reaches the Worker it must + * already be the resolved integer. + */ +export const PROFILE_COLOR_PRESETS = [ + { name: "Blue", value: 0x5865f2 }, + { name: "Purple", value: 0x9b59b6 }, + { name: "Rose", value: 0xe0568a }, + { name: "Red", value: 0xe74c3c }, + { name: "Orange", value: 0xe67e22 }, + { name: "Gold", value: 0xd4a72c }, + { name: "Emerald", value: 0x2ead78 }, + { name: "Teal", value: 0x2aa198 }, +] as const; + /** * Per-orientation feature capabilities. Pronouns are available to every * orientation; everything else is gated so the wizard (and this Worker's @@ -112,6 +135,83 @@ export const ORIENTATION_CAPABILITIES: Readonly): WizardStageUpdate { + let wizardStage: WizardStage | null | undefined; + if ("wizard_stage" in record) { + const raw = record.wizard_stage; + if (raw === null) { + wizardStage = null; + } else if (isWizardStage(raw)) { + wizardStage = raw; + } else { + fail("invalid_wizard_stage", `wizard_stage must be one of: ${WIZARD_STAGES.join(", ")}`); + } + } + + let wizardSubstep: string | null | undefined; + if ("wizard_substep" in record) { + wizardSubstep = parseOptionalSubstep(record.wizard_substep, "wizard_substep"); + } + + return { wizardStage, wizardSubstep }; +} + +/** + * The dedicated `PUT .../wizard-stage` body (beyond the standard + * `owner_user_id`/`expected_revision` mutation envelope the route itself + * validates). + * + * Unlike the optional bookmark a step mutation may carry, `stage` is + * required here -- this endpoint exists precisely to record one -- and an + * *omitted* `substep` deliberately means "clear it", not "leave it alone". + * That normalization is what makes a substep strictly scoped to the single + * screen that set it: a caller that navigates anywhere without naming a + * substep can never inherit a stale one (e.g. a leftover "verified" from an + * earlier Throne check, or a "review" return-marker from an edit jump). + */ +export interface WizardStageRequest { + readonly stage: WizardStage; + readonly substep: string | null; +} + +export function parseWizardStageRequest(record: Record): WizardStageRequest { + const rawStage = record.stage; + if (!isWizardStage(rawStage)) { + fail("invalid_wizard_stage", `stage must be one of: ${WIZARD_STAGES.join(", ")}`); + } + const substep = "substep" in record ? parseOptionalSubstep(record.substep, "substep") : null; + return { stage: rawStage, substep }; +} + +function parseOptionalSubstep(raw: unknown, field: string): string | null { + if (raw === null || raw === undefined) return null; + if (typeof raw !== "string" || raw.trim().length === 0 || raw.length > LIMITS.wizardSubstepMaxChars) { + fail( + "invalid_wizard_substep", + `${field} must be a non-empty string of at most ${LIMITS.wizardSubstepMaxChars} characters, or null`, + ); + } + return raw; +} + export function isOrientation(value: unknown): value is Orientation { return typeof value === "string" && (ORIENTATIONS as readonly string[]).includes(value); } @@ -196,6 +369,7 @@ export interface IdentityStepInput { readonly bio: string | null; readonly publicSendStats: boolean; readonly aliases: string[]; + readonly profileColor: number | null; } /** @@ -239,6 +413,10 @@ export function parseIdentityStep( ? parseAliases(record.aliases) : (requireEmptyOrAbsent(record.aliases, "aliases", orientation), []); + // Available to every orientation, unlike honourifics/labels/aliases/stats: the accent colour + // gates nothing else and has no per-orientation capability to check. + const profileColor = parseOptionalColor(record.profile_color); + return { pronouns, honourifics, @@ -247,6 +425,7 @@ export function parseIdentityStep( bio, publicSendStats, aliases, + profileColor, }; } @@ -373,6 +552,7 @@ export interface LinkedIdentityStepInput { readonly bio: string | null; readonly publicSendStats: boolean; readonly aliases: string[]; + readonly profileColor: number | null; } function parseOverridesList(value: unknown, caps: OrientationCapabilities): Set { @@ -407,7 +587,12 @@ export function parseLinkedIdentityStep(body: unknown, globalOrientation: Orient const caps = ORIENTATION_CAPABILITIES[globalOrientation]; const overriddenFields = parseOverridesList(record.overrides, caps); - const pronouns = overriddenFields.has("pronouns") ? parseFixedMultiSelect(record.pronouns, PRONOUNS, "pronouns") : []; + const pronouns = overriddenFields.has("pronouns") + ? parseFixedMultiSelect(record.pronouns, PRONOUNS, "pronouns") + : []; + if (overriddenFields.has("pronouns") && pronouns.length === 0) { + fail("pronouns_required", "an explicit pronoun override must contain at least one pronoun"); + } const honourifics = overriddenFields.has("honourifics") ? parseFixedMultiSelect(record.honourifics, HONOURIFICS, "honourifics") : []; @@ -422,8 +607,9 @@ export function parseLinkedIdentityStep(body: unknown, globalOrientation: Orient ? parseOptionalBoolean(record.public_send_stats, "public_send_stats") : false; const aliases = overriddenFields.has("aliases") ? parseAliases(record.aliases) : []; + const profileColor = overriddenFields.has("profile_color") ? parseOptionalColor(record.profile_color) : null; - return { overriddenFields, pronouns, honourifics, submissiveLabels, dmStatus, bio, publicSendStats, aliases }; + return { overriddenFields, pronouns, honourifics, submissiveLabels, dmStatus, bio, publicSendStats, aliases, profileColor }; } export interface LinkedLinksStepInput { @@ -478,6 +664,25 @@ function parseOptionalId(value: unknown, field: string): string | null { return value; } +/** + * A document's optional accent colour: an absent or explicit `null` value + * means "no colour" (a deliberate, valid choice, not merely unset -- see + * migration 0004), and any other value must be an in-range RGB integer. + * This is the one place storage range validation happens; the wizard's + * named presets (`PROFILE_COLOR_PRESETS`) are just documentation and are + * never specially required here. + */ +export function parseOptionalColor(value: unknown, field = "profile_color"): number | null { + if (value === undefined || value === null) return null; + if (typeof value !== "number" || !Number.isInteger(value)) { + fail("invalid_field", `${field} must be an integer or null`); + } + if (value < 0 || value > LIMITS.profileColorMax) { + fail("invalid_profile_color", `${field} must be between 0 and ${LIMITS.profileColorMax} (0xFFFFFF)`); + } + return value; +} + function parseOptionalBoolean(value: unknown, field: string): boolean { if (typeof value !== "boolean") fail("invalid_field", `${field} must be a boolean`); return value; diff --git a/worker/src/profile/documentStore.ts b/worker/src/profile/documentStore.ts index c4a2364..6120b8b 100644 --- a/worker/src/profile/documentStore.ts +++ b/worker/src/profile/documentStore.ts @@ -43,6 +43,10 @@ export interface DocumentSnapshot { readonly overriddenFields: readonly OverridableField[]; /** Only meaningful on a linked overlay document; ids of inherited global links this overlay hides. */ readonly hiddenInheritedLinkIds: readonly string[]; + /** The document's optional accent colour (0x000000-0xFFFFFF), or `null` for "no colour" -- a + * deliberate, valid choice on its own, not merely unset (see migration 0004). On a linked + * overlay this is only meaningful when `overriddenFields` includes `"profile_color"`. */ + readonly profileColor: number | null; } export const EMPTY_SNAPSHOT: DocumentSnapshot = { @@ -57,6 +61,7 @@ export const EMPTY_SNAPSHOT: DocumentSnapshot = { links: [], overriddenFields: [], hiddenInheritedLinkIds: [], + profileColor: null, }; interface DocumentScalarRow { @@ -66,11 +71,12 @@ interface DocumentScalarRow { public_send_stats: number; throne_creator_id: string | null; preferred_payment_link_id: string | null; + profile_color: number | null; } export async function readDocumentSnapshot(env: Env, documentId: string): Promise { const doc = await env.DB.prepare( - `SELECT orientation, dm_status, bio, public_send_stats, throne_creator_id, preferred_payment_link_id + `SELECT orientation, dm_status, bio, public_send_stats, throne_creator_id, preferred_payment_link_id, profile_color FROM profile_documents WHERE id = ?`, ) .bind(documentId) @@ -133,6 +139,7 @@ export async function readDocumentSnapshot(env: Env, documentId: string): Promis })), overriddenFields: overrideResult.results.map((row) => row.field_name) as OverridableField[], hiddenInheritedLinkIds: visibilityResult.results.map((row) => row.inherited_link_id), + profileColor: doc.profile_color, }; } @@ -195,8 +202,8 @@ export function buildDocumentWriteStatements( env.DB.prepare( `INSERT INTO profile_documents (id, owner_user_id, state, orientation, dm_status, bio, public_send_stats, - throne_creator_id, preferred_payment_link_id, created_at, updated_at) - VALUES (?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?)`, + throne_creator_id, preferred_payment_link_id, profile_color, created_at, updated_at) + VALUES (?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).bind( documentId, ownerUserId, @@ -206,6 +213,7 @@ export function buildDocumentWriteStatements( snapshot.publicSendStats ? 1 : 0, snapshot.throneCreatorId, snapshot.preferredPaymentLinkId, + snapshot.profileColor, now, now, ), @@ -215,7 +223,7 @@ export function buildDocumentWriteStatements( env.DB.prepare( `UPDATE profile_documents SET orientation = ?, dm_status = ?, bio = ?, public_send_stats = ?, - throne_creator_id = ?, preferred_payment_link_id = ?, updated_at = ? + throne_creator_id = ?, preferred_payment_link_id = ?, profile_color = ?, updated_at = ? WHERE id = ?${guardFragment.sql}`, ).bind( snapshot.orientation, @@ -224,6 +232,7 @@ export function buildDocumentWriteStatements( snapshot.publicSendStats ? 1 : 0, snapshot.throneCreatorId, snapshot.preferredPaymentLinkId, + snapshot.profileColor, now, documentId, ...guardFragment.params, diff --git a/worker/src/profile/draftService.ts b/worker/src/profile/draftService.ts index 36df3bb..ee93882 100644 --- a/worker/src/profile/draftService.ts +++ b/worker/src/profile/draftService.ts @@ -21,19 +21,24 @@ import { newId, nowIso } from "../util/id.js"; import { isSnowflake } from "../util/snowflake.js"; import { ValidationError, + WIZARD_STAGES, + isWizardStage, parseIdentityStep, parseLinkStep, parseLinkedIdentityStep, parseLinkedLinksStep, parseOrientationStep, parseThroneStep, + parseWizardStageUpdate, stepsForDraft, + wizardStagesForDraft, ORIENTATION_CAPABILITIES, LIMITS, type Orientation, type ServerMode, type StepKey, type TargetScope, + type WizardStage, } from "./contracts.js"; import { EMPTY_SNAPSHOT, @@ -76,6 +81,18 @@ export interface DraftRow { status: "active" | "published"; current_step: StepKey; revision: number; + /** NULL on every draft that predates migration 0004, and on any draft whose owner has not + * navigated since; `buildContract` derives a resume position rather than exposing the NULL. */ + wizard_stage: string | null; + wizard_substep: string | null; + /** Staged, not-yet-confirmed Throne identity (see migration 0004 and `throneDraftService`). + * Only the confirmation capability's hash is stored; nothing exists in `throne_creators` + * and no webhook secret has been minted while these are set. */ + pending_throne_token_hash: string | null; + pending_throne_public_creator_id: string | null; + pending_throne_handle: string | null; + pending_throne_profile_url: string | null; + pending_throne_expires_at: string | null; created_at: string; updated_at: string; published_at: string | null; @@ -95,6 +112,12 @@ interface StepStatusRow { completed_at: string | null; } +/** A `linked` server overlay inherits orientation/Throne from the owner's global document, which + * changes both its step sequence and its wizard stage sequence. */ +function linkedDraft(draft: DraftRow): boolean { + return draft.target_scope === "server" && draft.server_mode === "linked"; +} + async function loadStepStatuses(env: Env, draftId: string): Promise> { const { results } = await env.DB.prepare( "SELECT step_key, status, completed_at FROM profile_draft_steps WHERE draft_id = ?", @@ -106,7 +129,7 @@ async function loadStepStatuses(env: Env, draftId: string): Promise { - if (!(draft.target_scope === "server" && draft.server_mode === "linked")) { + if (!linkedDraft(draft)) { return ownDocument.orientation; } const globalRoot = await env.DB.prepare("SELECT current_document_id FROM global_profiles WHERE owner_user_id = ?") @@ -132,6 +155,21 @@ export interface DraftContract { readonly steps: { key: StepKey; status: "pending" | "completed"; completedAt: string | null }[]; readonly dmStatusSelected: boolean; readonly governingOrientation: Orientation | null; + /** The wizard screen this draft should resume on. Never null, even for a draft whose stored + * bookmark is still NULL (pre-0004 rows, or a draft nobody has navigated yet): see + * `deriveWizardStage`. Always one of `wizardStagesForDraft` for this draft. */ + readonly wizardStage: WizardStage; + /** A free-form marker scoped to `wizardStage` only (e.g. Throne verification state, or the + * "came here from review" return marker); null unless the last navigation named one. */ + readonly wizardSubstep: string | null; + /** A Throne handle this draft has resolved but whose owner has not confirmed yet, so the + * confirmation screen survives a bot restart. Deliberately carries no creator id, no public + * Throne id, and never the confirmation token itself -- only what the owner is being asked + * to say yes to. Null once confirmed, expired-and-replaced, or never resolved. */ + readonly thronePending: { handle: string; expiresAt: string | null } | null; + /** Effective colour the profile would publish with right now. For linked + * drafts this includes the live global value when no local override exists. */ + readonly resolvedProfileColor: number | null; readonly document: { dmStatus: DocumentSnapshot["dmStatus"]; bio: string | null; @@ -143,6 +181,7 @@ export interface DraftContract { hiddenInheritedLinkIds: readonly string[]; throneCreatorId: string | null; preferredPaymentLinkId: string | null; + profileColor: number | null; }; /** Only present when the governing orientation has the Throne capability; lets the wizard * offer "reuse your existing Throne creator" / "this guild already has a registration" @@ -201,9 +240,66 @@ async function loadThronePrefill( return { ownedCreators, existingRegistrationCreatorId }; } +/** + * The coarse step a stage belongs to, used only to translate a step-based + * resume position into a stage-based one. + */ +const STAGE_FOR_STEP: Readonly> = { + orientation: "orientation", + identity: "pronouns", + links: "links", + throne: "throne", + review: "review", +}; + +/** + * The screen a draft resumes on. + * + * A stored bookmark wins whenever it is still part of this draft's stage + * sequence. Otherwise -- a draft created before migration 0004 added the + * columns, a draft nobody has navigated since, or a bookmark the owner + * invalidated by changing orientation (say, `throne` after switching to + * `submissive`) -- the position is *derived*, deterministically, from the + * draft's own progress: the first still-pending step, else the last step it + * touched, mapped to that step's first stage and then clamped backwards to + * the nearest applicable stage. Deriving rather than persisting a guess is + * what lets 0004 stay purely additive while old active drafts still resume + * exactly where their coarse `current_step` left them. + */ +export function deriveWizardStage( + stages: readonly WizardStage[], + nextStep: StepKey | null, + currentStep: StepKey, +): WizardStage { + const target = STAGE_FOR_STEP[nextStep ?? currentStep] ?? "review"; + if (stages.includes(target)) return target; + const targetIndex = WIZARD_STAGES.indexOf(target); + const applicable = stages.filter((stage) => WIZARD_STAGES.indexOf(stage) <= targetIndex); + return applicable.at(-1) ?? (stages[0] as WizardStage); +} + +function resolveWizardStage( + draft: DraftRow, + stages: readonly WizardStage[], + nextStep: StepKey | null, +): WizardStage { + const stored = draft.wizard_stage; + if (isWizardStage(stored) && stages.includes(stored)) return stored; + return deriveWizardStage(stages, nextStep, draft.current_step); +} + export async function buildContract(env: Env, draft: DraftRow): Promise { const snapshot = (await readDocumentSnapshot(env, draft.document_id)) ?? EMPTY_SNAPSHOT; const governingOrientation = await resolveGoverningOrientation(env, draft, snapshot); + let resolvedProfileColor = snapshot.profileColor; + if (linkedDraft(draft) && !snapshot.overriddenFields.includes("profile_color")) { + const globalRoot = await env.DB.prepare("SELECT current_document_id FROM global_profiles WHERE owner_user_id = ?") + .bind(draft.owner_user_id) + .first<{ current_document_id: string }>(); + const globalDocument = + globalRoot === null ? null : await readDocumentSnapshot(env, globalRoot.current_document_id); + resolvedProfileColor = globalDocument?.profileColor ?? null; + } const steps = stepsForDraft(draft.target_scope, draft.server_mode, governingOrientation); const statuses = await loadStepStatuses(env, draft.id); const dmStatusSelected = @@ -220,6 +316,7 @@ export async function buildContract(env: Env, draft: DraftRow): Promise step.status === "pending")?.key ?? null; const thronePrefill = await loadThronePrefill(env, draft, governingOrientation); + const stages = wizardStagesForDraft(draft.target_scope, draft.server_mode, governingOrientation); return { id: draft.id, @@ -236,6 +333,13 @@ export async function buildContract(env: Env, draft: DraftRow): Promise { - const linked = draft.target_scope === "server" && draft.server_mode === "linked"; + const linked = linkedDraft(draft); if (stepKey === "orientation") { if (linked) badRequest("step_not_applicable", "a linked server profile inherits orientation from the global profile"); @@ -469,6 +574,11 @@ async function computeNewSnapshot( }, aliases: parsed.aliases, overriddenFields: Array.from(parsed.overriddenFields), + // On an overlay the column alone cannot express "inherit": it is the + // `profile_color` override row (persisted from `overriddenFields`) that + // distinguishes an inherited colour from a deliberately cleared one, so a + // non-overriding body always stores NULL here (see resolver.ts). + profileColor: parsed.profileColor, }; } const parsed = parseIdentityStep( @@ -483,6 +593,7 @@ async function computeNewSnapshot( publicSendStats: parsed.publicSendStats, selections: { pronouns: parsed.pronouns, honourifics: parsed.honourifics, submissiveLabels: parsed.submissiveLabels }, aliases: parsed.aliases, + profileColor: parsed.profileColor, }; } @@ -509,10 +620,20 @@ async function computeNewSnapshot( if (stepKey === "throne") { const parsed = parseThroneStep(body, governingOrientation); if (parsed.throneCreatorId !== null) { - const owned = await env.DB.prepare("SELECT id FROM throne_creators WHERE id = ? AND owner_discord_user_id = ?") + const owned = await env.DB.prepare( + `SELECT id, webhook_verified_at + FROM throne_creators + WHERE id = ? AND owner_discord_user_id = ?`, + ) .bind(parsed.throneCreatorId, draft.owner_user_id) - .first(); + .first<{ id: string; webhook_verified_at: string | null }>(); if (owned === null) badRequest("throne_creator_not_owned", "that Throne creator is not owned by this user"); + if (owned.webhook_verified_at === null) { + badRequest( + "throne_webhook_unverified", + "run Throne's Test Webhook and check the connection before completing this step", + ); + } } if (parsed.preferredPaymentLinkId !== null) { if (!current.links.some((link) => link.id === parsed.preferredPaymentLinkId && link.linkType === "payment")) { @@ -617,6 +738,47 @@ export async function applyDraftStep(env: Env, input: ApplyStepInput): Promise(); + const globalSnapshot = + globalRoot === null + ? null + : await readDocumentSnapshot(env, globalRoot.current_document_id); + effectivePronouns = globalSnapshot?.selections.pronouns ?? []; + } + if (effectivePronouns.length === 0) { + badRequest("pronouns_required", "choose at least one pronoun before completing identity"); + } + } + + // A step mutation may *optionally* carry a bookmark update, so a caller that + // knows where it is sending the owner next can persist both in the one + // guarded batch instead of a second round trip. Omitting either key leaves + // that column untouched (unlike the dedicated wizard-stage endpoint, where an + // omitted substep clears it). + let bookmark: { wizardStage: WizardStage | null | undefined; wizardSubstep: string | null | undefined }; + try { + bookmark = parseWizardStageUpdate(bodyRecord ?? {}); + } catch (error) { + if (error instanceof ValidationError) badRequest(error.code, error.message); + throw error; + } + if (bookmark.wizardStage !== undefined && bookmark.wizardStage !== null) { + // Validate against the sequence this draft will have *after* the mutation: + // completing the orientation step is exactly when the sequence changes. + const orientationAfter = linkedDraft(draft) ? governingOrientation : newSnapshot.orientation; + const stagesAfter = wizardStagesForDraft(draft.target_scope, draft.server_mode, orientationAfter); + if (!stagesAfter.includes(bookmark.wizardStage)) { + badRequest("stage_not_applicable", `${bookmark.wizardStage} is not part of this draft's wizard sequence`); + } + } const now = nowIso(); const newRevision = draft.revision + 1; @@ -644,13 +806,23 @@ export async function applyDraftStep(env: Env, input: ApplyStepInput): Promise 0 ? `, ${bookmarkAssignments.join(", ")}` : ""} WHERE id = ? AND revision = ? AND status = 'active' AND EXISTS (SELECT 1 FROM profile_documents WHERE id = ? AND state = 'draft')`, - ).bind(newRevision, input.stepKey, now, draft.id, draft.revision, draft.document_id), + ).bind(newRevision, input.stepKey, now, ...bookmarkParams, draft.id, draft.revision, draft.document_id), ); const results = await env.DB.batch(statements); @@ -697,7 +869,11 @@ export async function restartDraft(env: Env, input: RestartDraftInput): Promise< ...buildDocumentWriteStatements(env, draft.document_id, draft.owner_user_id, snapshot, now, { isNew: false, guard }), env.DB.prepare( `UPDATE profile_drafts - SET revision = ?, current_step = 'orientation', base_version = ?, updated_at = ? + SET revision = ?, current_step = 'orientation', base_version = ?, updated_at = ?, + wizard_stage = NULL, wizard_substep = NULL, + pending_throne_token_hash = NULL, pending_throne_public_creator_id = NULL, + pending_throne_handle = NULL, pending_throne_profile_url = NULL, + pending_throne_expires_at = NULL WHERE id = ? AND revision = ? AND status = 'active' AND EXISTS (SELECT 1 FROM profile_documents WHERE id = ? AND state = 'draft')`, ).bind(newRevision, baseVersion, now, draft.id, draft.revision, draft.document_id), @@ -712,3 +888,59 @@ export async function restartDraft(env: Env, input: RestartDraftInput): Promise< const updated = await loadOwnedDraft(env, draft.id, draft.owner_user_id); return buildContract(env, updated); } + +// --- wizard stage bookmark --------------------------------------------------------------------- + +export interface SetWizardStageInput { + readonly draftId: string; + readonly ownerUserId: string; + readonly expectedRevision: number; + readonly stage: WizardStage; + /** Always explicit: the route normalizes an omitted `substep` to `null` (see + * `parseWizardStageRequest`), so navigating without naming one always clears it. */ + readonly substep: string | null; +} + +/** + * Records where the private wizard is, durably, before the bot rerenders + * its message. + * + * This is a full first-class mutation, not a side note: it is + * ownership-checked, requires the draft to still be active, compare-and-swaps + * on `expected_revision`, and bumps the revision like every other draft + * mutation, so a duplicate click or a second device replaying an old + * navigation loses cleanly with `stale_revision` instead of dragging the + * winner's wizard backwards. A zero-row UPDATE is not an error in SQLite, so + * the CAS is verified through `meta.changes` rather than assumed. + */ +export async function setDraftWizardStage(env: Env, input: SetWizardStageInput): Promise { + const draft = await loadOwnedDraft(env, input.draftId, input.ownerUserId); + if (draft.status !== "active") conflict("draft_not_active", "this draft has already been published or restarted"); + if (draft.revision !== input.expectedRevision) { + conflict("stale_revision", "expected_revision does not match the draft's current revision"); + } + + const snapshot = (await readDocumentSnapshot(env, draft.document_id)) ?? EMPTY_SNAPSHOT; + const governingOrientation = await resolveGoverningOrientation(env, draft, snapshot); + const stages = wizardStagesForDraft(draft.target_scope, draft.server_mode, governingOrientation); + if (!stages.includes(input.stage)) { + badRequest("stage_not_applicable", `${input.stage} is not part of this draft's wizard sequence`); + } + + const now = nowIso(); + const newRevision = draft.revision + 1; + const results = await env.DB.batch([ + env.DB.prepare( + `UPDATE profile_drafts + SET wizard_stage = ?, wizard_substep = ?, revision = ?, updated_at = ? + WHERE id = ? AND revision = ? AND status = 'active'`, + ).bind(input.stage, input.substep, newRevision, now, draft.id, draft.revision), + ]); + const guardResult = results.at(-1); + if (guardResult === undefined || guardResult.meta.changes === 0) { + conflict("stale_revision", "expected_revision does not match the draft's current revision"); + } + + const updated = await loadOwnedDraft(env, draft.id, draft.owner_user_id); + return buildContract(env, updated); +} diff --git a/worker/src/profile/publishService.ts b/worker/src/profile/publishService.ts index 52cbf03..87d3039 100644 --- a/worker/src/profile/publishService.ts +++ b/worker/src/profile/publishService.ts @@ -79,6 +79,7 @@ export async function publishDraft(env: Env, input: PublishDraftInput): Promise< const linked = draft.target_scope === "server" && draft.server_mode === "linked"; let governingOrientation = snapshot.orientation; let governingCreatorId = snapshot.throneCreatorId; + let effectivePronouns = snapshot.selections.pronouns; if (linked) { const globalRoot = await env.DB.prepare("SELECT current_document_id FROM global_profiles WHERE owner_user_id = ?") .bind(draft.owner_user_id) @@ -89,10 +90,28 @@ export async function publishDraft(env: Env, input: PublishDraftInput): Promise< const globalSnapshot = await readDocumentSnapshot(env, globalRoot.current_document_id); governingOrientation = globalSnapshot?.orientation ?? null; governingCreatorId = globalSnapshot?.throneCreatorId ?? null; + if (!snapshot.overriddenFields.includes("pronouns")) { + effectivePronouns = globalSnapshot?.selections.pronouns ?? []; + } } if (governingOrientation === null) { badRequest("orientation_required", "orientation must be chosen before publishing"); } + if (governingCreatorId !== null) { + const verifiedCreator = await env.DB.prepare( + `SELECT id + FROM throne_creators + WHERE id = ? AND owner_discord_user_id = ? AND webhook_verified_at IS NOT NULL`, + ) + .bind(governingCreatorId, draft.owner_user_id) + .first(); + if (verifiedCreator === null) { + badRequest( + "throne_webhook_unverified", + "the connected Throne creator must pass Test Webhook before this profile can be published", + ); + } + } const requiredSteps = stepsForDraft(draft.target_scope, draft.server_mode, governingOrientation).filter( (step): step is Exclude => step !== "review", @@ -116,6 +135,9 @@ export async function publishDraft(env: Env, input: PublishDraftInput): Promise< if (!linked && snapshot.dmStatus === null) { badRequest("dm_status_required", "dm_status must be chosen before publishing"); } + if (effectivePronouns.length === 0) { + badRequest("pronouns_required", "at least one pronoun is required before publishing"); + } const caps = ORIENTATION_CAPABILITIES[governingOrientation]; if (!linked) { @@ -143,6 +165,15 @@ export async function publishDraft(env: Env, input: PublishDraftInput): Promise< ); } const legacyGuard = legacyRegistrationConflictGuard(registrationProjections); + const throneVerificationGuardSql = + governingCreatorId === null + ? "1 = 1" + : `EXISTS ( + SELECT 1 FROM throne_creators + WHERE id = ? AND owner_discord_user_id = ? AND webhook_verified_at IS NOT NULL + )`; + const throneVerificationGuardParams = + governingCreatorId === null ? [] : [governingCreatorId, draft.owner_user_id]; const now = nowIso(); const isGlobal = draft.target_scope === "global"; @@ -237,7 +268,8 @@ export async function publishDraft(env: Env, input: PublishDraftInput): Promise< (SELECT document_id FROM profile_drafts WHERE id = ? AND revision = ? AND status = 'published' - AND document_id = ? AND ${baseRootGuardSql} AND ${legacyGuard.sql}), + AND document_id = ? AND ${baseRootGuardSql} AND ${legacyGuard.sql} + AND ${throneVerificationGuardSql}), ? )`, ).bind( @@ -251,6 +283,7 @@ export async function publishDraft(env: Env, input: PublishDraftInput): Promise< newDocumentId, ...baseRootGuardParams, ...legacyGuard.params, + ...throneVerificationGuardParams, now, ), rootUpsertStatement, diff --git a/worker/src/profile/resolver.ts b/worker/src/profile/resolver.ts index 2f5b4c2..5d7bdbb 100644 --- a/worker/src/profile/resolver.ts +++ b/worker/src/profile/resolver.ts @@ -46,6 +46,12 @@ export interface ResolvedProfile { readonly aliases: string[]; readonly links: ResolvedLink[]; readonly preferredPaymentLinkId: string | null; + /** The published card's accent colour (0x000000-0xFFFFFF), or `null` for a neutral card. + * On a `linked` server profile this is the global document's colour unless the overlay + * carries a `profile_color` override row -- an override row with a NULL column means the + * owner deliberately chose "no colour" for this guild, which is *not* the same as + * inheriting a global colour that happens to be unset. */ + readonly profileColor: number | null; readonly throneConnected: boolean; /** Per-currency attributed send counts/totals for *this guild*, present only when * `publicSendStats` is enabled -- `null` otherwise (including when the capability/orientation @@ -70,6 +76,7 @@ interface DocumentRow { public_send_stats: number; throne_creator_id: string | null; preferred_payment_link_id: string | null; + profile_color: number | null; } interface SelectionRow { @@ -96,7 +103,8 @@ interface LinkRow { async function loadDocument(env: Env, documentId: string): Promise { return env.DB.prepare( - `SELECT id, owner_user_id, orientation, dm_status, bio, public_send_stats, throne_creator_id, preferred_payment_link_id + `SELECT id, owner_user_id, orientation, dm_status, bio, public_send_stats, throne_creator_id, + preferred_payment_link_id, profile_color FROM profile_documents WHERE id = ?`, ) .bind(documentId) @@ -178,6 +186,7 @@ async function resolveCompleteDocument( aliases, links, preferredPaymentLinkId: choosePreferredPayment(links, document.preferred_payment_link_id), + profileColor: document.profile_color, throneConnected: document.throne_creator_id !== null, sendStats: null, version, @@ -303,6 +312,7 @@ async function resolveLinkedOverlay( aliases, links, preferredPaymentLinkId: choosePreferredPayment(links, preferredCandidate), + profileColor: overridden.has("profile_color") ? overlayDoc.profile_color : globalDoc.profile_color, throneConnected: globalDoc.throne_creator_id !== null, sendStats: null, version: serverRoot.version, diff --git a/worker/src/profile/throneDraftService.ts b/worker/src/profile/throneDraftService.ts index 937934a..1545c0b 100644 --- a/worker/src/profile/throneDraftService.ts +++ b/worker/src/profile/throneDraftService.ts @@ -7,7 +7,8 @@ * the connection into a profile document rather than a guild registration. */ import type { Env } from "../env.js"; -import { nowIso } from "../util/id.js"; +import { newRouteSecret, nowIso } from "../util/id.js"; +import { constantTimeEqualHex, sha256Hex } from "../util/hash.js"; import { ORIENTATION_CAPABILITIES, type Orientation } from "./contracts.js"; import { EMPTY_SNAPSHOT, buildDocumentWriteStatements, readDocumentSnapshot, type DocumentSnapshot } from "./documentStore.js"; import { @@ -21,12 +22,34 @@ import { } from "./draftService.js"; import { buildPreparedCreatorStatements, + findCreatorByPublicId, + httpStatusForThroneErrorCode, + prepareAttachmentForIdentity, prepareThroneCreatorAttachment, prepareWebhookSecret, + resolveThroneIdentity, ThroneResolutionError, + type ResolvedThroneIdentity, type SqlMutationGuard, type WebhookState, } from "../throne/creatorService.js"; +import { DraftError } from "./draftService.js"; + +/** How long a resolved-but-unconfirmed Throne identity stays confirmable. Long enough for a + * member to read the handle and press a button, short enough that an abandoned confirmation + * cannot be replayed later against a handle that has since changed hands on Throne. */ +const PENDING_THRONE_TTL_MS = 15 * 60 * 1000; + +const CLEAR_PENDING_THRONE_SQL = `, + pending_throne_token_hash = NULL, pending_throne_public_creator_id = NULL, + pending_throne_handle = NULL, pending_throne_profile_url = NULL, + pending_throne_expires_at = NULL`; + +/** Re-raises a Throne resolution failure with the same HTTP status the legacy `/register` route + * uses, so "not found" is a 404 and "already linked by someone else" a 409 here too. */ +function failThroneResolution(error: ThroneResolutionError): never { + throw new DraftError(httpStatusForThroneErrorCode(error.code), error.code, error.message); +} function requireThroneCapableDraft(draft: DraftRow, governingOrientation: Orientation | null): void { if (governingOrientation === null || !ORIENTATION_CAPABILITIES[governingOrientation].throne) { @@ -81,6 +104,7 @@ async function bumpRevisionAndReturn( draft: DraftRow, newSnapshot: DocumentSnapshot | null, prefixStatements: readonly D1PreparedStatement[] = [], + options: { clearPendingThrone?: boolean } = {}, ): Promise { const now = nowIso(); const newRevision = draft.revision + 1; @@ -94,7 +118,7 @@ async function bumpRevisionAndReturn( statements.push( env.DB.prepare( `UPDATE profile_drafts - SET revision = ?, updated_at = ? + SET revision = ?, updated_at = ?${options.clearPendingThrone === true ? CLEAR_PENDING_THRONE_SQL : ""} WHERE id = ? AND revision = ? AND status = 'active' AND EXISTS (SELECT 1 FROM profile_documents WHERE id = ? AND state = 'draft')`, ).bind(newRevision, now, draft.id, draft.revision, draft.document_id), @@ -116,15 +140,152 @@ async function bumpRevisionAndReturn( return buildContract(env, updated); } +export interface ResolveThroneInput { + readonly draftId: string; + readonly ownerUserId: string; + readonly expectedRevision: number; + readonly throneInput: string; +} + +export interface ThroneResolveResult { + readonly draft: DraftContract; + /** The handle the owner is being asked to confirm. */ + readonly handle: string; + /** True when this Throne creator is already linked to this same Discord user *and* has + * already proven its webhook by delivering a signed Throne payload -- so confirming can reuse + * the existing connection instead of walking the owner through webhook setup again. */ + readonly alreadyVerified: boolean; + /** One-time, opaque capability authorizing exactly one confirmation of exactly this + * resolution on exactly this draft. Returned once; only its hash is stored. */ + readonly confirmationToken: string; + readonly expiresAt: string; +} + +/** + * Step one of the two-step Throne connection: resolve the owner's submitted + * username/URL and stage what was found, *without* creating a + * `throne_creators` row, minting a webhook secret, or touching the draft's + * document. + * + * Nothing here is success-shaped. The response deliberately carries no + * creator id (neither Bill's nor Throne's), no secret, and no webhook URL: + * only the handle to show on the "is this you?" screen, whether that + * creator is already verified for this same user, and an opaque capability + * to confirm with. Because the staged identity lives on the draft row, a + * confirmation screen survives a bot restart, and an abandoned resolution + * simply expires -- it can never leave a live webhook route or an orphan + * creator row behind. + */ +export async function resolveThroneForDraft(env: Env, input: ResolveThroneInput): Promise { + const { draft, governingOrientation } = await loadMutableDraft( + env, + input.draftId, + input.ownerUserId, + input.expectedRevision, + ); + requireThroneCapableDraft(draft, governingOrientation); + + let identity: ResolvedThroneIdentity; + try { + identity = await resolveThroneIdentity(input.throneInput); + } catch (error) { + if (error instanceof ThroneResolutionError) failThroneResolution(error); + throw error; + } + + // Ownership is checked here as well as at confirmation time, so a member who + // typed somebody else's handle is told immediately rather than after agreeing + // to connect it -- and either way no row or secret is created for it. + const existing = await findCreatorByPublicId(env, identity.publicCreatorId); + if (existing !== null && existing.owner_discord_user_id !== draft.owner_user_id) { + throw new DraftError(409, "creator_owned", "That Throne creator is already linked by a different Discord user"); + } + const alreadyVerified = existing !== null && (existing.webhook_verified_at ?? null) !== null; + + const confirmationToken = newRouteSecret(); + const tokenHash = await sha256Hex(confirmationToken); + const now = nowIso(); + const expiresAt = new Date(Date.now() + PENDING_THRONE_TTL_MS).toISOString(); + const newRevision = draft.revision + 1; + const confirmationSubstep = + draft.wizard_substep === "review" || draft.wizard_substep?.startsWith("review:") + ? "review:confirm" + : "confirm"; + + const results = await env.DB.batch([ + env.DB.prepare( + `UPDATE profile_drafts + SET pending_throne_token_hash = ?, pending_throne_public_creator_id = ?, + pending_throne_handle = ?, pending_throne_profile_url = ?, + pending_throne_expires_at = ?, wizard_stage = 'throne', + wizard_substep = ?, revision = ?, updated_at = ? + WHERE id = ? AND revision = ? AND status = 'active'`, + ).bind( + tokenHash, + identity.publicCreatorId, + identity.handle, + identity.profileUrl, + expiresAt, + confirmationSubstep, + newRevision, + now, + draft.id, + draft.revision, + ), + ]); + const guardResult = results.at(-1); + if (guardResult === undefined || guardResult.meta.changes === 0) { + conflict("stale_revision", "expected_revision does not match the draft's current revision"); + } + + const updated = await loadOwnedDraft(env, draft.id, draft.owner_user_id); + return { + draft: await buildContract(env, updated), + handle: identity.handle, + alreadyVerified, + confirmationToken, + expiresAt, + }; +} + +/** Validates a confirmation capability against the identity staged on this draft, returning that + * identity. Rejects a missing, mismatched, or expired staging without revealing which. */ +async function consumePendingIdentity(draft: DraftRow, confirmationToken: string): Promise { + const storedHash = draft.pending_throne_token_hash; + const publicCreatorId = draft.pending_throne_public_creator_id; + const handle = draft.pending_throne_handle; + const profileUrl = draft.pending_throne_profile_url; + if (storedHash === null || publicCreatorId === null || handle === null || profileUrl === null) { + badRequest("invalid_confirmation_token", "this draft has no Throne connection awaiting confirmation"); + } + const presentedHash = await sha256Hex(confirmationToken); + if (!(await constantTimeEqualHex(presentedHash, storedHash))) { + badRequest("invalid_confirmation_token", "that Throne confirmation is no longer valid; resolve the handle again"); + } + const expiresAt = draft.pending_throne_expires_at; + if (expiresAt !== null && Date.parse(expiresAt) <= Date.now()) { + badRequest("throne_confirmation_expired", "that Throne confirmation expired; resolve the handle again"); + } + return { publicCreatorId, handle, profileUrl }; +} + export interface AttachThroneInput { readonly draftId: string; readonly ownerUserId: string; readonly expectedRevision: number; - /** A Throne username/profile URL to resolve, mutually exclusive with `existingCreatorId`. */ + /** A Throne username/profile URL to resolve and attach in one step. Mutually exclusive with + * the other two inputs, and never used by the confirmed wizard flow. */ readonly throneInput: string | null; /** An id from this draft's `thronePrefill.ownedCreators`, to reattach a creator already owned * by this user without re-resolving it against Throne. */ readonly existingCreatorId: string | null; + /** The capability returned by `resolveThroneForDraft`: confirms the exact handle the owner was + * shown, with no creator id ever leaving the Worker. */ + readonly confirmationToken: string | null; + /** Confirms the currently staged identity using the bearer-authenticated, owner/revision-bound + * draft capability. This is the restart-safe wizard path: the plaintext one-time token is not + * persisted or embedded in Discord custom IDs. */ + readonly confirmPending?: boolean; readonly rotateWebhook: boolean; } @@ -134,9 +295,14 @@ export interface ThroneDraftResult { readonly webhookState: WebhookState | "unchanged"; } -/** Resolves/attaches a Throne creator to this draft's document. Exactly one of `throneInput`/ - * `existingCreatorId` must be supplied; a webhook secret is only ever returned in plaintext here, - * on the one request that issues or rotates it. */ +/** + * Attaches a Throne creator to this draft's document. Exactly one of + * `confirmationToken` (the wizard's confirmed flow), `existingCreatorId` + * (reattaching a creator this user already owns), or `throneInput` (direct, + * unconfirmed resolution) must be supplied. This is the only place a webhook + * secret is ever minted for a draft, and its plaintext URL is returned exactly + * once, on the request that issues or rotates it. + */ export async function attachThroneToDraft(env: Env, input: AttachThroneInput): Promise { const { draft, current, governingOrientation } = await loadMutableDraft( env, @@ -146,16 +312,64 @@ export async function attachThroneToDraft(env: Env, input: AttachThroneInput): P ); requireThroneCapableDraft(draft, governingOrientation); - if ((input.throneInput === null) === (input.existingCreatorId === null)) { - badRequest("throne_input_required", "exactly one of throne_input or existing_creator_id is required"); + const supplied = [ + input.throneInput, + input.existingCreatorId, + input.confirmationToken, + input.confirmPending ? "pending" : null, + ].filter((value) => value !== null); + if (supplied.length !== 1) { + badRequest( + "throne_input_required", + "exactly one Throne confirmation or attachment input is required", + ); } let creatorId: string; let webhookUrl: string | null = null; let webhookState: WebhookState | "unchanged" = "unchanged"; let creatorStatements: D1PreparedStatement[] = []; + let clearPendingThrone = false; const mutationGuard = draftMutationGuard(draft); + if (input.confirmationToken !== null || input.confirmPending) { + const identity = + input.confirmationToken !== null + ? await consumePendingIdentity(draft, input.confirmationToken) + : pendingIdentityForOwnedDraft(draft); + let prepared; + try { + // The staged identity, not a fresh network lookup, is what gets attached: the + // owner confirmed *that* handle, so a Throne-side rename between the two + // requests can never silently connect a different creator. + prepared = await prepareAttachmentForIdentity(env, draft.owner_user_id, identity, { + rotateWebhook: input.rotateWebhook, + }); + } catch (error) { + if (error instanceof ThroneResolutionError) failThroneResolution(error); + throw error; + } + + function pendingIdentityForOwnedDraft(draft: DraftRow): ResolvedThroneIdentity { + const publicCreatorId = draft.pending_throne_public_creator_id; + const handle = draft.pending_throne_handle; + const profileUrl = draft.pending_throne_profile_url; + if (publicCreatorId === null || handle === null || profileUrl === null) { + badRequest("pending_throne_required", "this draft has no Throne connection awaiting confirmation"); + } + const expiresAt = draft.pending_throne_expires_at; + if (expiresAt !== null && Date.parse(expiresAt) <= Date.now()) { + badRequest("throne_confirmation_expired", "that Throne confirmation expired; resolve the handle again"); + } + return { publicCreatorId, handle, profileUrl }; + } + creatorId = prepared.creatorId; + webhookUrl = prepared.webhookUrl; + webhookState = prepared.webhookState; + creatorStatements = buildPreparedCreatorStatements(env, prepared, mutationGuard); + clearPendingThrone = true; + } else + if (input.existingCreatorId !== null) { const owned = await env.DB.prepare("SELECT id FROM throne_creators WHERE id = ? AND owner_discord_user_id = ?") .bind(input.existingCreatorId, draft.owner_user_id) @@ -169,7 +383,7 @@ export async function attachThroneToDraft(env: Env, input: AttachThroneInput): P creatorStatements = [ env.DB.prepare( `UPDATE throne_creators - SET route_secret_hash = ?, updated_at = ? + SET route_secret_hash = ?, webhook_verified_at = NULL, updated_at = ? WHERE id = ? AND owner_discord_user_id = ? AND ${mutationGuard.sql}`, ).bind( rotated.routeSecretHash, @@ -200,7 +414,7 @@ export async function attachThroneToDraft(env: Env, input: AttachThroneInput): P } const newSnapshot: DocumentSnapshot = { ...current, throneCreatorId: creatorId }; - const contract = await bumpRevisionAndReturn(env, draft, newSnapshot, creatorStatements); + const contract = await bumpRevisionAndReturn(env, draft, newSnapshot, creatorStatements, { clearPendingThrone }); return { draft: contract, webhookUrl, webhookState }; } @@ -232,7 +446,7 @@ export async function rotateDraftThroneWebhook(env: Env, input: RotateThroneInpu const mutationGuard = draftMutationGuard(draft); const rotateStatement = env.DB.prepare( `UPDATE throne_creators - SET route_secret_hash = ?, updated_at = ? + SET route_secret_hash = ?, webhook_verified_at = NULL, updated_at = ? WHERE id = ? AND owner_discord_user_id = ? AND ${mutationGuard.sql}`, ).bind( rotated.routeSecretHash, @@ -247,3 +461,64 @@ export async function rotateDraftThroneWebhook(env: Env, input: RotateThroneInpu const contract = await bumpRevisionAndReturn(env, draft, null, [rotateStatement]); return { draft: contract, webhookUrl: rotated.webhookUrl, webhookState: "rotated" }; } + +export interface ThroneStatusInput { + readonly draftId: string; + readonly ownerUserId: string; + readonly expectedRevision: number; +} + +/** + * The deliberately tiny, secret-free view of a draft's Throne connection. + * + * `verified` reflects `throne_creators.webhook_verified_at`, which the public + * webhook route stamps the first time Throne delivers a correctly signed + * payload (including its "test webhook" button) -- so this is a genuine + * proof-of-delivery check, re-read live on every call rather than cached on + * the draft. Nothing identifying or sensitive is included: no creator id, no + * route secret or its hash, no webhook URL, no public Throne creator id. + */ +export interface ThroneDraftStatus { + readonly handle: string | null; + readonly verified: boolean; + readonly verifiedAt: string | null; +} + +/** + * `GET`-side counterpart to `attachThroneToDraft`: reports whether Throne has + * actually delivered a signed webhook for the creator connected to this + * draft. It is not a mutation, but it still requires the caller's + * `expected_revision` to match exactly, so a stale wizard message can never + * flip its own UI to "verified" using a status that belongs to a newer state + * of the draft (for instance one whose secret was rotated since). + */ +export async function getDraftThroneStatus(env: Env, input: ThroneStatusInput): Promise { + const draft = await loadOwnedDraft(env, input.draftId, input.ownerUserId); + if (draft.status !== "active") conflict("draft_not_active", "this draft has already been published or restarted"); + if (draft.revision !== input.expectedRevision) { + conflict("stale_revision", "expected_revision does not match the draft's current revision"); + } + + const current = (await readDocumentSnapshot(env, draft.document_id)) ?? EMPTY_SNAPSHOT; + const governingOrientation = await resolveGoverningOrientation(env, draft, current); + requireThroneCapableDraft(draft, governingOrientation); + + if (current.throneCreatorId === null) { + return { handle: null, verified: false, verifiedAt: null }; + } + const creator = await env.DB.prepare( + "SELECT handle, webhook_verified_at FROM throne_creators WHERE id = ? AND owner_discord_user_id = ?", + ) + .bind(current.throneCreatorId, draft.owner_user_id) + .first<{ handle: string; webhook_verified_at: string | null }>(); + if (creator === null) { + // The document points at a creator this user does not own (or that no longer + // exists). Report "not connected" rather than leaking that it exists at all. + return { handle: null, verified: false, verifiedAt: null }; + } + return { + handle: creator.handle, + verified: creator.webhook_verified_at !== null, + verifiedAt: creator.webhook_verified_at, + }; +} diff --git a/worker/src/routes/profileDrafts.ts b/worker/src/routes/profileDrafts.ts index c793647..ccf2b1d 100644 --- a/worker/src/routes/profileDrafts.ts +++ b/worker/src/routes/profileDrafts.ts @@ -2,12 +2,19 @@ import type { RouteContext } from "../router.js"; import { Errors, fail, ok } from "../util/response.js"; import { isSnowflake } from "../util/snowflake.js"; import { HomeGuildNotConfiguredError } from "../env.js"; -import { STEP_KEYS, type StepKey } from "../profile/contracts.js"; +import { + STEP_KEYS, + ValidationError, + parseWizardStageRequest, + type StepKey, + type WizardStageRequest, +} from "../profile/contracts.js"; import { DraftError, applyDraftStep, getDraftContract, restartDraft, + setDraftWizardStage, startDraft, type DraftContract, } from "../profile/draftService.js"; @@ -25,7 +32,7 @@ async function readJsonBody(request: Request): Promise | return body as Record; } -function serializeDraftContract(draft: DraftContract) { +export function serializeDraftContract(draft: DraftContract) { return { id: draft.id, owner_user_id: draft.ownerUserId, @@ -64,6 +71,7 @@ function serializeDraftContract(draft: DraftContract) { hidden_inherited_link_ids: draft.document.hiddenInheritedLinkIds, throne_creator_id: draft.document.throneCreatorId, preferred_payment_link_id: draft.document.preferredPaymentLinkId, + profile_color: draft.document.profileColor, }, throne_prefill: draft.thronePrefill === null @@ -72,6 +80,13 @@ function serializeDraftContract(draft: DraftContract) { owned_creators: draft.thronePrefill.ownedCreators.map((creator) => ({ id: creator.id, handle: creator.handle })), existing_registration_creator_id: draft.thronePrefill.existingRegistrationCreatorId, }, + wizard_stage: draft.wizardStage, + wizard_substep: draft.wizardSubstep, + throne_pending: + draft.thronePending === null + ? null + : { handle: draft.thronePending.handle, expires_at: draft.thronePending.expiresAt }, + resolved_profile_color: draft.resolvedProfileColor, created_at: draft.createdAt, updated_at: draft.updatedAt, published_at: draft.publishedAt, @@ -103,6 +118,7 @@ function serializeResolvedProfile(profile: ResolvedProfile) { sort_order: link.sortOrder, })), preferred_payment_link_id: profile.preferredPaymentLinkId, + profile_color: profile.profileColor, throne_connected: profile.throneConnected, send_stats: profile.sendStats === null @@ -219,6 +235,41 @@ function parseDraftMutationBody(body: Record | null): DraftMuta return { ownerUserId, expectedRevision }; } +/** + * `PUT /v1/profile-drafts/:draftId/wizard-stage` -- durably record which + * wizard screen the owner is on before the bot rerenders its message, so a + * message rebuilt after a restart (or opened on a second device) resumes on + * the same micro-screen. Body: the usual `owner_user_id`/`expected_revision` + * mutation envelope plus `stage`, and an optional `substep` whose omission + * deliberately clears any previous substep. + */ +export async function handleSetDraftWizardStage(ctx: RouteContext): Promise { + const draftId = ctx.params.draftId ?? ""; + const body = await readJsonBody(ctx.request); + const parsed = parseDraftMutationBody(body); + if (parsed instanceof Response) return parsed; + + let request: WizardStageRequest; + try { + request = parseWizardStageRequest(body as Record); + } catch (error) { + if (error instanceof ValidationError) return Errors.badRequest(error.message, error.code); + throw error; + } + + const result = await runDraftOperation(() => + setDraftWizardStage(ctx.env, { + draftId, + ownerUserId: parsed.ownerUserId, + expectedRevision: parsed.expectedRevision, + stage: request.stage, + substep: request.substep, + }), + ); + if (!result.ok) return result.response; + return ok({ draft: serializeDraftContract(result.value) }); +} + export async function handleRestartDraft(ctx: RouteContext): Promise { const draftId = ctx.params.draftId ?? ""; const parsed = parseDraftMutationBody(await readJsonBody(ctx.request)); diff --git a/worker/src/routes/profileLinks.ts b/worker/src/routes/profileLinks.ts index ae7440d..73aeac5 100644 --- a/worker/src/routes/profileLinks.ts +++ b/worker/src/routes/profileLinks.ts @@ -47,7 +47,10 @@ function serializeDraftContract(draft: DraftContract) { hidden_inherited_link_ids: draft.document.hiddenInheritedLinkIds, throne_creator_id: draft.document.throneCreatorId, preferred_payment_link_id: draft.document.preferredPaymentLinkId, + profile_color: draft.document.profileColor, }, + wizard_stage: draft.wizardStage, + wizard_substep: draft.wizardSubstep, created_at: draft.createdAt, updated_at: draft.updatedAt, published_at: draft.publishedAt, diff --git a/worker/src/routes/profileThrone.ts b/worker/src/routes/profileThrone.ts index cceb651..ebd4853 100644 --- a/worker/src/routes/profileThrone.ts +++ b/worker/src/routes/profileThrone.ts @@ -2,31 +2,16 @@ import type { RouteContext } from "../router.js"; import { Errors, fail, ok } from "../util/response.js"; import { isSnowflake } from "../util/snowflake.js"; import { HomeGuildNotConfiguredError } from "../env.js"; -import { DraftError, type DraftContract } from "../profile/draftService.js"; -import { attachThroneToDraft, rotateDraftThroneWebhook, type ThroneDraftResult } from "../profile/throneDraftService.js"; - -function serializeDraftContract(draft: DraftContract) { - return { - id: draft.id, - revision: draft.revision, - current_step: draft.currentStep, - next_step: draft.nextStep, - steps: draft.steps.map((step) => ({ key: step.key, status: step.status, completed_at: step.completedAt })), - dm_status_selected: draft.dmStatusSelected, - document: { - throne_creator_id: draft.document.throneCreatorId, - preferred_payment_link_id: draft.document.preferredPaymentLinkId, - }, - throne_prefill: - draft.thronePrefill === null - ? null - : { - owned_creators: draft.thronePrefill.ownedCreators.map((creator) => ({ id: creator.id, handle: creator.handle })), - existing_registration_creator_id: draft.thronePrefill.existingRegistrationCreatorId, - }, - updated_at: draft.updatedAt, - }; -} +import { DraftError } from "../profile/draftService.js"; +import { serializeDraftContract } from "./profileDrafts.js"; +import { + attachThroneToDraft, + getDraftThroneStatus, + resolveThroneForDraft, + rotateDraftThroneWebhook, + type ThroneDraftResult, + type ThroneResolveResult, +} from "../profile/throneDraftService.js"; function serializeThroneResult(result: ThroneDraftResult) { return { @@ -72,8 +57,50 @@ function parseCommonMutationFields(body: Record | null): { owne return { ownerUserId, expectedRevision }; } -/** `POST /v1/profile-drafts/:draftId/throne` -- resolve/attach a Throne creator (new username/URL, - * or an already-owned creator id from this draft's `throne_prefill`) to the draft's document. */ +function serializeResolveResult(result: ThroneResolveResult) { + return { + draft: serializeDraftContract(result.draft), + handle: result.handle, + already_verified: result.alreadyVerified, + confirmation_token: result.confirmationToken, + expires_at: result.expiresAt, + }; +} + +/** + * `POST /v1/profile-drafts/:draftId/throne/resolve` -- step one of connecting + * Throne: look up the submitted username/profile URL and stage what was found + * for confirmation. Creates no creator row, mints no webhook secret, and + * returns no identifiers -- only the handle to confirm, whether that creator is + * already verified for this same user, and a one-time confirmation capability + * to send back to `POST .../throne`. + */ +export async function handleResolveDraftThrone(ctx: RouteContext): Promise { + const draftId = ctx.params.draftId ?? ""; + const body = await readJsonBody(ctx.request); + const common = parseCommonMutationFields(body); + if (common instanceof Response) return common; + + const throneInputRaw = body!.throne_input; + if (typeof throneInputRaw !== "string" || throneInputRaw.trim().length === 0) { + return Errors.badRequest("throne_input must be a non-empty string", "invalid_throne_input"); + } + + const result = await runThroneOperation(() => + resolveThroneForDraft(ctx.env, { + draftId, + ownerUserId: common.ownerUserId, + expectedRevision: common.expectedRevision, + throneInput: throneInputRaw, + }), + ); + if (!result.ok) return result.response; + return ok(serializeResolveResult(result.value)); +} + +/** `POST /v1/profile-drafts/:draftId/throne` -- attach a Throne creator to the draft's document, + * confirming a staged resolution (`confirmation_token`), reattaching an already-owned creator + * (`existing_creator_id`), or resolving a username/URL directly (`throne_input`). */ export async function handleAttachDraftThrone(ctx: RouteContext): Promise { const draftId = ctx.params.draftId ?? ""; const body = await readJsonBody(ctx.request); @@ -82,12 +109,24 @@ export async function handleAttachDraftThrone(ctx: RouteContext): Promise @@ -97,6 +136,8 @@ export async function handleAttachDraftThrone(ctx: RouteContext): Promise { + const draftId = ctx.params.draftId ?? ""; + const params = new URL(ctx.request.url).searchParams; + const ownerUserId = params.get("owner_user_id"); + if (!isSnowflake(ownerUserId)) { + return Errors.badRequest("owner_user_id must be a Discord snowflake", "invalid_owner_user_id"); + } + const rawRevision = params.get("expected_revision"); + const expectedRevision = rawRevision === null ? Number.NaN : Number(rawRevision); + if (!/^\d+$/.test(rawRevision ?? "") || !Number.isSafeInteger(expectedRevision)) { + return Errors.badRequest("expected_revision must be a non-negative integer", "invalid_expected_revision"); + } + + const result = await runThroneOperation(() => + getDraftThroneStatus(ctx.env, { draftId, ownerUserId, expectedRevision }), + ); + if (!result.ok) return result.response; + return ok({ + handle: result.value.handle, + verified: result.value.verified, + verified_at: result.value.verifiedAt, + }); +} diff --git a/worker/src/routes/profiles.ts b/worker/src/routes/profiles.ts index 81007d2..69fe168 100644 --- a/worker/src/routes/profiles.ts +++ b/worker/src/routes/profiles.ts @@ -29,6 +29,7 @@ function serializeProfile(profile: ResolvedProfile) { sort_order: link.sortOrder, })), preferred_payment_link_id: profile.preferredPaymentLinkId, + profile_color: profile.profileColor, throne_connected: profile.throneConnected, send_stats: profile.sendStats === null diff --git a/worker/src/routes/webhookThrone.ts b/worker/src/routes/webhookThrone.ts index 590e1e0..f7035a0 100644 --- a/worker/src/routes/webhookThrone.ts +++ b/worker/src/routes/webhookThrone.ts @@ -1,5 +1,5 @@ import type { RouteContext } from "../router.js"; -import { resolveConfig } from "../env.js"; +import { resolveConfig, type Env } from "../env.js"; import { Errors, ok } from "../util/response.js"; import { constantTimeEqualHex, sha256Hex } from "../util/hash.js"; import { newId, nowIso } from "../util/id.js"; @@ -22,6 +22,17 @@ function isUniqueConstraintError(error: unknown): boolean { return /UNIQUE constraint failed/i.test(message); } +export function webhookVerificationStatement( + env: Env, + creatorId: string, + authenticatedRouteSecretHash: string, + verifiedAt: string, +): D1PreparedStatement { + return env.DB.prepare( + "UPDATE throne_creators SET webhook_verified_at = ? WHERE id = ? AND route_secret_hash = ?", + ).bind(verifiedAt, creatorId, authenticatedRouteSecretHash); +} + export async function handleThroneWebhook(ctx: RouteContext): Promise { const creatorId = ctx.params.creatorId ?? ""; const routeSecret = ctx.params.routeSecret ?? ""; @@ -87,9 +98,15 @@ export async function handleThroneWebhook(ctx: RouteContext): Promise if (parsed.isTest || isKnownTestSender) { // Explicit test events (and configured test senders) verify the webhook // but never create an event, send, or notification. - await ctx.env.DB.prepare("UPDATE throne_creators SET webhook_verified_at = ? WHERE id = ?") - .bind(nowIso(), creatorId) - .run(); + const verification = await webhookVerificationStatement( + ctx.env, + creatorId, + presentedHash, + nowIso(), + ).run(); + if (verification.meta.changes !== 1) { + return Errors.notFound("Route not found"); + } return ok({ status: "test", verified: true }); } @@ -109,7 +126,11 @@ export async function handleThroneWebhook(ctx: RouteContext): Promise id, creator_id, raw_type, normalized_type, event_id, order_id, fallback_hash, amount_minor, currency, sender_username, sender_display_name, item_name, item_image_url, is_private, is_anonymous, purchased_at, received_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM throne_creators WHERE id = ? AND route_secret_hash = ? + )`, ).bind( eventRowId, creatorId, @@ -128,11 +149,16 @@ export async function handleThroneWebhook(ctx: RouteContext): Promise parsed.isAnonymous ? 1 : 0, parsed.purchasedAt, receivedAt, + creatorId, + presentedHash, ); - const markVerifiedStmt = ctx.env.DB.prepare( - "UPDATE throne_creators SET webhook_verified_at = ? WHERE id = ?", - ).bind(receivedAt, creatorId); + const markVerifiedStmt = webhookVerificationStatement( + ctx.env, + creatorId, + presentedHash, + receivedAt, + ); // Attribution only ever runs when the parser has a sender name to match at all; it has already // nulled both sender fields for private/anonymous events, so those never reach this branch. @@ -154,17 +180,27 @@ export async function handleThroneWebhook(ctx: RouteContext): Promise const notificationId = newId(); fanOutStatements.push( ctx.env.DB.prepare( - "INSERT INTO sends (id, event_id, guild_id, registration_id, sender_discord_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", - ).bind(sendId, eventRowId, registration.guild_id, registration.id, senderDiscordUserId, receivedAt), + `INSERT INTO sends (id, event_id, guild_id, registration_id, sender_discord_user_id, created_at) + SELECT ?, ?, ?, ?, ?, ? + WHERE EXISTS (SELECT 1 FROM throne_events WHERE id = ?)`, + ).bind(sendId, eventRowId, registration.guild_id, registration.id, senderDiscordUserId, receivedAt, eventRowId), ctx.env.DB.prepare( `INSERT INTO notifications (id, send_id, status, attempts, max_attempts, next_attempt_at, created_at, updated_at) - VALUES (?, ?, 'pending', 0, ?, ?, ?, ?)`, - ).bind(notificationId, sendId, maxAttempts, receivedAt, receivedAt, receivedAt), + SELECT ?, ?, 'pending', 0, ?, ?, ?, ? + WHERE EXISTS (SELECT 1 FROM sends WHERE id = ?)`, + ).bind(notificationId, sendId, maxAttempts, receivedAt, receivedAt, receivedAt, sendId), ); } try { - await ctx.env.DB.batch([insertEventStmt, markVerifiedStmt, ...fanOutStatements]); + const results = await ctx.env.DB.batch([ + insertEventStmt, + markVerifiedStmt, + ...fanOutStatements, + ]); + if (results[1]?.meta.changes !== 1) { + return Errors.notFound("Route not found"); + } } catch (error) { if (!isUniqueConstraintError(error)) { console.error("Failed to record Throne event", error instanceof Error ? error.message : "unknown"); @@ -174,9 +210,15 @@ export async function handleThroneWebhook(ctx: RouteContext): Promise // batch above rolled back entirely, so no partial fan-out occurred. A // duplicate of a real, supported event still proves the webhook works, // so it marks verification on its own. - await ctx.env.DB.prepare("UPDATE throne_creators SET webhook_verified_at = ? WHERE id = ?") - .bind(nowIso(), creatorId) - .run(); + const verification = await webhookVerificationStatement( + ctx.env, + creatorId, + presentedHash, + nowIso(), + ).run(); + if (verification.meta.changes !== 1) { + return Errors.notFound("Route not found"); + } return ok({ status: "duplicate" }); } diff --git a/worker/src/throne/creatorService.ts b/worker/src/throne/creatorService.ts index 92e40be..1127492 100644 --- a/worker/src/throne/creatorService.ts +++ b/worker/src/throne/creatorService.ts @@ -22,11 +22,12 @@ export class ThroneResolutionError extends Error { } } -interface CreatorRow { +export interface CreatorRow { id: string; public_creator_id: string; handle: string; owner_discord_user_id: string; + webhook_verified_at?: string | null; } export function buildWebhookUrl(publicBaseUrl: string, creatorId: string, secret: string): string { @@ -64,17 +65,19 @@ export interface SqlMutationGuard { } /** - * Performs network resolution and prepares secret material without writing D1. - * Profile drafts use the returned values to put the creator mutation and their - * revision CAS in one guarded batch; the legacy API executes the same prepared - * statement immediately. + * A Throne creator as the network resolver found it, before anything about + * it is written to D1. This is the whole payload of the "is this you?" + * confirmation screen: resolving is a pure read, so it can safely run + * before the owner has agreed to connect anything. */ -export async function prepareThroneCreatorAttachment( - env: Env, - ownerUserId: string, - rawThroneInput: string, - options: { rotateWebhook: boolean }, -): Promise { +export interface ResolvedThroneIdentity { + readonly publicCreatorId: string; + readonly handle: string; + readonly profileUrl: string; +} + +/** Normalizes and network-resolves a username/profile URL. Writes nothing and issues no secret. */ +export async function resolveThroneIdentity(rawThroneInput: string): Promise { const normalized = normalizeThroneInput(rawThroneInput); if (!normalized) { throw new ThroneResolutionError("invalid_throne_input", "throne must be a Throne username or profile URL"); @@ -85,11 +88,42 @@ export async function prepareThroneCreatorAttachment( throw new ThroneResolutionError("throne_creator_not_found", "Could not resolve that Throne creator"); } - const existing = await env.DB.prepare( - "SELECT id, public_creator_id, handle, owner_discord_user_id FROM throne_creators WHERE public_creator_id = ?", + return { + publicCreatorId: resolved.publicCreatorId, + handle: resolved.handle, + profileUrl: normalized.profileUrl, + }; +} + +/** The `throne_creators` row for a resolved identity, if this Throne creator is known at all. */ +export async function findCreatorByPublicId(env: Env, publicCreatorId: string): Promise { + return env.DB.prepare( + `SELECT id, public_creator_id, handle, owner_discord_user_id, webhook_verified_at + FROM throne_creators WHERE public_creator_id = ?`, ) - .bind(resolved.publicCreatorId) + .bind(publicCreatorId) .first(); +} + +/** + * Prepares the creator row mutation and (only where one is actually needed) + * secret material for an already-resolved identity, without writing D1. + * Profile drafts use the returned values to put the creator mutation and + * their revision CAS in one guarded batch; the legacy API executes the same + * prepared statement immediately. + * + * Splitting this from `resolveThroneIdentity` is what lets the draft wizard + * resolve a handle, show it for confirmation, and only reach this function -- + * the first step that can ever mint a webhook secret -- once the owner has + * confirmed. + */ +export async function prepareAttachmentForIdentity( + env: Env, + ownerUserId: string, + identity: ResolvedThroneIdentity, + options: { rotateWebhook: boolean }, +): Promise { + const existing = await findCreatorByPublicId(env, identity.publicCreatorId); if (existing && existing.owner_discord_user_id !== ownerUserId) { throw new ThroneResolutionError( @@ -123,17 +157,34 @@ export async function prepareThroneCreatorAttachment( return { creatorId, - handle: resolved.handle, + handle: identity.handle, webhookUrl, webhookState, - publicCreatorId: resolved.publicCreatorId, - profileUrl: normalized.profileUrl, + publicCreatorId: identity.publicCreatorId, + profileUrl: identity.profileUrl, ownerUserId, existing: existing !== null, routeSecretHash, }; } +/** + * Performs network resolution and prepares secret material without writing D1. + * Used by the legacy guild-scoped `/register` flow and by the draft Throne + * step's direct (unconfirmed) input path; the draft wizard's confirmed flow + * instead calls `resolveThroneIdentity` and `prepareAttachmentForIdentity` + * across two separate requests. + */ +export async function prepareThroneCreatorAttachment( + env: Env, + ownerUserId: string, + rawThroneInput: string, + options: { rotateWebhook: boolean }, +): Promise { + const identity = await resolveThroneIdentity(rawThroneInput); + return prepareAttachmentForIdentity(env, ownerUserId, identity, options); +} + export function buildPreparedCreatorStatements( env: Env, prepared: PreparedThroneAttachment, @@ -167,7 +218,8 @@ export function buildPreparedCreatorStatements( return [ env.DB.prepare( `UPDATE throne_creators - SET handle = ?, profile_url = ?, route_secret_hash = ?, updated_at = ? + SET handle = ?, profile_url = ?, route_secret_hash = ?, + webhook_verified_at = NULL, updated_at = ? WHERE id = ? AND owner_discord_user_id = ?${guardSuffix}`, ).bind( prepared.handle, @@ -232,7 +284,7 @@ export async function prepareWebhookSecret( export async function rotateThroneWebhookSecret(env: Env, creatorId: string): Promise<{ webhookUrl: string }> { const prepared = await prepareWebhookSecret(env, creatorId); const result = await env.DB.prepare( - "UPDATE throne_creators SET route_secret_hash = ?, updated_at = ? WHERE id = ?", + "UPDATE throne_creators SET route_secret_hash = ?, webhook_verified_at = NULL, updated_at = ? WHERE id = ?", ) .bind(prepared.routeSecretHash, nowIso(), creatorId) .run(); diff --git a/worker/test/contracts.test.ts b/worker/test/contracts.test.ts index fbfaaa4..73d8a47 100644 --- a/worker/test/contracts.test.ts +++ b/worker/test/contracts.test.ts @@ -88,6 +88,12 @@ describe("parseLinkedIdentityStep", () => { it("rejects overriding a field the governing orientation does not support", () => { expect(() => parseLinkedIdentityStep({ overrides: ["submissive_labels"] }, "domme")).toThrow(ValidationError); }); + + it("rejects an explicit empty pronoun override", () => { + expect(() => + parseLinkedIdentityStep({ overrides: ["pronouns"], pronouns: [] }, "domme"), + ).toThrow(expect.objectContaining({ code: "pronouns_required" })); + }); }); describe("parseLinkStep", () => { diff --git a/worker/test/migrations0004.test.ts b/worker/test/migrations0004.test.ts new file mode 100644 index 0000000..3df920e --- /dev/null +++ b/worker/test/migrations0004.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from "vitest"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { authHeaders, callWorker, jsonRequest, readJson, TEST_HOME_GUILD_ID } from "./helpers"; +import { seedAlias, seedDocument, seedGlobalProfile, seedLink, seedSelection } from "./profileHelpers"; + +/** Inserts a document through the *pre-0004* column list, since `seedDocument` (like the Worker + * itself) now always writes `profile_color`. */ +async function seedPre0004Document(id: string, ownerUserId: string, state: string, bio: string | null): Promise { + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO profile_documents + (id, owner_user_id, state, orientation, dm_status, bio, public_send_stats, + throne_creator_id, preferred_payment_link_id, created_at, updated_at) + VALUES (?, ?, ?, 'domme', 'open', ?, 0, NULL, NULL, ?, ?)`, + ) + .bind(id, ownerUserId, state, bio, now, now) + .run(); +} + +/** + * Mirrors `migrations.test.ts` (0002) and `migrations0003.test.ts` (0003): + * rewind D1 to an exact pre-0004 shape, populate it through the schema an + * already-running deployment would have, then replay migrations so 0004 + * applies for real over populated 0001-0003 data. 0004 only adds nullable + * columns, so every pre-existing row must survive byte-for-byte apart from + * the new NULL columns, and every pre-existing draft must still resume. + */ +describe("0004 migration additive safety", () => { + it("applies over populated 0001-0003 data, preserving every row and back-filling only NULLs", async () => { + // SQLite cannot drop a column, so rebuild both altered tables at their + // exact pre-0004 shapes, copy the data across, and forget 0004 ever ran. + await env.DB.prepare( + `CREATE TABLE profile_documents_pre0004 ( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('draft', 'published', 'superseded')), + orientation TEXT CHECK (orientation IN ('domme', 'submissive', 'switch_domme', 'switch_submissive')), + dm_status TEXT CHECK (dm_status IN ('open', 'by_request', 'after_tribute', 'closed')), + bio TEXT CHECK (bio IS NULL OR length(bio) <= 300), + public_send_stats INTEGER NOT NULL DEFAULT 0 CHECK (public_send_stats IN (0, 1)), + throne_creator_id TEXT, + preferred_payment_link_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + ).run(); + await env.DB.prepare( + `INSERT INTO profile_documents_pre0004 + (id, owner_user_id, state, orientation, dm_status, bio, public_send_stats, + throne_creator_id, preferred_payment_link_id, created_at, updated_at) + SELECT id, owner_user_id, state, orientation, dm_status, bio, public_send_stats, + throne_creator_id, preferred_payment_link_id, created_at, updated_at + FROM profile_documents`, + ).run(); + await env.DB.prepare("DROP TABLE profile_documents").run(); + await env.DB.prepare("ALTER TABLE profile_documents_pre0004 RENAME TO profile_documents").run(); + await env.DB.prepare("CREATE INDEX idx_profile_documents_owner ON profile_documents (owner_user_id)").run(); + await env.DB.prepare( + "CREATE INDEX idx_profile_documents_owner_state ON profile_documents (owner_user_id, state)", + ).run(); + + await env.DB.prepare( + `CREATE TABLE profile_drafts_pre0004 ( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL, + origin_guild_id TEXT, + target_scope TEXT NOT NULL CHECK (target_scope IN ('global', 'server')), + guild_id TEXT, + server_mode TEXT CHECK (server_mode IN ('linked', 'independent')), + document_id TEXT NOT NULL REFERENCES profile_documents (id), + base_version INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'published')), + current_step TEXT NOT NULL DEFAULT 'orientation', + revision INTEGER NOT NULL DEFAULT 0, + intro_message_id TEXT, + wizard_message_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + published_at TEXT + )`, + ).run(); + await env.DB.prepare( + `INSERT INTO profile_drafts_pre0004 + (id, owner_user_id, origin_guild_id, target_scope, guild_id, server_mode, document_id, + base_version, status, current_step, revision, intro_message_id, wizard_message_id, + created_at, updated_at, published_at) + SELECT id, owner_user_id, origin_guild_id, target_scope, guild_id, server_mode, document_id, + base_version, status, current_step, revision, intro_message_id, wizard_message_id, + created_at, updated_at, published_at + FROM profile_drafts`, + ).run(); + await env.DB.prepare("DROP TABLE profile_drafts").run(); + await env.DB.prepare("ALTER TABLE profile_drafts_pre0004 RENAME TO profile_drafts").run(); + await env.DB.prepare( + `CREATE UNIQUE INDEX idx_profile_drafts_active_global + ON profile_drafts (owner_user_id) + WHERE target_scope = 'global' AND status = 'active'`, + ).run(); + await env.DB.prepare( + `CREATE UNIQUE INDEX idx_profile_drafts_active_server + ON profile_drafts (guild_id, owner_user_id) + WHERE target_scope = 'server' AND status = 'active'`, + ).run(); + await env.DB.prepare("CREATE INDEX idx_profile_drafts_owner ON profile_drafts (owner_user_id)").run(); + await env.DB.prepare("DELETE FROM d1_migrations WHERE name = '0004_profile_color_and_wizard_stage.sql'").run(); + + const documentColumnsBefore = await env.DB.prepare("PRAGMA table_info(profile_documents)").all<{ name: string }>(); + expect(documentColumnsBefore.results.map((row) => row.name)).not.toContain("profile_color"); + const draftColumnsBefore = await env.DB.prepare("PRAGMA table_info(profile_drafts)").all<{ name: string }>(); + expect(draftColumnsBefore.results.map((row) => row.name)).not.toContain("wizard_stage"); + + // Populate through the pre-0004 schema, exactly like a live deployment would: + // one published global profile and one half-finished active draft. + const owner = "960000000000000001"; + await seedPre0004Document("doc-pre0004-published", owner, "published", "pre-existing bio"); + await seedSelection("doc-pre0004-published", "pronoun", "She/Her"); + await seedAlias("doc-pre0004-published", "PreExisting", "preexisting"); + await seedLink({ + id: "link-pre0004", + documentId: "doc-pre0004-published", + platform: "bluesky", + publicLabel: "Bluesky", + normalizedUrl: "https://bsky.app/profile/pre.test", + linkType: "social", + }); + await seedGlobalProfile(owner, "doc-pre0004-published", 2); + + await seedPre0004Document("doc-pre0004-draft", owner, "draft", null); + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO profile_drafts + (id, owner_user_id, origin_guild_id, target_scope, guild_id, server_mode, document_id, + base_version, status, current_step, revision, created_at, updated_at) + VALUES (?, ?, ?, 'global', NULL, NULL, ?, 2, 'active', 'identity', 4, ?, ?)`, + ) + .bind("draft-pre0004", owner, TEST_HOME_GUILD_ID, "doc-pre0004-draft", now, now) + .run(); + await env.DB.prepare( + `INSERT INTO profile_draft_steps (draft_id, step_key, status, completed_at) + VALUES ('draft-pre0004', 'orientation', 'completed', ?), ('draft-pre0004', 'identity', 'completed', ?)`, + ) + .bind(now, now) + .run(); + + const publishedBefore = await env.DB.prepare("SELECT * FROM profile_documents WHERE id = ?") + .bind("doc-pre0004-published") + .first(); + const draftDocBefore = await env.DB.prepare("SELECT * FROM profile_documents WHERE id = ?") + .bind("doc-pre0004-draft") + .first(); + const draftBefore = await env.DB.prepare("SELECT * FROM profile_drafts WHERE id = ?") + .bind("draft-pre0004") + .first(); + const linkBefore = await env.DB.prepare("SELECT * FROM profile_links WHERE id = ?").bind("link-pre0004").first(); + const aliasBefore = await env.DB.prepare("SELECT * FROM profile_aliases WHERE document_id = ?") + .bind("doc-pre0004-published") + .first(); + const documentCountBefore = await env.DB.prepare("SELECT COUNT(*) AS count FROM profile_documents").first<{ + count: number; + }>(); + + await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); + + // Only the new nullable columns appear; nothing is dropped or renamed. + const documentColumnsAfter = (await env.DB.prepare("PRAGMA table_info(profile_documents)").all<{ name: string }>()) + .results.map((row) => row.name); + expect(documentColumnsAfter).toEqual([...documentColumnsBefore.results.map((row) => row.name), "profile_color"]); + const draftColumnsAfter = (await env.DB.prepare("PRAGMA table_info(profile_drafts)").all<{ name: string }>()).results + .map((row) => row.name); + expect(draftColumnsAfter).toEqual([ + ...draftColumnsBefore.results.map((row) => row.name), + "wizard_stage", + "wizard_substep", + "pending_throne_token_hash", + "pending_throne_public_creator_id", + "pending_throne_handle", + "pending_throne_profile_url", + "pending_throne_expires_at", + ]); + + expect(await env.DB.prepare("SELECT * FROM profile_documents WHERE id = ?").bind("doc-pre0004-published").first()) + .toEqual({ ...(publishedBefore as object), profile_color: null }); + expect(await env.DB.prepare("SELECT * FROM profile_documents WHERE id = ?").bind("doc-pre0004-draft").first()) + .toEqual({ ...(draftDocBefore as object), profile_color: null }); + expect(await env.DB.prepare("SELECT * FROM profile_drafts WHERE id = ?").bind("draft-pre0004").first()).toEqual({ + ...(draftBefore as object), + wizard_stage: null, + wizard_substep: null, + pending_throne_token_hash: null, + pending_throne_public_creator_id: null, + pending_throne_handle: null, + pending_throne_profile_url: null, + pending_throne_expires_at: null, + }); + expect(await env.DB.prepare("SELECT * FROM profile_links WHERE id = ?").bind("link-pre0004").first()).toEqual( + linkBefore, + ); + expect( + await env.DB.prepare("SELECT * FROM profile_aliases WHERE document_id = ?").bind("doc-pre0004-published").first(), + ).toEqual(aliasBefore); + expect( + (await env.DB.prepare("SELECT COUNT(*) AS count FROM profile_documents").first<{ count: number }>())?.count, + ).toBe(documentCountBefore?.count); + }); + + it("enforces the sRGB range on the new colour column and accepts NULL as a real value", async () => { + await seedDocument({ id: "doc-0004-range", ownerUserId: "960000000000000002", orientation: "domme" }); + + await expect( + env.DB.prepare("UPDATE profile_documents SET profile_color = ? WHERE id = ?") + .bind(-1, "doc-0004-range") + .run(), + ).rejects.toThrow(); + await expect( + env.DB.prepare("UPDATE profile_documents SET profile_color = ? WHERE id = ?") + .bind(0x1000000, "doc-0004-range") + .run(), + ).rejects.toThrow(); + + for (const value of [0, 0x5865f2, 0xffffff, null]) { + await env.DB.prepare("UPDATE profile_documents SET profile_color = ? WHERE id = ?") + .bind(value, "doc-0004-range") + .run(); + const row = await env.DB.prepare("SELECT profile_color FROM profile_documents WHERE id = ?") + .bind("doc-0004-range") + .first<{ profile_color: number | null }>(); + expect(row?.profile_color).toBe(value); + } + }); + + it("leaves the wizard columns free-form so the bot's vocabulary can grow without a migration", async () => { + const owner = "960000000000000003"; + await seedDocument({ id: "doc-0004-vocab", ownerUserId: owner, state: "draft", orientation: "domme" }); + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO profile_drafts + (id, owner_user_id, origin_guild_id, target_scope, guild_id, server_mode, document_id, + base_version, status, current_step, revision, created_at, updated_at) + VALUES ('draft-0004-vocab', ?, ?, 'global', NULL, NULL, 'doc-0004-vocab', 0, 'active', 'identity', 0, ?, ?)`, + ) + .bind(owner, TEST_HOME_GUILD_ID, now, now) + .run(); + await env.DB.prepare( + "INSERT INTO profile_draft_steps (draft_id, step_key, status, completed_at) VALUES ('draft-0004-vocab', 'orientation', 'completed', ?)", + ) + .bind(now) + .run(); + + await env.DB.prepare( + "UPDATE profile_drafts SET wizard_stage = 'some_future_screen', wizard_substep = 'step-2' WHERE id = ?", + ) + .bind("draft-0004-vocab") + .run(); + const row = await env.DB.prepare("SELECT wizard_stage, wizard_substep FROM profile_drafts WHERE id = ?") + .bind("draft-0004-vocab") + .first<{ wizard_stage: string; wizard_substep: string }>(); + expect(row).toEqual({ wizard_stage: "some_future_screen", wizard_substep: "step-2" }); + + // The Worker, not the schema, is what rejects an unknown stage -- and the draft + // contract ignores the unknown stored value rather than echoing it back. + const response = await callWorker( + jsonRequest( + "GET", + `/v1/profile-drafts/draft-0004-vocab?owner_user_id=${owner}`, + undefined, + authHeaders(), + ), + ); + const parsed = await readJson<{ data: { draft: { wizard_stage: string; wizard_substep: string | null } } }>( + response, + ); + expect(parsed.data.draft.wizard_stage).toBe("pronouns"); + expect(parsed.data.draft.wizard_substep).toBe("step-2"); + }); + + it("lets a draft created before 0004 resume, and start recording a bookmark", async () => { + const owner = "960000000000000004"; + await seedDocument({ id: "doc-0004-resume", ownerUserId: owner, state: "draft", orientation: "domme" }); + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO profile_drafts + (id, owner_user_id, origin_guild_id, target_scope, guild_id, server_mode, document_id, + base_version, status, current_step, revision, created_at, updated_at) + VALUES ('draft-0004-resume', ?, ?, 'global', NULL, NULL, 'doc-0004-resume', 0, 'active', 'links', 7, ?, ?)`, + ) + .bind(owner, TEST_HOME_GUILD_ID, now, now) + .run(); + for (const step of ["orientation", "identity", "links"]) { + await env.DB.prepare( + "INSERT INTO profile_draft_steps (draft_id, step_key, status, completed_at) VALUES ('draft-0004-resume', ?, 'completed', ?)", + ) + .bind(step, now) + .run(); + } + + const before = await callWorker( + jsonRequest("GET", `/v1/profile-drafts/draft-0004-resume?owner_user_id=${owner}`, undefined, authHeaders()), + ); + const beforeDraft = ( + await readJson<{ data: { draft: { wizard_stage: string; wizard_substep: string | null; revision: number } } }>( + before, + ) + ).data.draft; + // Pre-0004 rows have no deliberate DM-status marker, so identity must be revisited. + expect(beforeDraft.wizard_stage).toBe("pronouns"); + expect(beforeDraft.wizard_substep).toBeNull(); + + const moved = await callWorker( + jsonRequest( + "PUT", + "/v1/profile-drafts/draft-0004-resume/wizard-stage", + { owner_user_id: owner, expected_revision: 7, stage: "profile_color" }, + authHeaders(), + ), + ); + expect(moved.status).toBe(200); + const movedDraft = (await readJson<{ data: { draft: { wizard_stage: string; revision: number } } }>(moved)).data + .draft; + expect(movedDraft.wizard_stage).toBe("profile_color"); + expect(movedDraft.revision).toBe(8); + expect( + await env.DB.prepare("SELECT wizard_stage FROM profile_drafts WHERE id = ?") + .bind("draft-0004-resume") + .first<{ wizard_stage: string }>(), + ).toEqual({ wizard_stage: "profile_color" }); + }); +}); diff --git a/worker/test/profileColor.test.ts b/worker/test/profileColor.test.ts new file mode 100644 index 0000000..466ecfc --- /dev/null +++ b/worker/test/profileColor.test.ts @@ -0,0 +1,494 @@ +import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:test"; +import { + LIMITS, + PROFILE_COLOR_PRESETS, + parseIdentityStep, + parseLinkedIdentityStep, + parseOptionalColor, + ValidationError, +} from "../src/profile/contracts"; +import { readDocumentSnapshot } from "../src/profile/documentStore"; +import { resolveProfile } from "../src/profile/resolver"; +import { authHeaders, callWorker, jsonRequest, readJson, TEST_HOME_GUILD_ID } from "./helpers"; +import { seedDocument, seedGlobalProfile, seedOverride, seedSelection, seedServerProfile } from "./profileHelpers"; + +const OTHER_GUILD = "710000000000000001"; +const ROSE = 0xe0568a; +const TEAL = 0x2aa198; + +interface DraftBody { + id: string; + revision: number; + resolved_profile_color: number | null; + document: { profile_color: number | null; overridden_fields: string[] }; +} +interface DraftEnvelope { + data: { draft: DraftBody }; + error?: { code: string; message: string }; +} + +async function startDraft(body: Record): Promise { + const response = await callWorker(jsonRequest("POST", "/v1/profile-drafts/start", body, authHeaders())); + const parsed = await readJson(response); + return parsed.data.draft; +} + +async function putStep(draftId: string, stepKey: string, body: Record) { + const response = await callWorker( + jsonRequest("PUT", `/v1/profile-drafts/${draftId}/steps/${stepKey}`, body, authHeaders()), + ); + const parsed = await readJson(response); + return { status: response.status, draft: parsed.data?.draft, error: parsed.error }; +} + +async function getDraft(draftId: string, owner: string): Promise { + const response = await callWorker( + jsonRequest("GET", `/v1/profile-drafts/${draftId}?owner_user_id=${owner}`, undefined, authHeaders()), + ); + return (await readJson(response)).data.draft; +} + +async function publish(draftId: string, owner: string, expectedRevision: number) { + const response = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${draftId}/publish`, + { owner_user_id: owner, expected_revision: expectedRevision }, + authHeaders(), + ), + ); + const parsed = await readJson<{ data?: { profile: Record }; error?: { code: string } }>(response); + return { status: response.status, profile: parsed.data?.profile, error: parsed.error }; +} + +async function lookup(guildId: string, userId: string) { + const response = await callWorker( + jsonRequest("GET", `/v1/guilds/${guildId}/profiles/${userId}`, undefined, authHeaders()), + ); + return (await readJson<{ data: { profile: Record | null } }>(response)).data.profile; +} + +function identityBody(owner: string, revision: number, extra: Record): Record { + return { + owner_user_id: owner, + expected_revision: revision, + pronouns: ["She/Her"], + honourifics: [], + submissive_labels: [], + dm_status: "open", + dm_status_selected: true, + bio: null, + public_send_stats: false, + aliases: [], + ...extra, + }; +} + +describe("profile_color parsing", () => { + it("accepts any in-range sRGB integer, and treats absent/null as no colour", () => { + expect(parseOptionalColor(0)).toBe(0); + expect(parseOptionalColor(ROSE)).toBe(ROSE); + expect(parseOptionalColor(LIMITS.profileColorMax)).toBe(LIMITS.profileColorMax); + expect(parseOptionalColor(null)).toBeNull(); + expect(parseOptionalColor(undefined)).toBeNull(); + }); + + it("keeps every documented preset inside the storage range", () => { + expect(PROFILE_COLOR_PRESETS.length).toBeGreaterThan(0); + for (const preset of PROFILE_COLOR_PRESETS) { + expect(parseOptionalColor(preset.value)).toBe(preset.value); + } + expect(new Set(PROFILE_COLOR_PRESETS.map((preset) => preset.value)).size).toBe(PROFILE_COLOR_PRESETS.length); + }); + + it("rejects out-of-range and non-integer colours", () => { + expect(() => parseOptionalColor(-1)).toThrow(ValidationError); + expect(() => parseOptionalColor(LIMITS.profileColorMax + 1)).toThrow(ValidationError); + try { + parseOptionalColor(0x1000000); + } catch (error) { + expect((error as ValidationError).code).toBe("invalid_profile_color"); + } + for (const bad of [1.5, "#e0568a", "e0568a", true, {}, []]) { + expect(() => parseOptionalColor(bad)).toThrow(ValidationError); + } + try { + parseOptionalColor("#e0568a"); + } catch (error) { + expect((error as ValidationError).code).toBe("invalid_field"); + } + }); + + it("parses the colour on the identity step for every orientation, including partial bodies", () => { + const base = { + pronouns: ["She/Her"], + honourifics: [], + submissive_labels: [], + dm_status: null, + bio: null, + public_send_stats: false, + aliases: [], + }; + expect(parseIdentityStep({ ...base, profile_color: ROSE }, "domme", true).profileColor).toBe(ROSE); + expect(parseIdentityStep({ ...base, profile_color: null }, "domme", true).profileColor).toBeNull(); + expect(parseIdentityStep(base, "domme", true).profileColor).toBeNull(); + expect( + parseIdentityStep({ ...base, submissive_labels: ["Brat"], profile_color: TEAL }, "submissive", true).profileColor, + ).toBe(TEAL); + expect(() => parseIdentityStep({ ...base, profile_color: -5 }, "domme", true)).toThrow(ValidationError); + }); + + it("only reads a linked overlay's colour when profile_color is an explicit override", () => { + const base = { + pronouns: [], + honourifics: [], + submissive_labels: [], + dm_status: null, + bio: null, + public_send_stats: false, + aliases: [], + }; + const inherited = parseLinkedIdentityStep({ ...base, overrides: [], profile_color: ROSE }, "domme"); + expect(inherited.overriddenFields.has("profile_color")).toBe(false); + expect(inherited.profileColor).toBeNull(); + + const overriddenValue = parseLinkedIdentityStep( + { ...base, overrides: ["profile_color"], profile_color: ROSE }, + "domme", + ); + expect(overriddenValue.overriddenFields.has("profile_color")).toBe(true); + expect(overriddenValue.profileColor).toBe(ROSE); + + const overriddenNull = parseLinkedIdentityStep( + { ...base, overrides: ["profile_color"], profile_color: null }, + "domme", + ); + expect(overriddenNull.overriddenFields.has("profile_color")).toBe(true); + expect(overriddenNull.profileColor).toBeNull(); + + expect(() => + parseLinkedIdentityStep({ ...base, overrides: ["profile_color"], profile_color: 0x1000000 }, "domme"), + ).toThrow(ValidationError); + }); +}); + +describe("profile_color document persistence", () => { + it("round-trips the colour through the identity step, and clears it back to no colour", async () => { + const owner = "710000000000000010"; + const draft = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + await putStep(draft.id, "orientation", { owner_user_id: owner, expected_revision: 0, orientation: "domme" }); + + const coloured = await putStep(draft.id, "identity", identityBody(owner, 1, { profile_color: ROSE, complete: false })); + expect(coloured.status).toBe(200); + expect(coloured.draft?.document.profile_color).toBe(ROSE); + expect((await getDraft(draft.id, owner)).document.profile_color).toBe(ROSE); + + const stored = await env.DB.prepare( + "SELECT profile_color FROM profile_documents WHERE id = (SELECT document_id FROM profile_drafts WHERE id = ?)", + ) + .bind(draft.id) + .first<{ profile_color: number | null }>(); + expect(stored?.profile_color).toBe(ROSE); + + const cleared = await putStep(draft.id, "identity", identityBody(owner, 2, { profile_color: null, complete: false })); + expect(cleared.draft?.document.profile_color).toBeNull(); + expect((await getDraft(draft.id, owner)).document.profile_color).toBeNull(); + }); + + it("rejects an out-of-range colour without touching the stored document", async () => { + const owner = "710000000000000011"; + const draft = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + await putStep(draft.id, "orientation", { owner_user_id: owner, expected_revision: 0, orientation: "domme" }); + await putStep(draft.id, "identity", identityBody(owner, 1, { profile_color: TEAL, complete: false })); + + const rejected = await putStep(draft.id, "identity", identityBody(owner, 2, { profile_color: 0xffffff + 1 })); + expect(rejected.status).toBe(400); + expect(rejected.error?.code).toBe("invalid_profile_color"); + + const after = await getDraft(draft.id, owner); + expect(after.document.profile_color).toBe(TEAL); + expect(after.revision).toBe(2); + }); + + it("keeps the colour when other services rewrite the document snapshot", async () => { + const owner = "710000000000000012"; + const draft = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + await putStep(draft.id, "orientation", { owner_user_id: owner, expected_revision: 0, orientation: "domme" }); + await putStep(draft.id, "identity", identityBody(owner, 1, { profile_color: ROSE })); + + const added = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${draft.id}/links`, + { + owner_user_id: owner, + expected_revision: 2, + platform: "bluesky", + public_label: "Bluesky", + normalized_url: "https://bsky.app/profile/example.test", + link_type: "social", + }, + authHeaders(), + ), + ); + expect(added.status).toBe(201); + expect((await getDraft(draft.id, owner)).document.profile_color).toBe(ROSE); + }); + + it("keeps the colour when the orientation changes and its capabilities are renormalized", async () => { + const owner = "710000000000000014"; + const draft = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + await putStep(draft.id, "orientation", { owner_user_id: owner, expected_revision: 0, orientation: "domme" }); + await putStep(draft.id, "identity", identityBody(owner, 1, { profile_color: ROSE, complete: false })); + + const reoriented = await putStep(draft.id, "orientation", { + owner_user_id: owner, + expected_revision: 2, + orientation: "submissive", + }); + expect(reoriented.status).toBe(200); + expect(reoriented.draft?.document.profile_color).toBe(ROSE); + }); + + it("clones the published colour into the next draft and clears it on restart", async () => { + const owner = "710000000000000013"; + const first = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + await putStep(first.id, "orientation", { owner_user_id: owner, expected_revision: 0, orientation: "domme" }); + await putStep(first.id, "identity", identityBody(owner, 1, { profile_color: ROSE })); + await putStep(first.id, "links", { owner_user_id: owner, expected_revision: 2, links: [] }); + await putStep(first.id, "throne", { owner_user_id: owner, expected_revision: 3, throne_creator_id: null }); + await putStep(first.id, "review", { owner_user_id: owner, expected_revision: 4 }); + const published = await publish(first.id, owner, 5); + expect(published.status).toBe(200); + expect(published.profile?.profile_color).toBe(ROSE); + + // A brand new draft clones the currently published document, colour included. + const second = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + expect(second.document.profile_color).toBe(ROSE); + expect(second.id).not.toBe(first.id); + + // Restart re-clones from the same published root, so the colour survives that too. + const restarted = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${second.id}/restart`, + { owner_user_id: owner, expected_revision: second.revision }, + authHeaders(), + ), + ); + const restartedDraft = (await readJson(restarted)).data.draft; + expect(restartedDraft.document.profile_color).toBe(ROSE); + + const snapshot = await readDocumentSnapshot( + env, + ( + await env.DB.prepare("SELECT document_id FROM profile_drafts WHERE id = ?") + .bind(second.id) + .first<{ document_id: string }>() + )?.document_id as string, + ); + expect(snapshot?.profileColor).toBe(ROSE); + }); +}); + +describe("profile_color resolution", () => { + it("publishes the colour on a global profile and exposes it to viewers", async () => { + await seedDocument({ id: "colour-doc-global", ownerUserId: "720", orientation: "domme", dmStatus: "open", profileColor: ROSE }); + await seedGlobalProfile("720", "colour-doc-global"); + + const resolved = await resolveProfile(env, TEST_HOME_GUILD_ID, "720"); + expect(resolved.profile?.profileColor).toBe(ROSE); + expect(await lookup(TEST_HOME_GUILD_ID, "720")).toMatchObject({ profile_color: ROSE }); + }); + + it("resolves an independent server profile's own colour, ignoring the global one", async () => { + await seedDocument({ id: "colour-doc-g2", ownerUserId: "721", orientation: "domme", dmStatus: "open", profileColor: ROSE }); + await seedGlobalProfile("721", "colour-doc-g2"); + await seedDocument({ id: "colour-doc-i2", ownerUserId: "721", orientation: "domme", dmStatus: "open", profileColor: TEAL }); + await seedServerProfile({ + id: "colour-srv-2", + guildId: OTHER_GUILD, + ownerUserId: "721", + mode: "independent", + documentId: "colour-doc-i2", + }); + + const resolved = await resolveProfile(env, OTHER_GUILD, "721"); + expect(resolved.profile?.profileColor).toBe(TEAL); + }); + + it("inherits the global colour on a linked overlay with no profile_color override", async () => { + await seedDocument({ id: "colour-doc-g3", ownerUserId: "722", orientation: "domme", dmStatus: "open", profileColor: ROSE }); + await seedGlobalProfile("722", "colour-doc-g3"); + // The overlay's own column is NULL and must not be mistaken for "no colour". + await seedDocument({ id: "colour-doc-l3", ownerUserId: "722", orientation: "domme", profileColor: null }); + await seedServerProfile({ + id: "colour-srv-3", + guildId: OTHER_GUILD, + ownerUserId: "722", + mode: "linked", + documentId: "colour-doc-l3", + }); + + const resolved = await resolveProfile(env, OTHER_GUILD, "722"); + expect(resolved.profile?.profileColor).toBe(ROSE); + }); + + it("uses a linked overlay's own colour when profile_color is overridden", async () => { + await seedDocument({ id: "colour-doc-g4", ownerUserId: "723", orientation: "domme", dmStatus: "open", profileColor: ROSE }); + await seedGlobalProfile("723", "colour-doc-g4"); + await seedDocument({ id: "colour-doc-l4", ownerUserId: "723", orientation: "domme", profileColor: TEAL }); + await seedOverride("colour-doc-l4", "profile_color"); + await seedServerProfile({ + id: "colour-srv-4", + guildId: OTHER_GUILD, + ownerUserId: "723", + mode: "linked", + documentId: "colour-doc-l4", + }); + + const resolved = await resolveProfile(env, OTHER_GUILD, "723"); + expect(resolved.profile?.profileColor).toBe(TEAL); + }); + + it("treats an override row with a NULL colour as a deliberate 'no colour', not inheritance", async () => { + await seedDocument({ id: "colour-doc-g5", ownerUserId: "724", orientation: "domme", dmStatus: "open", profileColor: ROSE }); + await seedGlobalProfile("724", "colour-doc-g5"); + await seedDocument({ id: "colour-doc-l5", ownerUserId: "724", orientation: "domme", profileColor: null }); + await seedOverride("colour-doc-l5", "profile_color"); + await seedServerProfile({ + id: "colour-srv-5", + guildId: OTHER_GUILD, + ownerUserId: "724", + mode: "linked", + documentId: "colour-doc-l5", + }); + + const resolved = await resolveProfile(env, OTHER_GUILD, "724"); + expect(resolved.profile?.profileColor).toBeNull(); + expect(await lookup(OTHER_GUILD, "724")).toMatchObject({ profile_color: null }); + }); +}); + +describe("profile_color on a linked server draft", () => { + it("persists an override, then a deliberate clear, then a return to inheritance", async () => { + const owner = "710000000000000020"; + await seedDocument({ + id: "colour-linked-global", + ownerUserId: owner, + orientation: "domme", + dmStatus: "open", + profileColor: ROSE, + }); + await seedGlobalProfile(owner, "colour-linked-global"); + + const draft = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "server", + guild_id: OTHER_GUILD, + server_mode: "linked", + }); + expect(draft.resolved_profile_color).toBe(ROSE); + + const overridden = await putStep(draft.id, "identity", { + owner_user_id: owner, + expected_revision: draft.revision, + overrides: ["profile_color"], + profile_color: TEAL, + complete: false, + }); + expect(overridden.status).toBe(200); + expect(overridden.draft?.document.profile_color).toBe(TEAL); + expect(overridden.draft?.document.overridden_fields).toContain("profile_color"); + expect(overridden.draft?.resolved_profile_color).toBe(TEAL); + + const explicitNone = await putStep(draft.id, "identity", { + owner_user_id: owner, + expected_revision: (overridden.draft as DraftBody).revision, + overrides: ["profile_color"], + profile_color: null, + complete: false, + }); + expect(explicitNone.draft?.document.profile_color).toBeNull(); + expect(explicitNone.draft?.document.overridden_fields).toContain("profile_color"); + + const inherited = await putStep(draft.id, "identity", { + owner_user_id: owner, + expected_revision: (explicitNone.draft as DraftBody).revision, + overrides: [], + profile_color: TEAL, + complete: false, + }); + expect(inherited.draft?.document.overridden_fields).not.toContain("profile_color"); + expect(inherited.draft?.document.profile_color).toBeNull(); + expect(inherited.draft?.resolved_profile_color).toBe(ROSE); + }); + + it("publishes a linked overlay whose overridden colour beats the inherited one", async () => { + const owner = "710000000000000021"; + await seedDocument({ + id: "colour-linked-global-2", + ownerUserId: owner, + orientation: "domme", + dmStatus: "open", + profileColor: ROSE, + }); + await seedSelection("colour-linked-global-2", "pronoun", "She/Her"); + await seedGlobalProfile(owner, "colour-linked-global-2"); + + const draft = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "server", + guild_id: OTHER_GUILD, + server_mode: "linked", + }); + const identity = await putStep(draft.id, "identity", { + owner_user_id: owner, + expected_revision: draft.revision, + overrides: ["profile_color"], + profile_color: TEAL, + dm_status_selected: true, + }); + const links = await putStep(draft.id, "links", { + owner_user_id: owner, + expected_revision: (identity.draft as DraftBody).revision, + links: [], + hidden_inherited_link_ids: [], + }); + const review = await putStep(draft.id, "review", { + owner_user_id: owner, + expected_revision: (links.draft as DraftBody).revision, + }); + const published = await publish(draft.id, owner, (review.draft as DraftBody).revision); + expect(published.status).toBe(200); + expect(published.profile?.profile_color).toBe(TEAL); + expect(await lookup(OTHER_GUILD, owner)).toMatchObject({ profile_color: TEAL, mode: "linked" }); + }); +}); diff --git a/worker/test/profileDrafts.test.ts b/worker/test/profileDrafts.test.ts index 609bbeb..c6878e6 100644 --- a/worker/test/profileDrafts.test.ts +++ b/worker/test/profileDrafts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { env } from "cloudflare:test"; import { authHeaders, callWorker, jsonRequest, readJson, TEST_HOME_GUILD_ID } from "./helpers"; +import { seedDocument, seedGlobalProfile, seedSelection } from "./profileHelpers"; const OWNER = "500000000000000001"; const OTHER_GUILD = "500000000000000002"; @@ -66,6 +67,98 @@ async function lookup(guildId: string, userId: string) { } describe("profile draft lifecycle (global scope, domme orientation)", () => { + it.each([ + { + owner: "500000000000000191", + target_scope: "global", + origin_guild_id: TEST_HOME_GUILD_ID, + }, + { + owner: "500000000000000192", + target_scope: "server", + origin_guild_id: OTHER_GUILD, + guild_id: OTHER_GUILD, + server_mode: "independent", + }, + ])("rejects completing a $target_scope identity without pronouns", async (scope) => { + const started = await startDraft({ + owner_user_id: scope.owner, + origin_guild_id: scope.origin_guild_id, + target_scope: scope.target_scope, + ...(scope.guild_id === undefined + ? {} + : { guild_id: scope.guild_id, server_mode: scope.server_mode }), + }); + await putStep(started.draft.id, "orientation", { + owner_user_id: scope.owner, + expected_revision: 0, + orientation: "domme", + }); + + const result = await putStep(started.draft.id, "identity", { + owner_user_id: scope.owner, + expected_revision: 1, + pronouns: [], + dm_status: "open", + dm_status_selected: true, + }); + + expect(result.status).toBe(400); + expect(result.error?.code).toBe("pronouns_required"); + }); + + it("defends publication when a completed global draft has no pronouns", async () => { + const owner = "500000000000000193"; + const started = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + const draftId = started.draft.id; + await putStep(draftId, "orientation", { + owner_user_id: owner, + expected_revision: 0, + orientation: "domme", + }); + await putStep(draftId, "identity", { + owner_user_id: owner, + expected_revision: 1, + pronouns: ["She/Her"], + dm_status: "open", + dm_status_selected: true, + }); + await putStep(draftId, "links", { + owner_user_id: owner, + expected_revision: 2, + links: [], + }); + await putStep(draftId, "throne", { + owner_user_id: owner, + expected_revision: 3, + throne_creator_id: null, + preferred_payment_link_id: null, + }); + await putStep(draftId, "review", { + owner_user_id: owner, + expected_revision: 4, + }); + await env.DB.prepare( + `DELETE FROM profile_document_selections + WHERE document_id = (SELECT document_id FROM profile_drafts WHERE id = ?) + AND category = 'pronoun'`, + ) + .bind(draftId) + .run(); + + const result = await publish(draftId, { + owner_user_id: owner, + expected_revision: 5, + }); + + expect(result.status).toBe(400); + expect(result.error?.code).toBe("pronouns_required"); + }); + it("persists partial identity selections without choosing a DM status or completing the step", async () => { const owner = "500000000000000090"; const started = await startDraft({ @@ -334,6 +427,7 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { const result = await putStep(started.draft.id, "identity", { owner_user_id: owner, expected_revision: 1, + pronouns: ["She/Her"], dm_status: "open", dm_status_selected: true, bio: "must not be written", @@ -535,6 +629,7 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { await putStep(draftId, "identity", { owner_user_id: owner, expected_revision: 1, + pronouns: ["She/Her"], dm_status: "open", dm_status_selected: true, }); @@ -588,6 +683,7 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { await putStep(draftId, "identity", { owner_user_id: owner, expected_revision: 1, + pronouns: ["She/Her"], dm_status: "open", dm_status_selected: true, }); @@ -652,6 +748,65 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { }); describe("profile draft lifecycle (server scope)", () => { + it("requires nonempty effective inherited pronouns at completion and publish", async () => { + const owner = "500000000000000194"; + const globalDocumentId = "linked-pronoun-global"; + await seedDocument({ + id: globalDocumentId, + ownerUserId: owner, + orientation: "domme", + dmStatus: "open", + }); + await seedGlobalProfile(owner, globalDocumentId); + const started = await startDraft({ + owner_user_id: owner, + origin_guild_id: OTHER_GUILD, + target_scope: "server", + guild_id: OTHER_GUILD, + server_mode: "linked", + }); + + const missingInherited = await putStep(started.draft.id, "identity", { + owner_user_id: owner, + expected_revision: 0, + overrides: [], + dm_status_selected: true, + }); + expect(missingInherited.status).toBe(400); + expect(missingInherited.error?.code).toBe("pronouns_required"); + + await seedSelection(globalDocumentId, "pronoun", "She/Her"); + const identity = await putStep(started.draft.id, "identity", { + owner_user_id: owner, + expected_revision: 0, + overrides: [], + dm_status_selected: true, + }); + const links = await putStep(started.draft.id, "links", { + owner_user_id: owner, + expected_revision: (identity.draft as DraftBody).revision, + local_links: [], + hidden_inherited_link_ids: [], + preferred_payment_link_id: null, + }); + const review = await putStep(started.draft.id, "review", { + owner_user_id: owner, + expected_revision: (links.draft as DraftBody).revision, + }); + await env.DB.prepare( + "DELETE FROM profile_document_selections WHERE document_id = ? AND category = 'pronoun'", + ) + .bind(globalDocumentId) + .run(); + + const result = await publish(started.draft.id, { + owner_user_id: owner, + expected_revision: (review.draft as DraftBody).revision, + }); + expect(result.status).toBe(400); + expect(result.error?.code).toBe("pronouns_required"); + }); + it("publishes an independent server profile distinct from any global profile", async () => { const owner = "500000000000000020"; const started = await startDraft({ @@ -734,6 +889,16 @@ describe("profile draft lifecycle (server scope)", () => { expect(linkedStart.draft.steps.map((s: { key: string }) => s.key)).toEqual(["identity", "links", "review"]); const linkedDraftId = linkedStart.draft.id; + const emptyPronounOverride = await putStep(linkedDraftId, "identity", { + owner_user_id: owner, + expected_revision: 0, + overrides: ["pronouns"], + pronouns: [], + dm_status_selected: true, + }); + expect(emptyPronounOverride.status).toBe(400); + expect(emptyPronounOverride.error?.code).toBe("pronouns_required"); + const afterIdentity = await putStep(linkedDraftId, "identity", { owner_user_id: owner, expected_revision: 0, diff --git a/worker/test/profileHelpers.ts b/worker/test/profileHelpers.ts index 5fbadc8..d920ed8 100644 --- a/worker/test/profileHelpers.ts +++ b/worker/test/profileHelpers.ts @@ -13,6 +13,7 @@ export interface SeedDocumentOptions { publicSendStats?: boolean; throneCreatorId?: string | null; preferredPaymentLinkId?: string | null; + profileColor?: number | null; } export async function seedDocument(options: SeedDocumentOptions): Promise { @@ -20,8 +21,8 @@ export async function seedDocument(options: SeedDocumentOptions): Promise await env.DB.prepare( `INSERT INTO profile_documents (id, owner_user_id, state, orientation, dm_status, bio, public_send_stats, - throne_creator_id, preferred_payment_link_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + throne_creator_id, preferred_payment_link_id, profile_color, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( options.id, @@ -33,6 +34,7 @@ export async function seedDocument(options: SeedDocumentOptions): Promise options.publicSendStats ? 1 : 0, options.throneCreatorId ?? null, options.preferredPaymentLinkId ?? null, + options.profileColor ?? null, now, now, ) diff --git a/worker/test/profilePublicationRegistration.test.ts b/worker/test/profilePublicationRegistration.test.ts index 5cdab6a..bb22d74 100644 --- a/worker/test/profilePublicationRegistration.test.ts +++ b/worker/test/profilePublicationRegistration.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { DraftError } from "../src/profile/draftService"; import { publishDraft } from "../src/profile/publishService"; import { seedCreator, seedGuild, TEST_HOME_GUILD_ID } from "./helpers"; -import { seedDocument } from "./profileHelpers"; +import { seedDocument, seedSelection } from "./profileHelpers"; async function seedCompletedDraft(options: { owner: string; @@ -19,7 +19,11 @@ async function seedCompletedDraft(options: { dmStatus: "open", throneCreatorId: options.creatorId, }); + await seedSelection(options.documentId, "pronoun", "She/Her"); const now = new Date().toISOString(); + await env.DB.prepare("UPDATE throne_creators SET webhook_verified_at = ? WHERE id = ?") + .bind(now, options.creatorId) + .run(); await env.DB.prepare( `INSERT INTO profile_drafts (id, owner_user_id, origin_guild_id, target_scope, document_id, base_version, diff --git a/worker/test/profileThrone.test.ts b/worker/test/profileThrone.test.ts index 88b0656..98875f9 100644 --- a/worker/test/profileThrone.test.ts +++ b/worker/test/profileThrone.test.ts @@ -1,5 +1,5 @@ -import { env } from "cloudflare:test"; -import { describe, expect, it } from "vitest"; +import { env, fetchMock } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; import { attachThroneToDraft } from "../src/profile/throneDraftService"; import { sha256Hex } from "../src/util/hash"; import { authHeaders, callWorker, jsonRequest, readJson, seedCreator, TEST_HOME_GUILD_ID } from "./helpers"; @@ -8,6 +8,27 @@ interface DraftEnvelope { data: { draft: { id: string; revision: number } }; } +const FIRESTORE_PATH = "/v1/projects/onlywish-9d17b/databases/(default)/documents:runQuery"; + +beforeAll(() => { + fetchMock.activate(); + fetchMock.disableNetConnect(); +}); + +function mockResolve(publicCreatorId: string, handle: string): void { + fetchMock + .get("https://firestore.googleapis.com") + .intercept({ method: "POST", path: FIRESTORE_PATH }) + .reply(200, [ + { + document: { + name: `projects/onlywish-9d17b/databases/(default)/documents/creators/${publicCreatorId}`, + fields: { username: { stringValue: handle } }, + }, + }, + ]); +} + async function startDommeDraft(owner: string): Promise<{ id: string; revision: number }> { const startedResponse = await callWorker( jsonRequest( @@ -39,6 +60,189 @@ async function startDommeDraft(owner: string): Promise<{ id: string; revision: n } describe("profile Throne mutations", () => { + it("resolves without issuing a secret, then confirms restart-safely from the draft", async () => { + const owner = "930000000000000010"; + const draft = await startDommeDraft(owner); + mockResolve("public-confirmed-creator", "confirmedqueen"); + await env.DB.prepare( + "UPDATE profile_drafts SET wizard_stage = 'throne', wizard_substep = 'review' WHERE id = ?", + ) + .bind(draft.id) + .run(); + + const resolvedResponse = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${draft.id}/throne/resolve`, + { + owner_user_id: owner, + expected_revision: draft.revision, + throne_input: "confirmedqueen", + }, + authHeaders(), + ), + ); + expect(resolvedResponse.status).toBe(200); + const resolved = await readJson<{ + data: { + draft: { + revision: number; + throne_pending: { handle: string }; + wizard_substep: string; + }; + handle: string; + already_verified: boolean; + confirmation_token: string; + }; + }>(resolvedResponse); + expect(resolved.data.handle).toBe("confirmedqueen"); + expect(resolved.data.draft.throne_pending.handle).toBe("confirmedqueen"); + expect(resolved.data.draft.wizard_substep).toBe("review:confirm"); + expect(resolved.data.already_verified).toBe(false); + expect(resolved.data.confirmation_token).toBeTruthy(); + expect( + await env.DB.prepare("SELECT id FROM throne_creators WHERE public_creator_id = ?") + .bind("public-confirmed-creator") + .first(), + ).toBeNull(); + + const resumedResponse = await callWorker( + new Request( + `https://worker.test/v1/profile-drafts/${draft.id}?owner_user_id=${owner}`, + { headers: authHeaders() }, + ), + ); + expect(resumedResponse.status).toBe(200); + const resumed = await readJson<{ + data: { draft: { throne_pending: { handle: string }; revision: number } }; + }>(resumedResponse); + expect(resumed.data.draft.throne_pending.handle).toBe("confirmedqueen"); + + const confirmResponse = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${draft.id}/throne`, + { + owner_user_id: owner, + expected_revision: resumed.data.draft.revision, + confirm_pending: true, + }, + authHeaders(), + ), + ); + expect(confirmResponse.status).toBe(200); + const confirmed = await readJson<{ + data: { + webhook_url: string; + draft: { + owner_user_id: string; + status: string; + document: { selections: { pronouns: string[] }; throne_creator_id: string }; + throne_pending: null; + }; + }; + }>(confirmResponse); + expect(confirmed.data.webhook_url).toMatch(/^https:\/\/usebill\.dev\/t\/[^/]+\/[\w-]+$/); + expect(confirmed.data.draft.owner_user_id).toBe(owner); + expect(confirmed.data.draft.status).toBe("active"); + expect(confirmed.data.draft.document.selections.pronouns).toEqual([]); + expect(confirmed.data.draft.document.throne_creator_id).toBeTruthy(); + expect(confirmed.data.draft.throne_pending).toBeNull(); + expect( + await env.DB.prepare("SELECT owner_discord_user_id FROM throne_creators WHERE public_creator_id = ?") + .bind("public-confirmed-creator") + .first(), + ).toEqual({ owner_discord_user_id: owner }); + }); + + it("requires live webhook verification when completing Throne and publishing", async () => { + const owner = "930000000000000011"; + const draft = await startDommeDraft(owner); + const creator = await seedCreator({ + id: "profile-throne-live-verification", + ownerDiscordUserId: owner, + }); + const attached = await attachThroneToDraft(env, { + draftId: draft.id, + ownerUserId: owner, + expectedRevision: draft.revision, + throneInput: null, + existingCreatorId: creator.id, + confirmationToken: null, + rotateWebhook: false, + }); + + async function putStep( + step: string, + expectedRevision: number, + values: Record, + ): Promise<{ status: number; revision?: number; code?: string }> { + const response = await callWorker( + jsonRequest( + "PUT", + `/v1/profile-drafts/${draft.id}/steps/${step}`, + { owner_user_id: owner, expected_revision: expectedRevision, ...values }, + authHeaders(), + ), + ); + const body = await readJson<{ + data?: { draft: { revision: number } }; + error?: { code: string }; + }>(response); + return { + status: response.status, + ...(body.data === undefined ? {} : { revision: body.data.draft.revision }), + ...(body.error === undefined ? {} : { code: body.error.code }), + }; + } + + const unverified = await putStep("throne", attached.draft.revision, { + throne_creator_id: creator.id, + preferred_payment_link_id: null, + }); + expect(unverified).toMatchObject({ + status: 400, + code: "throne_webhook_unverified", + }); + + await env.DB.prepare("UPDATE throne_creators SET webhook_verified_at = ? WHERE id = ?") + .bind("2026-08-23T12:00:00Z", creator.id) + .run(); + const identity = await putStep("identity", attached.draft.revision, { + pronouns: ["She/Her"], + honourifics: [], + submissive_labels: [], + dm_status: "open", + dm_status_selected: true, + bio: null, + public_send_stats: false, + aliases: [], + profile_color: null, + }); + const links = await putStep("links", identity.revision as number, { links: [] }); + const throne = await putStep("throne", links.revision as number, { + throne_creator_id: creator.id, + preferred_payment_link_id: null, + }); + const review = await putStep("review", throne.revision as number, {}); + + await env.DB.prepare("UPDATE throne_creators SET webhook_verified_at = NULL WHERE id = ?") + .bind(creator.id) + .run(); + const publishResponse = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${draft.id}/publish`, + { owner_user_id: owner, expected_revision: review.revision }, + authHeaders(), + ), + ); + expect(publishResponse.status).toBe(400); + expect( + (await readJson<{ error: { code: string } }>(publishResponse)).error.code, + ).toBe("throne_webhook_unverified"); + }); + it("lets only one racing rotation change the live webhook secret", async () => { const owner = "930000000000000001"; const draft = await startDommeDraft(owner); @@ -47,6 +251,9 @@ describe("profile Throne mutations", () => { ownerDiscordUserId: owner, secret: "original-secret", }); + await env.DB.prepare("UPDATE throne_creators SET webhook_verified_at = ? WHERE id = ?") + .bind("2026-08-23T12:00:00Z", creator.id) + .run(); const attempts = await Promise.allSettled([ attachThroneToDraft(env, { @@ -55,6 +262,7 @@ describe("profile Throne mutations", () => { expectedRevision: draft.revision, throneInput: null, existingCreatorId: creator.id, + confirmationToken: null, rotateWebhook: true, }), attachThroneToDraft(env, { @@ -63,6 +271,7 @@ describe("profile Throne mutations", () => { expectedRevision: draft.revision, throneInput: null, existingCreatorId: creator.id, + confirmationToken: null, rotateWebhook: true, }), ]); @@ -81,11 +290,12 @@ describe("profile Throne mutations", () => { expect(webhookUrl).toBeTruthy(); const secret = webhookUrl?.split("/").at(-1); const row = await env.DB.prepare( - "SELECT route_secret_hash FROM throne_creators WHERE id = ?", + "SELECT route_secret_hash, webhook_verified_at FROM throne_creators WHERE id = ?", ) .bind(creator.id) - .first<{ route_secret_hash: string }>(); + .first<{ route_secret_hash: string; webhook_verified_at: string | null }>(); expect(row?.route_secret_hash).toBe(await sha256Hex(secret as string)); expect(row?.route_secret_hash).not.toBe(await sha256Hex("original-secret")); + expect(row?.webhook_verified_at).toBeNull(); }); }); diff --git a/worker/test/registration.test.ts b/worker/test/registration.test.ts index 6a3f591..03a4382 100644 --- a/worker/test/registration.test.ts +++ b/worker/test/registration.test.ts @@ -126,6 +126,11 @@ describe("POST /v1/guilds/:guildId/registrations/domme", () => { ), ); const firstBody = await readJson<{ data: { webhook_url: string | null } }>(first); + await env.DB.prepare( + "UPDATE throne_creators SET webhook_verified_at = ? WHERE public_creator_id = ?", + ) + .bind("2026-08-23T12:00:00Z", "creator-carol") + .run(); mockResolve("creator-carol", "carol"); const second = await callWorker( @@ -141,6 +146,13 @@ describe("POST /v1/guilds/:guildId/registrations/domme", () => { expect(secondBody.data.webhook_state).toBe("rotated"); expect(secondBody.data.webhook_url).not.toBeNull(); expect(secondBody.data.webhook_url).not.toBe(firstBody.data.webhook_url); + expect( + await env.DB.prepare( + "SELECT webhook_verified_at FROM throne_creators WHERE public_creator_id = ?", + ) + .bind("creator-carol") + .first(), + ).toEqual({ webhook_verified_at: null }); }); it("rejects linking an already-owned creator to a different Discord user", async () => { diff --git a/worker/test/resolver.test.ts b/worker/test/resolver.test.ts index bdea32b..34b446f 100644 --- a/worker/test/resolver.test.ts +++ b/worker/test/resolver.test.ts @@ -41,6 +41,21 @@ describe("resolveProfile", () => { expect(result.profile?.selections.honourifics).toEqual(["Goddess"]); }); + it("keeps legacy published profiles with no pronouns readable", async () => { + await seedDocument({ + id: "doc-global-legacy-empty-pronouns", + ownerUserId: "100", + orientation: "domme", + dmStatus: "open", + }); + await seedGlobalProfile("100", "doc-global-legacy-empty-pronouns"); + + const result = await resolveProfile(env, TEST_HOME_GUILD_ID, "100"); + + expect(result.profile).not.toBeNull(); + expect(result.profile?.selections.pronouns).toEqual([]); + }); + it("reports global_available outside the home guild even without a server profile", async () => { await seedDocument({ id: "doc-global-2", ownerUserId: "11", orientation: "submissive", dmStatus: "open" }); await seedGlobalProfile("11", "doc-global-2"); diff --git a/worker/test/throneStatus.test.ts b/worker/test/throneStatus.test.ts new file mode 100644 index 0000000..abfb6d5 --- /dev/null +++ b/worker/test/throneStatus.test.ts @@ -0,0 +1,299 @@ +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + authHeaders, + callWorker, + generateThroneKeyPair, + jsonRequest, + readJson, + seedCreator, + TEST_HOME_GUILD_ID, +} from "./helpers"; +import { seedDocument, seedGlobalProfile } from "./profileHelpers"; + +const LINKED_GUILD = "750000000000000001"; + +interface KeyPair { + publicKeyPem: string; + sign: (timestamp: string, rawBody: string) => Promise; +} + +let keyPair: KeyPair; + +beforeEach(async () => { + keyPair = await generateThroneKeyPair(); + env.THRONE_PUBLIC_KEY_PEM = keyPair.publicKeyPem; +}); + +interface DraftBody { + id: string; + revision: number; +} +interface DraftEnvelope { + data?: { draft: DraftBody }; + error?: { code: string }; +} +interface StatusEnvelope { + data?: Record; + error?: { code: string; message: string }; +} + +async function startDraft(body: Record): Promise { + const response = await callWorker(jsonRequest("POST", "/v1/profile-drafts/start", body, authHeaders())); + return (await readJson(response)).data!.draft; +} + +async function putStep(draftId: string, stepKey: string, body: Record): Promise { + const response = await callWorker( + jsonRequest("PUT", `/v1/profile-drafts/${draftId}/steps/${stepKey}`, body, authHeaders()), + ); + return (await readJson(response)).data!.draft; +} + +async function startDommeDraft(owner: string): Promise { + const draft = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + return putStep(draft.id, "orientation", { + owner_user_id: owner, + expected_revision: draft.revision, + orientation: "domme", + }); +} + +async function attachCreator( + draft: DraftBody, + owner: string, + creatorId: string, +): Promise<{ id: string; revision: number; webhookUrl: string }> { + const response = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${draft.id}/throne`, + { + owner_user_id: owner, + expected_revision: draft.revision, + existing_creator_id: creatorId, + rotate_webhook: true, + }, + authHeaders(), + ), + ); + const parsed = await readJson<{ data: { draft: DraftBody; webhook_url: string } }>(response); + expect(response.status).toBe(200); + return { id: draft.id, revision: parsed.data.draft.revision, webhookUrl: parsed.data.webhook_url }; +} + +async function getStatus(draftId: string, query: string, headers = authHeaders()) { + const response = await callWorker( + jsonRequest("GET", `/v1/profile-drafts/${draftId}/throne/status?${query}`, undefined, headers), + ); + const parsed = await readJson(response); + return { status: response.status, body: parsed.data, error: parsed.error }; +} + +async function postWebhook(creatorId: string, secret: string, body: unknown): Promise { + const rawBody = JSON.stringify(body); + const timestamp = String(Math.floor(Date.now() / 1000)); + return callWorker( + new Request(`https://worker.test/t/${creatorId}/${secret}`, { + method: "POST", + headers: { + "content-type": "application/json", + "X-Signature-Timestamp": timestamp, + "X-Signature-Ed25519": await keyPair.sign(timestamp, rawBody), + }, + body: rawBody, + }), + ); +} + +describe("GET /v1/profile-drafts/:draftId/throne/status", () => { + it("reports an unverified connection, then flips to verified after Throne's signed test webhook", async () => { + const owner = "750000000000000010"; + const draft = await startDommeDraft(owner); + const creator = await seedCreator({ + id: "throne-status-creator", + handle: "statusqueen", + ownerDiscordUserId: owner, + secret: "status-secret", + }); + const attached = await attachCreator(draft, owner, creator.id); + + const before = await getStatus(attached.id, `owner_user_id=${owner}&expected_revision=${attached.revision}`); + expect(before.status).toBe(200); + expect(before.body).toEqual({ handle: "statusqueen", verified: false, verified_at: null }); + + // Only Throne itself, through the signed public webhook route, can flip verification. + const secret = attached.webhookUrl.split("/").at(-1) as string; + const webhookResponse = await postWebhook(creator.id, secret, { + type: "gift_purchased", + test: true, + amount: 5, + }); + expect(webhookResponse.status).toBe(200); + + const after = await getStatus(attached.id, `owner_user_id=${owner}&expected_revision=${attached.revision}`); + expect(after.status).toBe(200); + expect(after.body?.handle).toBe("statusqueen"); + expect(after.body?.verified).toBe(true); + expect(typeof after.body?.verified_at).toBe("string"); + }); + + it("never leaks the creator id, route secret, or webhook URL", async () => { + const owner = "750000000000000011"; + const draft = await startDommeDraft(owner); + const creator = await seedCreator({ + id: "throne-status-private", + handle: "privateer", + ownerDiscordUserId: owner, + secret: "super-secret-value", + }); + const attached = await attachCreator(draft, owner, creator.id); + + const status = await getStatus(attached.id, `owner_user_id=${owner}&expected_revision=${attached.revision}`); + expect(Object.keys(status.body ?? {}).sort()).toEqual(["handle", "verified", "verified_at"]); + const serialized = JSON.stringify(status.body); + expect(serialized).not.toContain(creator.id); + expect(serialized).not.toContain("super-secret-value"); + expect(serialized).not.toContain("public-"); + expect(serialized).not.toContain("/t/"); + + const row = await env.DB.prepare("SELECT route_secret_hash, public_creator_id FROM throne_creators WHERE id = ?") + .bind(creator.id) + .first<{ route_secret_hash: string; public_creator_id: string }>(); + expect(serialized).not.toContain(row?.route_secret_hash as string); + expect(serialized).not.toContain(row?.public_creator_id as string); + }); + + it("reports a not-yet-connected draft as unverified with no handle", async () => { + const owner = "750000000000000012"; + const draft = await startDommeDraft(owner); + + const status = await getStatus(draft.id, `owner_user_id=${owner}&expected_revision=${draft.revision}`); + expect(status.status).toBe(200); + expect(status.body).toEqual({ handle: null, verified: false, verified_at: null }); + }); + + it("hides a creator the draft's owner does not own", async () => { + const owner = "750000000000000013"; + const draft = await startDommeDraft(owner); + await seedCreator({ id: "throne-status-foreign", handle: "someoneelse", ownerDiscordUserId: "750000000000000014" }); + await env.DB.prepare( + "UPDATE profile_documents SET throne_creator_id = ? WHERE id = (SELECT document_id FROM profile_drafts WHERE id = ?)", + ) + .bind("throne-status-foreign", draft.id) + .run(); + + const status = await getStatus(draft.id, `owner_user_id=${owner}&expected_revision=${draft.revision}`); + expect(status.status).toBe(200); + expect(status.body).toEqual({ handle: null, verified: false, verified_at: null }); + }); + + it("requires bearer auth", async () => { + const owner = "750000000000000015"; + const draft = await startDommeDraft(owner); + const status = await getStatus(draft.id, `owner_user_id=${owner}&expected_revision=${draft.revision}`, {}); + expect(status.status).toBe(401); + expect(status.body).toBeUndefined(); + }); + + it("validates the owner, the draft, and the exact revision", async () => { + const owner = "750000000000000016"; + const draft = await startDommeDraft(owner); + + const wrongOwner = await getStatus( + draft.id, + `owner_user_id=750000000000000017&expected_revision=${draft.revision}`, + ); + expect(wrongOwner.status).toBe(404); + expect(wrongOwner.error?.code).toBe("draft_not_found"); + + const badOwner = await getStatus(draft.id, `owner_user_id=nope&expected_revision=${draft.revision}`); + expect(badOwner.status).toBe(400); + expect(badOwner.error?.code).toBe("invalid_owner_user_id"); + + const missingRevision = await getStatus(draft.id, `owner_user_id=${owner}`); + expect(missingRevision.status).toBe(400); + expect(missingRevision.error?.code).toBe("invalid_expected_revision"); + + const malformedRevision = await getStatus(draft.id, `owner_user_id=${owner}&expected_revision=-1`); + expect(malformedRevision.status).toBe(400); + expect(malformedRevision.error?.code).toBe("invalid_expected_revision"); + + const staleRevision = await getStatus( + draft.id, + `owner_user_id=${owner}&expected_revision=${draft.revision + 1}`, + ); + expect(staleRevision.status).toBe(409); + expect(staleRevision.error?.code).toBe("stale_revision"); + + const unknownDraft = await getStatus("no-such-draft", `owner_user_id=${owner}&expected_revision=0`); + expect(unknownDraft.status).toBe(404); + expect(unknownDraft.error?.code).toBe("draft_not_found"); + }); + + it("refuses orientations and draft shapes that have no Throne step", async () => { + const submissiveOwner = "750000000000000018"; + const submissiveDraft = await startDraft({ + owner_user_id: submissiveOwner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + const oriented = await putStep(submissiveDraft.id, "orientation", { + owner_user_id: submissiveOwner, + expected_revision: submissiveDraft.revision, + orientation: "submissive", + }); + const submissiveStatus = await getStatus( + oriented.id, + `owner_user_id=${submissiveOwner}&expected_revision=${oriented.revision}`, + ); + expect(submissiveStatus.status).toBe(400); + expect(submissiveStatus.error?.code).toBe("throne_unavailable"); + + const linkedOwner = "750000000000000019"; + await seedDocument({ id: "throne-status-global", ownerUserId: linkedOwner, orientation: "domme", dmStatus: "open" }); + await seedGlobalProfile(linkedOwner, "throne-status-global"); + const linkedDraft = await startDraft({ + owner_user_id: linkedOwner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "server", + guild_id: LINKED_GUILD, + server_mode: "linked", + }); + const linkedStatus = await getStatus( + linkedDraft.id, + `owner_user_id=${linkedOwner}&expected_revision=${linkedDraft.revision}`, + ); + expect(linkedStatus.status).toBe(400); + expect(linkedStatus.error?.code).toBe("step_not_applicable"); + }); + + it("is a read: it never advances the draft revision or mutates the creator row", async () => { + const owner = "750000000000000020"; + const draft = await startDommeDraft(owner); + const creator = await seedCreator({ + id: "throne-status-readonly", + handle: "readonly", + ownerDiscordUserId: owner, + }); + const attached = await attachCreator(draft, owner, creator.id); + + const creatorBefore = await env.DB.prepare("SELECT * FROM throne_creators WHERE id = ?") + .bind(creator.id) + .first(); + await getStatus(attached.id, `owner_user_id=${owner}&expected_revision=${attached.revision}`); + await getStatus(attached.id, `owner_user_id=${owner}&expected_revision=${attached.revision}`); + + const draftRow = await env.DB.prepare("SELECT revision FROM profile_drafts WHERE id = ?") + .bind(attached.id) + .first<{ revision: number }>(); + expect(draftRow?.revision).toBe(attached.revision); + expect(await env.DB.prepare("SELECT * FROM throne_creators WHERE id = ?").bind(creator.id).first()).toEqual( + creatorBefore, + ); + }); +}); diff --git a/worker/test/webhook.test.ts b/worker/test/webhook.test.ts index b98a741..866d719 100644 --- a/worker/test/webhook.test.ts +++ b/worker/test/webhook.test.ts @@ -1,5 +1,7 @@ import { env } from "cloudflare:test"; import { beforeEach, describe, expect, it } from "vitest"; +import { webhookVerificationStatement } from "../src/routes/webhookThrone"; +import { sha256Hex } from "../src/util/hash"; import { callWorker, generateThroneKeyPair, @@ -320,4 +322,33 @@ describe("POST /t/:creatorId/:routeSecret", () => { .first<{ webhook_verified_at: string | null }>(); expect(row?.webhook_verified_at).not.toBeNull(); }); + + it("does not let an old-secret in-flight request verify a rotated secret", async () => { + const { creatorId, secret } = await seedActiveCreatorAndGuild("verifyrace"); + const authenticatedHash = await sha256Hex(secret); + const rotatedHash = await sha256Hex("rotated-secret"); + await env.DB.prepare( + "UPDATE throne_creators SET route_secret_hash = ?, webhook_verified_at = NULL WHERE id = ?", + ) + .bind(rotatedHash, creatorId) + .run(); + + const staleWrite = await webhookVerificationStatement( + env, + creatorId, + authenticatedHash, + new Date().toISOString(), + ).run(); + + expect(staleWrite.meta.changes).toBe(0); + const row = await env.DB.prepare( + "SELECT route_secret_hash, webhook_verified_at FROM throne_creators WHERE id = ?", + ) + .bind(creatorId) + .first<{ route_secret_hash: string; webhook_verified_at: string | null }>(); + expect(row).toEqual({ + route_secret_hash: rotatedHash, + webhook_verified_at: null, + }); + }); }); diff --git a/worker/test/wizardStage.test.ts b/worker/test/wizardStage.test.ts new file mode 100644 index 0000000..73251f8 --- /dev/null +++ b/worker/test/wizardStage.test.ts @@ -0,0 +1,626 @@ +import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:test"; +import { + deriveWizardStage, + setDraftWizardStage, + type DraftContract, +} from "../src/profile/draftService"; +import { WIZARD_STAGES, wizardStagesForDraft } from "../src/profile/contracts"; +import { authHeaders, callWorker, jsonRequest, readJson, TEST_HOME_GUILD_ID } from "./helpers"; +import { seedDocument, seedGlobalProfile } from "./profileHelpers"; + +const LINKED_GUILD = "740000000000000001"; + +interface DraftBody { + id: string; + revision: number; + status: string; + current_step: string; + next_step: string | null; + governing_orientation: string | null; + wizard_stage: string; + wizard_substep: string | null; + document: Record; +} +interface DraftEnvelope { + data?: { draft: DraftBody }; + error?: { code: string; message: string }; +} + +async function startDraft(body: Record): Promise { + const response = await callWorker(jsonRequest("POST", "/v1/profile-drafts/start", body, authHeaders())); + return (await readJson(response)).data!.draft; +} + +async function putStep(draftId: string, stepKey: string, body: Record) { + const response = await callWorker( + jsonRequest("PUT", `/v1/profile-drafts/${draftId}/steps/${stepKey}`, body, authHeaders()), + ); + const parsed = await readJson(response); + return { status: response.status, draft: parsed.data?.draft, error: parsed.error }; +} + +async function setStage(draftId: string, body: Record, headers = authHeaders()) { + const response = await callWorker( + jsonRequest("PUT", `/v1/profile-drafts/${draftId}/wizard-stage`, body, headers), + ); + const parsed = await readJson(response); + return { status: response.status, draft: parsed.data?.draft, error: parsed.error }; +} + +async function getDraft(draftId: string, owner: string): Promise { + const response = await callWorker( + jsonRequest("GET", `/v1/profile-drafts/${draftId}?owner_user_id=${owner}`, undefined, authHeaders()), + ); + return (await readJson(response)).data!.draft; +} + +async function storedBookmark(draftId: string) { + return env.DB.prepare("SELECT wizard_stage, wizard_substep, revision FROM profile_drafts WHERE id = ?") + .bind(draftId) + .first<{ wizard_stage: string | null; wizard_substep: string | null; revision: number }>(); +} + +function identityBody(owner: string, revision: number, extra: Record = {}): Record { + return { + owner_user_id: owner, + expected_revision: revision, + pronouns: ["She/Her"], + honourifics: [], + submissive_labels: [], + dm_status: "open", + dm_status_selected: true, + bio: null, + public_send_stats: false, + aliases: [], + ...extra, + }; +} + +/** Starts a global draft and completes its orientation step. */ +async function startOrientedDraft(owner: string, orientation: string): Promise { + const draft = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + const oriented = await putStep(draft.id, "orientation", { + owner_user_id: owner, + expected_revision: draft.revision, + orientation, + }); + return oriented.draft as DraftBody; +} + +async function startLinkedDraft(owner: string): Promise { + await seedDocument({ id: `stage-global-${owner}`, ownerUserId: owner, orientation: "domme", dmStatus: "open" }); + await seedGlobalProfile(owner, `stage-global-${owner}`); + return startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "server", + guild_id: LINKED_GUILD, + server_mode: "linked", + }); +} + +describe("wizard stage sequences", () => { + it("matches the bot's per-orientation stage sequence", () => { + expect(wizardStagesForDraft("global", null, "domme")).toEqual([ + "orientation", + "pronouns", + "honourifics", + "dm_status", + "bio", + "profile_color", + "links", + "throne", + "review", + ]); + expect(wizardStagesForDraft("global", null, "submissive")).toEqual([ + "orientation", + "pronouns", + "submissive_labels", + "dm_status", + "bio", + "profile_color", + "links", + "details", + "review", + ]); + expect(wizardStagesForDraft("global", null, "switch_domme")).toEqual([ + "orientation", + "pronouns", + "honourifics", + "submissive_labels", + "dm_status", + "bio", + "profile_color", + "links", + "throne", + "details", + "review", + ]); + }); + + it("drops orientation and throne for a linked overlay, and offers everything before orientation is chosen", () => { + expect(wizardStagesForDraft("server", "linked", "domme")).toEqual([ + "pronouns", + "honourifics", + "dm_status", + "bio", + "profile_color", + "links", + "review", + ]); + expect(wizardStagesForDraft("server", "independent", "domme")).toContain("throne"); + expect(wizardStagesForDraft("global", null, null)).toEqual([ + "orientation", + "pronouns", + "honourifics", + "submissive_labels", + "dm_status", + "bio", + "profile_color", + "links", + "details", + "review", + ]); + for (const stage of WIZARD_STAGES) { + expect(WIZARD_STAGES.indexOf(stage)).toBeGreaterThanOrEqual(0); + } + }); + + it("derives a stage from a draft's coarse progress, clamping to the applicable sequence", () => { + const domme = wizardStagesForDraft("global", null, "domme"); + expect(deriveWizardStage(domme, "orientation", "orientation")).toBe("orientation"); + expect(deriveWizardStage(domme, "identity", "orientation")).toBe("pronouns"); + expect(deriveWizardStage(domme, "links", "identity")).toBe("links"); + expect(deriveWizardStage(domme, "throne", "links")).toBe("throne"); + expect(deriveWizardStage(domme, "review", "throne")).toBe("review"); + expect(deriveWizardStage(domme, null, "review")).toBe("review"); + + const linked = wizardStagesForDraft("server", "linked", "domme"); + // `orientation` is not a linked draft's screen; the nearest applicable one is used instead. + expect(deriveWizardStage(linked, "orientation", "orientation")).toBe("pronouns"); + const submissive = wizardStagesForDraft("global", null, "submissive"); + expect(deriveWizardStage(submissive, "throne", "links")).toBe("links"); + }); +}); + +describe("PUT /v1/profile-drafts/:draftId/wizard-stage", () => { + it("persists the stage and substep, bumping the revision like any other mutation", async () => { + const owner = "740000000000000010"; + const draft = await startOrientedDraft(owner, "domme"); + + const moved = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "throne", + substep: "awaiting_verification", + }); + expect(moved.status).toBe(200); + expect(moved.draft?.wizard_stage).toBe("throne"); + expect(moved.draft?.wizard_substep).toBe("awaiting_verification"); + expect(moved.draft?.revision).toBe(draft.revision + 1); + + expect(await storedBookmark(draft.id)).toMatchObject({ + wizard_stage: "throne", + wizard_substep: "awaiting_verification", + revision: draft.revision + 1, + }); + + const reloaded = await getDraft(draft.id, owner); + expect(reloaded.wizard_stage).toBe("throne"); + expect(reloaded.wizard_substep).toBe("awaiting_verification"); + }); + + it("clears a previous substep when the next navigation omits one", async () => { + const owner = "740000000000000011"; + const draft = await startOrientedDraft(owner, "domme"); + const verified = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "throne", + substep: "verified", + }); + expect(verified.draft?.wizard_substep).toBe("verified"); + + const moved = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: (verified.draft as DraftBody).revision, + stage: "review", + }); + expect(moved.status).toBe(200); + expect(moved.draft?.wizard_stage).toBe("review"); + expect(moved.draft?.wizard_substep).toBeNull(); + expect(await storedBookmark(draft.id)).toMatchObject({ wizard_substep: null }); + + const explicitNull = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: (moved.draft as DraftBody).revision, + stage: "bio", + substep: null, + }); + expect(explicitNull.draft?.wizard_substep).toBeNull(); + }); + + it("rejects a stale expected_revision without moving the bookmark", async () => { + const owner = "740000000000000012"; + const draft = await startOrientedDraft(owner, "domme"); + const first = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "bio", + substep: "review", + }); + expect(first.status).toBe(200); + + const stale = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "links", + }); + expect(stale.status).toBe(409); + expect(stale.error?.code).toBe("stale_revision"); + expect(await storedBookmark(draft.id)).toMatchObject({ + wizard_stage: "bio", + wizard_substep: "review", + revision: draft.revision + 1, + }); + }); + + it("lets exactly one of two racing navigations win", async () => { + const owner = "740000000000000013"; + const draft = await startOrientedDraft(owner, "domme"); + + const attempts = await Promise.allSettled([ + setDraftWizardStage(env, { + draftId: draft.id, + ownerUserId: owner, + expectedRevision: draft.revision, + stage: "bio", + substep: null, + }), + setDraftWizardStage(env, { + draftId: draft.id, + ownerUserId: owner, + expectedRevision: draft.revision, + stage: "links", + substep: null, + }), + ]); + const fulfilled = attempts.filter( + (attempt): attempt is PromiseFulfilledResult => attempt.status === "fulfilled", + ); + const rejected = attempts.filter((attempt): attempt is PromiseRejectedResult => attempt.status === "rejected"); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]?.reason).toEqual(expect.objectContaining({ code: "stale_revision" })); + + const stored = await storedBookmark(draft.id); + expect(stored?.revision).toBe(draft.revision + 1); + expect(stored?.wizard_stage).toBe(fulfilled[0]?.value.wizardStage); + }); + + it("rejects unknown stages, out-of-sequence stages, and malformed substeps", async () => { + const owner = "740000000000000014"; + const draft = await startOrientedDraft(owner, "submissive"); + + const unknown = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "colour_wheel", + }); + expect(unknown.status).toBe(400); + expect(unknown.error?.code).toBe("invalid_wizard_stage"); + + const missing = await setStage(draft.id, { owner_user_id: owner, expected_revision: draft.revision }); + expect(missing.status).toBe(400); + expect(missing.error?.code).toBe("invalid_wizard_stage"); + + // A submissive profile has no Throne screen at all. + const notApplicable = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "throne", + }); + expect(notApplicable.status).toBe(400); + expect(notApplicable.error?.code).toBe("stage_not_applicable"); + + const badSubstep = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "bio", + substep: "x".repeat(41), + }); + expect(badSubstep.status).toBe(400); + expect(badSubstep.error?.code).toBe("invalid_wizard_substep"); + + const emptySubstep = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "bio", + substep: " ", + }); + expect(emptySubstep.status).toBe(400); + expect(emptySubstep.error?.code).toBe("invalid_wizard_substep"); + + expect(await storedBookmark(draft.id)).toMatchObject({ wizard_stage: null, revision: draft.revision }); + }); + + it("refuses a stage that belongs to another draft shape", async () => { + const owner = "740000000000000015"; + const draft = await startLinkedDraft(owner); + expect(draft.wizard_stage).toBe("pronouns"); + + const orientation = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "orientation", + }); + expect(orientation.status).toBe(400); + expect(orientation.error?.code).toBe("stage_not_applicable"); + + const throne = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "throne", + }); + expect(throne.status).toBe(400); + expect(throne.error?.code).toBe("stage_not_applicable"); + + const allowed = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "profile_color", + }); + expect(allowed.status).toBe(200); + expect(allowed.draft?.wizard_stage).toBe("profile_color"); + }); + + it("requires bearer auth, a valid owner, and an active draft", async () => { + const owner = "740000000000000016"; + const draft = await startOrientedDraft(owner, "domme"); + + const unauthorized = await setStage( + draft.id, + { owner_user_id: owner, expected_revision: draft.revision, stage: "bio" }, + {}, + ); + expect(unauthorized.status).toBe(401); + + const otherOwner = await setStage(draft.id, { + owner_user_id: "740000000000000017", + expected_revision: draft.revision, + stage: "bio", + }); + expect(otherOwner.status).toBe(404); + expect(otherOwner.error?.code).toBe("draft_not_found"); + + const malformedOwner = await setStage(draft.id, { + owner_user_id: "not-a-snowflake", + expected_revision: draft.revision, + stage: "bio", + }); + expect(malformedOwner.status).toBe(400); + expect(malformedOwner.error?.code).toBe("invalid_owner_user_id"); + + // Publish the draft, then confirm a late navigation cannot revive it. + let revision = draft.revision; + revision = ((await putStep(draft.id, "identity", identityBody(owner, revision))).draft as DraftBody).revision; + revision = ((await putStep(draft.id, "links", { owner_user_id: owner, expected_revision: revision, links: [] })) + .draft as DraftBody).revision; + revision = (( + await putStep(draft.id, "throne", { + owner_user_id: owner, + expected_revision: revision, + throne_creator_id: null, + }) + ).draft as DraftBody).revision; + revision = ((await putStep(draft.id, "review", { owner_user_id: owner, expected_revision: revision })) + .draft as DraftBody).revision; + const publishResponse = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${draft.id}/publish`, + { owner_user_id: owner, expected_revision: revision }, + authHeaders(), + ), + ); + expect(publishResponse.status).toBe(200); + + const afterPublish = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: revision + 1, + stage: "bio", + }); + expect(afterPublish.status).toBe(409); + expect(afterPublish.error?.code).toBe("draft_not_active"); + }); +}); + +describe("wizard stage compatibility with drafts created before migration 0004", () => { + it("serializes a derived stage for a NULL bookmark, following the draft's own progress", async () => { + const owner = "740000000000000020"; + const draft = await startOrientedDraft(owner, "domme"); + // A fresh draft has never been navigated: its stored bookmark is NULL. + expect(await storedBookmark(draft.id)).toMatchObject({ wizard_stage: null, wizard_substep: null }); + expect(draft.wizard_stage).toBe("pronouns"); + expect(draft.wizard_substep).toBeNull(); + + const identity = await putStep(draft.id, "identity", identityBody(owner, draft.revision)); + expect(identity.draft?.next_step).toBe("links"); + expect(identity.draft?.wizard_stage).toBe("links"); + + const links = await putStep(draft.id, "links", { + owner_user_id: owner, + expected_revision: (identity.draft as DraftBody).revision, + links: [], + }); + expect(links.draft?.wizard_stage).toBe("throne"); + + const throne = await putStep(draft.id, "throne", { + owner_user_id: owner, + expected_revision: (links.draft as DraftBody).revision, + throne_creator_id: null, + }); + expect(throne.draft?.wizard_stage).toBe("review"); + }); + + it("derives a linked overlay's resume stage even though it has no orientation screen", async () => { + const owner = "740000000000000021"; + const draft = await startLinkedDraft(owner); + expect(await storedBookmark(draft.id)).toMatchObject({ wizard_stage: null }); + expect(draft.current_step).toBe("orientation"); + expect(draft.wizard_stage).toBe("pronouns"); + }); + + it("ignores a stored bookmark the draft's own orientation no longer allows", async () => { + const owner = "740000000000000022"; + const draft = await startOrientedDraft(owner, "domme"); + const moved = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "throne", + substep: "verified", + }); + expect(moved.draft?.wizard_stage).toBe("throne"); + + // Switching to an orientation without a Throne step invalidates that bookmark, so the + // contract falls back to deriving from progress (identity is still pending) instead of + // echoing a screen the wizard can no longer render. The stale row itself is left alone. + const reoriented = await putStep(draft.id, "orientation", { + owner_user_id: owner, + expected_revision: (moved.draft as DraftBody).revision, + orientation: "submissive", + }); + expect(reoriented.draft?.next_step).toBe("identity"); + expect(reoriented.draft?.wizard_stage).toBe("pronouns"); + expect(await storedBookmark(draft.id)).toMatchObject({ wizard_stage: "throne" }); + + // Once identity and links are done, the same invalid bookmark derives forward, never to a + // Throne screen this orientation does not have. + const identity = await putStep( + draft.id, + "identity", + identityBody(owner, (reoriented.draft as DraftBody).revision, { submissive_labels: ["Brat"] }), + ); + const links = await putStep(draft.id, "links", { + owner_user_id: owner, + expected_revision: (identity.draft as DraftBody).revision, + links: [], + }); + expect(links.draft?.next_step).toBe("review"); + expect(links.draft?.wizard_stage).toBe("review"); + }); + + it("clears the bookmark when a draft is restarted", async () => { + const owner = "740000000000000023"; + const draft = await startOrientedDraft(owner, "domme"); + const moved = await setStage(draft.id, { + owner_user_id: owner, + expected_revision: draft.revision, + stage: "bio", + substep: "review", + }); + + const restarted = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${draft.id}/restart`, + { owner_user_id: owner, expected_revision: (moved.draft as DraftBody).revision }, + authHeaders(), + ), + ); + const restartedDraft = (await readJson(restarted)).data!.draft; + expect(restartedDraft.wizard_stage).toBe("orientation"); + expect(restartedDraft.wizard_substep).toBeNull(); + expect(await storedBookmark(draft.id)).toMatchObject({ wizard_stage: null, wizard_substep: null }); + }); +}); + +describe("optional bookmark carried on a step mutation", () => { + it("persists stage and substep in the same guarded batch as the step itself", async () => { + const owner = "740000000000000030"; + const draft = await startOrientedDraft(owner, "domme"); + + const identity = await putStep( + draft.id, + "identity", + identityBody(owner, draft.revision, { wizard_stage: "links", wizard_substep: "review" }), + ); + expect(identity.status).toBe(200); + expect(identity.draft?.wizard_stage).toBe("links"); + expect(identity.draft?.wizard_substep).toBe("review"); + expect(await storedBookmark(draft.id)).toMatchObject({ wizard_stage: "links", wizard_substep: "review" }); + + // Omitting the keys entirely leaves the stored bookmark alone. + const links = await putStep(draft.id, "links", { + owner_user_id: owner, + expected_revision: (identity.draft as DraftBody).revision, + links: [], + }); + expect(links.status).toBe(200); + expect(await storedBookmark(draft.id)).toMatchObject({ wizard_stage: "links", wizard_substep: "review" }); + + // An explicit null clears just that column. + const throne = await putStep(draft.id, "throne", { + owner_user_id: owner, + expected_revision: (links.draft as DraftBody).revision, + throne_creator_id: null, + wizard_substep: null, + }); + expect(await storedBookmark(draft.id)).toMatchObject({ wizard_stage: "links", wizard_substep: null }); + expect(throne.draft?.wizard_stage).toBe("links"); + }); + + it("rejects an invalid bookmark without applying the step", async () => { + const owner = "740000000000000031"; + const draft = await startOrientedDraft(owner, "domme"); + + const bad = await putStep( + draft.id, + "identity", + identityBody(owner, draft.revision, { bio: "kept out", wizard_stage: "nowhere" }), + ); + expect(bad.status).toBe(400); + expect(bad.error?.code).toBe("invalid_wizard_stage"); + + const notApplicable = await putStep( + draft.id, + "orientation", + { + owner_user_id: owner, + expected_revision: draft.revision, + orientation: "submissive", + wizard_stage: "throne", + }, + ); + expect(notApplicable.status).toBe(400); + expect(notApplicable.error?.code).toBe("stage_not_applicable"); + + const unchanged = await getDraft(draft.id, owner); + expect(unchanged.revision).toBe(draft.revision); + expect(unchanged.document.bio).toBeNull(); + expect(unchanged.governing_orientation).toBe("domme"); + }); + + it("accepts a bookmark that only becomes valid because of the step being applied", async () => { + const owner = "740000000000000032"; + const draft = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + + const oriented = await putStep(draft.id, "orientation", { + owner_user_id: owner, + expected_revision: draft.revision, + orientation: "domme", + wizard_stage: "pronouns", + }); + expect(oriented.status).toBe(200); + expect(oriented.draft?.wizard_stage).toBe("pronouns"); + }); +});