diff --git a/.env.example b/.env.example index 37f386e..6c5e630 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ BILL_DISCORD_TOKEN= BILL_WORKER_BASE_URL=https://usebill.dev BILL_WORKER_API_TOKEN= +# Required: the server where global profiles are managed. +BILL_HOME_GUILD_ID= # Optional runtime tuning BILL_POLL_INTERVAL_SECONDS=5 diff --git a/README.md b/README.md index ce8a50f..05d4aa4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # Bill -Bill is a multi-server Discord bot that posts verified Throne sends. This first -milestone deliberately contains one complete feature: secure send tracking from -Throne to Discord. +Bill is a multi-server Discord bot with durable member profiles and verified +Throne send tracking. ## Architecture -- **Discord bot:** Python 3.12 on DigitalOcean. Administrators choose a send - channel with `/bill setup`; Dom/mes connect Throne with `/register domme`. +- **Discord bot:** Python 3.12 on DigitalOcean. Members use `/profile`; server + administrators configure the send channel with `/bill setup`. - **Webhook and data API:** native TypeScript Cloudflare Worker at `usebill.dev`. - **Database:** Cloudflare D1. The bot never connects to D1 directly. @@ -31,6 +30,8 @@ npm run check ## Guides +- [Codebase guide](docs/codebase-guide.md) +- [Profiles](docs/profiles.md) - [Version control with Git and GitHub](docs/version-control.md) - [Collaborating with Issues, PRs, reviews, and stacks](docs/github-collaboration.md) - [Releasing Bill](docs/releases.md) @@ -38,12 +39,14 @@ npm run check - [Deployment](docs/deployment.md) - [Architecture](docs/architecture.md) - [Send tracking](docs/send-tracking.md) +- [Roadmap](docs/roadmap.md) ## Current scope -Included: multi-server channel setup, Dom/me Throne registration, authenticated -Worker APIs, signed/idempotent Throne ingestion, D1 persistence, and leased -Discord notification delivery. +Included: global and per-server profiles, durable DM onboarding, safe static +link-page import, optional Throne connection, alias attribution for future +sends, public per-currency stats, multi-server setup, authenticated Worker APIs, +signed/idempotent Throne ingestion, D1 persistence, and leased notifications. -Profiles, leaderboards, sub aliases, reports, moderation, manual sends, and a -website are not part of this milestone. +Leaderboards, reports, moderation, manual sends, support tooling, diagnostics +commands, and a website remain out of scope. diff --git a/bill/bot.py b/bill/bot.py index 5765c0b..8e9a645 100644 --- a/bill/bot.py +++ b/bill/bot.py @@ -8,8 +8,11 @@ import discord from discord.ext import commands -from bill.cogs.registration import RegistrationCog +from bill.cogs.profile import ProfileCog from bill.cogs.setup import BillSetupCog +from bill.components.profile import ProfileSelectDynamic, ProfileWizardDynamic +from bill.components.public_profile import ProfileEditDynamic, ProfileLinksDynamic +from bill.components.setup import SetupChannelDynamic, SetupCompleteDynamic from bill.notifications import NotificationPoller from bill.settings import Settings from bill.worker_client import WorkerClient @@ -35,7 +38,17 @@ async def setup_hook(self) -> None: session=self.http_session, ) await self.add_cog(BillSetupCog(self)) - await self.add_cog(RegistrationCog(self)) + await self.add_cog(ProfileCog(self)) + # Dynamic dispatchers are registered before sync so controls remain usable + # after a process restart; Worker state remains the source of authority. + self.add_dynamic_items( + ProfileWizardDynamic, + ProfileSelectDynamic, + ProfileLinksDynamic, + ProfileEditDynamic, + SetupChannelDynamic, + SetupCompleteDynamic, + ) if self.settings.test_guild_id is not None: guild = discord.Object(id=self.settings.test_guild_id) diff --git a/bill/cogs/profile.py b/bill/cogs/profile.py new file mode 100644 index 0000000..a1456a1 --- /dev/null +++ b/bill/cogs/profile.py @@ -0,0 +1,248 @@ +"""Global guild-context ``/profile`` command and private onboarding entry points.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import discord +from discord import app_commands +from discord.ext import commands + +from bill.components.profile import profile_wizard_view +from bill.components.public_profile import public_profile_view +from bill.worker_client import DraftScope, ProfileDraft, ServerProfileMode, WorkerAPIError + +if TYPE_CHECKING: + from bill.bot import BillBot + +PROFILE_MISSING_PROMPT = "You don't have a Bill profile here yet. Would you like to set one up?" +DM_CLOSED_PROMPT = ( + "I couldn't DM you. Please enable direct messages from server members, then try again." +) +RESUME_PROMPT = "You have a saved Bill profile draft. Would you like to resume it or restart?" + + +class ProfilePromptView(discord.ui.View): + """Normal ephemeral entry prompt: V2 begins only after the private DM intro.""" + + def __init__(self, cog: ProfileCog, *, server_choice: bool = False) -> None: + super().__init__(timeout=180) + self.cog, self.server_choice = cog, server_choice + + @discord.ui.button(label="Confirm", style=discord.ButtonStyle.success) + async def start( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + await self.cog.start_profile(interaction, server_mode=None) + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger) + async def cancel( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + await interaction.response.edit_message( + content="No problem — your profile has not been changed.", view=None + ) + + +class GlobalChoiceView(discord.ui.View): + def __init__(self, cog: ProfileCog) -> None: + super().__init__(timeout=180) + self.cog = cog + + @discord.ui.button(label="Use global profile", style=discord.ButtonStyle.success) + async def linked( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + await self.cog.start_profile(interaction, server_mode=ServerProfileMode.LINKED) + + @discord.ui.button( + label="Create a separate server profile", style=discord.ButtonStyle.secondary + ) + async def independent( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + await self.cog.start_profile(interaction, server_mode=ServerProfileMode.INDEPENDENT) + + +class ResumeDraftView(discord.ui.View): + """A resumed draft is never DM'd until the owner explicitly chooses it.""" + + def __init__(self, cog: ProfileCog, draft: ProfileDraft) -> None: + super().__init__(timeout=180) + self.cog, self.draft = cog, draft + + @discord.ui.button(label="Resume draft", style=discord.ButtonStyle.success) + async def resume( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + await self.cog.deliver_draft(interaction, self.draft, resumed=True) + + @discord.ui.button(label="Restart draft", style=discord.ButtonStyle.danger) + async def restart( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + await interaction.response.edit_message( + content="Restart this saved draft? Unsaved progress will be replaced.", + view=ResumeRestartConfirmView(self.cog, self.draft), + ) + + +class ResumeRestartConfirmView(discord.ui.View): + def __init__(self, cog: ProfileCog, draft: ProfileDraft) -> None: + super().__init__(timeout=90) + self.cog, self.draft = cog, draft + + @discord.ui.button(label="Restart draft", style=discord.ButtonStyle.danger) + async def confirm( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + try: + restarted = await self.cog.bot.require_worker().restart_draft( + self.draft.id, + owner_user_id=interaction.user.id, + expected_revision=self.draft.revision, + ) + except WorkerAPIError as exc: + await interaction.response.edit_message( + content=f"Bill could not restart your draft: {exc}", + view=None, + ) + return + await self.cog.deliver_draft(interaction, restarted, resumed=False) + + @discord.ui.button(label="Keep draft", style=discord.ButtonStyle.success) + async def cancel( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + await interaction.response.edit_message(content="Your saved draft is unchanged.", view=None) + + +class ProfileCog(commands.Cog): + """Routes lookups without ever exposing drafts, webhook URLs, or secrets publicly.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = cast("BillBot", bot) + + @app_commands.command(name="profile", description="View a Bill profile in this server") + @app_commands.guild_only() + @app_commands.describe(member="The member whose profile you want to view") + async def profile( + self, interaction: discord.Interaction[discord.Client], member: discord.Member | None = None + ) -> None: + target = member or interaction.user + try: + lookup = await self.bot.require_worker().get_profile( + guild_id=interaction.guild_id, user_id=target.id + ) + except WorkerAPIError: + await interaction.response.send_message( + "Bill could not load that profile right now.", ephemeral=True + ) + return + if lookup.profile is not None: + await interaction.response.send_message( + view=public_profile_view( + lookup.profile, + guild_id=interaction.guild_id or 0, + owner_view=target.id == interaction.user.id, + display_name=target.display_name, + ) + ) + return + if member is not None and member.id != interaction.user.id: + await interaction.response.send_message( + f"{member.mention} does not have an applicable Bill profile here.", ephemeral=True + ) + return + if interaction.guild_id != self.bot.settings.home_guild_id and lookup.global_available: + await interaction.response.send_message( + "You have a global Bill profile. Choose how to use it in this server.", + view=GlobalChoiceView(self), + ephemeral=True, + ) + return + await interaction.response.send_message( + PROFILE_MISSING_PROMPT, view=ProfilePromptView(self), ephemeral=True + ) + + async def start_profile( + self, + interaction: discord.Interaction[discord.Client], + *, + server_mode: ServerProfileMode | None, + ) -> None: + """Create/resume Worker state, then deliver normal DM intro before V2 wizard.""" + if interaction.guild_id is None: + await interaction.response.send_message( + "Profiles can only be started from a server.", ephemeral=True + ) + return + scope = ( + DraftScope.GLOBAL + if interaction.guild_id == self.bot.settings.home_guild_id + else DraftScope.SERVER + ) + if scope is DraftScope.SERVER and server_mode is None: + await interaction.response.send_message( + "Choose whether to use your global profile or create a separate server profile.", + view=GlobalChoiceView(self), + ephemeral=True, + ) + return + try: + started = await self.bot.require_worker().start_draft( + owner_user_id=interaction.user.id, + origin_guild_id=interaction.guild_id, + target_scope=scope, + guild_id=interaction.guild_id if scope is DraftScope.SERVER else None, + server_mode=server_mode, + ) + except discord.Forbidden: + await interaction.response.edit_message(content=DM_CLOSED_PROMPT, view=None) + return + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not start your profile: {exc}", ephemeral=True + ) + return + if started.resume_required: + await interaction.response.edit_message( + content=RESUME_PROMPT, + view=ResumeDraftView(self, started.draft), + ) + return + await self.deliver_draft(interaction, started.draft, resumed=False) + + async def deliver_draft( + self, + interaction: discord.Interaction[discord.Client], + draft: ProfileDraft, + *, + resumed: bool, + ) -> None: + """DM after opt-in; Worker state remains recoverable when DMs are closed.""" + try: + dm = await interaction.user.create_dm() + await dm.send( + "Welcome to Bill profile setup. Your progress is private and saved automatically." + ) + await dm.send(view=profile_wizard_view(draft)) + except discord.Forbidden: + if interaction.response.is_done(): + await interaction.followup.send( + f"{DM_CLOSED_PROMPT} Your saved draft can be resumed later.", + ephemeral=True, + ) + else: + await interaction.response.edit_message( + content=f"{DM_CLOSED_PROMPT} Your saved draft can be resumed later.", + view=None, + ) + return + text = "I sent your private profile wizard in a DM." + if resumed: + text = "I sent your saved private profile wizard in a DM." + if interaction.response.is_done(): + await interaction.followup.send(text, ephemeral=True) + else: + await interaction.response.edit_message(content=text, view=None) diff --git a/bill/cogs/registration.py b/bill/cogs/registration.py deleted file mode 100644 index ff82dcc..0000000 --- a/bill/cogs/registration.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, cast - -import discord -from discord import app_commands -from discord.ext import commands - -from bill.worker_client import DommeRegistration, WorkerAPIError - -if TYPE_CHECKING: - from bill.bot import BillBot - - -def registration_embed(result: DommeRegistration) -> discord.Embed: - if result.webhook_url: - description = ( - f"Bill linked **@{result.throne_handle}**.\n\n" - "1. Open your Throne creator webhook settings.\n" - "2. Add the URL below exactly as shown.\n" - "3. Use Throne's test action to confirm the connection.\n\n" - f"```text\n{result.webhook_url}\n```\n" - "Keep this URL private—it contains the secret that authorizes your webhook." - ) - title = "Add Bill to your Throne webhooks" - else: - description = ( - f"Bill linked **@{result.throne_handle}** to this server. " - "Your existing Bill webhook is still active, so there is nothing to change on Throne." - ) - title = "Throne tracking is linked" - return discord.Embed( - title=title, - description=description, - color=discord.Color.from_rgb(99, 72, 214), - ) - - -class RegistrationCog(commands.Cog): - register = app_commands.Group(name="register", description="Register for Bill send tracking") - - def __init__(self, bot: commands.Bot) -> None: - self.bot = cast("BillBot", bot) - - @register.command(name="domme", description="Connect your Throne creator account") - @app_commands.describe( - throne="Your Throne username or full profile URL", - reset_webhook="Replace your existing Bill webhook URL", - ) - @app_commands.guild_only() - async def domme( - self, - interaction: discord.Interaction, - throne: str, - reset_webhook: bool = False, - ) -> None: - await interaction.response.defer(ephemeral=True, thinking=True) - try: - worker = self.bot.require_worker() - config = await worker.get_guild_config(interaction.guild_id) - if config is None: - await interaction.followup.send( - "An administrator needs to run `/bill setup` before anyone can register.", - ephemeral=True, - ) - return - result = await worker.register_domme( - guild_id=interaction.guild_id, - discord_user_id=interaction.user.id, - throne=throne, - reset_webhook=reset_webhook, - ) - except WorkerAPIError as exc: - await interaction.followup.send( - f"Bill could not connect that Throne account: {exc}", - ephemeral=True, - ) - return - await interaction.followup.send(embed=registration_embed(result), ephemeral=True) diff --git a/bill/cogs/setup.py b/bill/cogs/setup.py index bc33ba4..f76b207 100644 --- a/bill/cogs/setup.py +++ b/bill/cogs/setup.py @@ -1,3 +1,5 @@ +"""Public progressive guild setup command for Bill send notifications.""" + from __future__ import annotations from typing import TYPE_CHECKING, cast @@ -6,22 +8,13 @@ from discord import app_commands from discord.ext import commands +from bill.components.setup import missing_channel_permissions, setup_view from bill.worker_client import WorkerAPIError if TYPE_CHECKING: from bill.bot import BillBot -def missing_channel_permissions(permissions: discord.Permissions) -> tuple[str, ...]: - required = { - "view_channel": "View Channel", - "send_messages": "Send Messages", - "embed_links": "Embed Links", - "read_message_history": "Read Message History", - } - return tuple(label for name, label in required.items() if not getattr(permissions, name)) - - class BillSetupCog(commands.Cog): bill = app_commands.Group(name="bill", description="Configure Bill for this server") @@ -29,54 +22,35 @@ def __init__(self, bot: commands.Bot) -> None: self.bot = cast("BillBot", bot) @bill.command(name="setup", description="Choose where Bill posts Throne sends") - @app_commands.describe(send_channel="The channel where Bill should post sends") @app_commands.default_permissions(manage_guild=True) @app_commands.guild_only() - async def setup( - self, - interaction: discord.Interaction, - send_channel: discord.TextChannel, - ) -> None: + async def setup(self, interaction: discord.Interaction[discord.Client]) -> None: if ( not isinstance(interaction.user, discord.Member) or not interaction.user.guild_permissions.manage_guild ): await interaction.response.send_message( - "You need **Manage Server** to configure Bill.", - ephemeral=True, + "You need **Manage Server** to configure Bill.", ephemeral=True ) return - me = interaction.guild.me if interaction.guild else None - if me is None: + try: + started = await self.bot.require_worker().start_guild_setup( + guild_id=interaction.guild_id, initiator_user_id=interaction.user.id + ) + except WorkerAPIError as exc: await interaction.response.send_message( - "Bill could not check its server permissions. Please try again.", - ephemeral=True, + f"Bill could not start setup: {exc}", ephemeral=True ) return - missing = missing_channel_permissions(send_channel.permissions_for(me)) - if missing: - missing_text = ", ".join(f"**{item}**" for item in missing) + if started.resume_required: await interaction.response.send_message( - f"Bill needs {missing_text} in {send_channel.mention}.", + "A Bill setup session is already in progress. " + "Use its existing public setup message.", ephemeral=True, ) return + # It is public for moderator visibility, while callback authorization is initiator-bound. + await interaction.response.send_message(view=setup_view(started.session), ephemeral=False) - await interaction.response.defer(ephemeral=True, thinking=True) - try: - worker = self.bot.require_worker() - await worker.configure_guild( - guild_id=interaction.guild_id, - send_channel_id=send_channel.id, - ) - except WorkerAPIError as exc: - await interaction.followup.send( - f"Bill could not save that channel: {exc}", - ephemeral=True, - ) - return - await interaction.followup.send( - f"Bill is ready to post Throne sends in {send_channel.mention}. " - "Dom/mes can now run `/register domme`.", - ephemeral=True, - ) + +__all__ = ["BillSetupCog", "missing_channel_permissions"] diff --git a/bill/components/__init__.py b/bill/components/__init__.py new file mode 100644 index 0000000..5c41e38 --- /dev/null +++ b/bill/components/__init__.py @@ -0,0 +1 @@ +"""Discord Components V2 renderers and persistent interaction dispatchers.""" diff --git a/bill/components/custom_ids.py b/bill/components/custom_ids.py new file mode 100644 index 0000000..572a471 --- /dev/null +++ b/bill/components/custom_ids.py @@ -0,0 +1,45 @@ +"""Compact reversible tokens for Discord's 100-character component ID limit. + +Persistent controls must carry resource, user, guild, and revision context so a +restart can route and re-authorize them. UUID integers and decimal snowflakes +are encoded in base 36 rather than dropping any binding context to save space. +""" + +from __future__ import annotations + +import uuid + +_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" + + +def encode_uint(value: int | str) -> str: + number = int(value) + if number < 0: + raise ValueError("component integers cannot be negative") + if number == 0: + return "0" + encoded = "" + while number: + number, remainder = divmod(number, 36) + encoded = _ALPHABET[remainder] + encoded + return encoded + + +def decode_uint(value: str) -> int: + return int(value, 36) + + +def encode_resource_id(value: str) -> str: + """Compress UUIDs while retaining a raw-token fallback for test/legacy IDs.""" + try: + return f"u{encode_uint(uuid.UUID(value).int)}" + except ValueError: + return f"r{value}" + + +def decode_resource_id(value: str) -> str: + if value.startswith("u"): + return str(uuid.UUID(int=decode_uint(value[1:]))) + if value.startswith("r"): + return value[1:] + raise ValueError("unknown component resource encoding") diff --git a/bill/components/profile.py b/bill/components/profile.py new file mode 100644 index 0000000..8ca1ab1 --- /dev/null +++ b/bill/components/profile.py @@ -0,0 +1,1259 @@ +"""Durable private profile wizard components. + +All state lives in Worker drafts. Dynamic IDs carry enough routing context to +reject replayed controls after a restart, but every callback still reloads the +draft because an ID is not an authorization decision. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from typing import TYPE_CHECKING, cast + +import discord + +from bill.components.custom_ids import ( + decode_resource_id, + decode_uint, + encode_resource_id, + encode_uint, +) +from bill.worker_client import ( + DmStatus, + DraftScope, + DraftStepKey, + LinkType, + Orientation, + ProfileDraft, + ProfileLink, + ServerProfileMode, + WorkerAPIError, +) + +if TYPE_CHECKING: + from bill.bot import BillBot + +PRONOUNS = ( + "She/Her", + "He/Him", + "They/Them", + "It/Its", + "She/They", + "He/They", + "Any Pronouns", + "Ask Me", +) +HONOURIFICS = ( + "Goddess", + "Mistress", + "Princess", + "Temptress", + "Enchantress", + "Mommy", + "Master", + "Daddy", + "CashMaster", +) +SUBMISSIVE_LABELS = ("Submissive", "Sub", "Brat", "Pet", "Good boy", "Good girl", "Good pet", "Toy") +ORIENTATION_LABELS = { + Orientation.DOMME: "Dom/me", + Orientation.SUBMISSIVE: "Submissive", + Orientation.SWITCH_DOMME: "Switch (Dom/me lean)", + Orientation.SWITCH_SUBMISSIVE: "Switch (submissive lean)", +} + + +def safe_text(value: str, *, limit: int = 300) -> str: + """Escape user-provided strings before V2 rendering and prevent pings.""" + return discord.utils.escape_mentions(discord.utils.escape_markdown(value))[:limit] + + +def wizard_custom_id(draft: ProfileDraft, action: str) -> str: + """Build a <=100-character persistent ID bound to all durable auth context.""" + custom_id = ( + f"bill:p:{encode_resource_id(draft.id)}:{encode_uint(draft.owner_user_id)}:" + f"{encode_uint(draft.origin_guild_id)}:{encode_uint(draft.revision)}:{action}" + ) + if len(custom_id) > 100: + raise ValueError("Bill profile component ID exceeds Discord's 100-character limit") + return custom_id + + +def _caps(orientation: Orientation | None) -> tuple[bool, bool, bool, bool, bool]: + if orientation is Orientation.DOMME: + return True, False, False, True, False + if orientation is Orientation.SUBMISSIVE: + return False, True, True, False, True + 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") + if key is DraftStepKey.IDENTITY: + values = ( + *draft.document.selections.pronouns, + *draft.document.selections.honourifics, + *draft.document.selections.submissive_labels, + ) + return ", ".join(safe_text(value, limit=70) for value in values) or "Identity saved" + if key is DraftStepKey.LINKS: + return f"{len(draft.document.links)} link(s) saved" + if key is DraftStepKey.THRONE: + return "Throne connected" if draft.document.throne_creator_id else "Throne skipped" + return "Ready to publish" + + +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 profile_wizard_view(draft: ProfileDraft) -> discord.ui.LayoutView: + """Render the sole editable V2 wizard message from the latest Worker state.""" + view = discord.ui.LayoutView(timeout=None) + container = discord.ui.Container(accent_color=discord.Color.green()) + container.add_item(discord.ui.TextDisplay("## Build your Bill profile")) + for step in draft.steps: + if step.status == "completed": + container.add_item( + discord.ui.TextDisplay( + f"-# **{step.key.value.title()}**: {_summary(draft, step.key)} (Complete)" + ) + ) + current = draft.next_step or draft.current_step + if current is DraftStepKey.ORIENTATION: + container.add_item(discord.ui.TextDisplay("### 1. Choose your orientation")) + container.add_item(discord.ui.ActionRow(OrientationSelect(draft))) + elif current is DraftStepKey.IDENTITY: + container.add_item( + discord.ui.TextDisplay( + "### Identity\nSet fixed selections, DM status, bio, aliases, and public stats " + "preference." + ) + ) + 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", + ) + ) + ) + if labels: + container.add_item( + discord.ui.ActionRow( + IdentitySelect( + draft, + "labels", + SUBMISSIVE_LABELS, + "Choose submissive labels", + ) + ) + ) + container.add_item( + discord.ui.ActionRow( + _button( + draft, + "Save DM status, bio & aliases", + "identity", + discord.ButtonStyle.primary, + ) + ) + ) + elif current is DraftStepKey.LINKS: + _, _, _, payment, _ = _caps(draft.governing_orientation) + container.add_item( + discord.ui.TextDisplay( + "### Links\nManage individual social/payment links or import a link page." + ) + ) + links = [ + _button(draft, "Add link", "links", discord.ButtonStyle.primary), + _button(draft, "Import page", "import", discord.ButtonStyle.secondary), + _button(draft, "Done", "complete-links", discord.ButtonStyle.success), + ] + if not draft.document.links: + links.append(_button(draft, "Skip", "skip-links", discord.ButtonStyle.secondary)) + if ( + draft.target_scope is DraftScope.SERVER + and draft.server_mode is ServerProfileMode.LINKED + ): + links.append( + _button(draft, "Inherited visibility", "visibility", discord.ButtonStyle.secondary) + ) + container.add_item(discord.ui.ActionRow(*links)) + 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( + "### Throne\nConnect an account, select a saved creator, rotate its webhook, " + "or skip." + ) + ) + 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) + ) + container.add_item(discord.ui.ActionRow(*controls)) + if draft.throne_prefill and draft.throne_prefill.owned_creators: + options = [ + discord.SelectOption(label=safe_text(creator.handle, limit=80), value=creator.id) + for creator in draft.throne_prefill.owned_creators[:25] + ] + container.add_item(discord.ui.ActionRow(ThroneCreatorSelect(draft, options))) + else: + container.add_item( + discord.ui.TextDisplay( + "### Review\nYour saved draft is shown above. Edit a section or publish atomically." + ) + ) + edits = [ + _button(draft, "Edit identity", "identity", discord.ButtonStyle.secondary), + _button(draft, "Edit links", "links", 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 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( + _button(draft, "Publish", "publish", discord.ButtonStyle.success), + _button(draft, "Restart", "restart", discord.ButtonStyle.danger), + ) + ) + view.add_item(container) + return view + + +async def _load_draft( + bot: BillBot, + interaction: discord.Interaction[discord.Client], + draft_id: str, + owner: str, + origin: str, + revision: int, +) -> ProfileDraft | None: + if str(interaction.user.id) != owner: + await interaction.response.send_message( + "That profile control belongs to someone else.", ephemeral=True + ) + 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( + "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 + # preserves origin authorization even after a bot restart. + if ( + draft.owner_user_id != owner + 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 + ) + 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 + ) + return None + return draft + + +class OrientationSelect(discord.ui.Select): + def __init__(self, draft: ProfileDraft) -> None: + super().__init__( + custom_id=wizard_custom_id(draft, "orientation"), + placeholder="Choose an orientation", + options=[ + discord.SelectOption(label=label, value=value.value) + for value, label in ORIENTATION_LABELS.items() + ], + ) + + +class IdentitySelect(discord.ui.Select): + def __init__( + self, + draft: ProfileDraft, + field: str, + choices: tuple[str, ...], + placeholder: str, + ) -> None: + if field == "pronouns": + selected = set(draft.document.selections.pronouns) + elif field == "honourifics": + selected = set(draft.document.selections.honourifics) + else: + selected = set(draft.document.selections.submissive_labels) + super().__init__( + custom_id=wizard_custom_id(draft, f"identity-{field}"), + placeholder=placeholder, + min_values=0, + max_values=len(choices), + options=[ + discord.SelectOption(label=choice, value=choice, default=choice in selected) + for choice in choices + ], + ) + + +class LinkSelect(discord.ui.Select): + def __init__(self, draft: ProfileDraft, *, payment: bool) -> None: + options = [ + discord.SelectOption( + label=safe_text(link.public_label, limit=70), + value=link.id, + description=link.link_type.value, + ) + for link in draft.document.links[:25] + ] + super().__init__( + custom_id=wizard_custom_id(draft, "link-select"), + placeholder="Edit, remove, or prefer a link", + options=options, + ) + + +class ThroneCreatorSelect(discord.ui.Select): + def __init__(self, draft: ProfileDraft, options: list[discord.SelectOption]) -> None: + super().__init__( + custom_id=wizard_custom_id(draft, "creator-select"), + placeholder="Use a saved Throne creator", + options=options, + ) + + +class ProfileWizardDynamic( + discord.ui.DynamicItem[discord.ui.Button], + template=re.compile( + r"bill:p:(?P[A-Za-z0-9_-]+):(?P[a-z0-9]+):" + r"(?P[a-z0-9]+):(?P[a-z0-9]+):(?P[a-z0-9:_-]+)$" + ), +): + """Persistent action dispatcher; only Worker state decides whether it is valid.""" + + def __init__( + self, + item: discord.ui.Button, + draft_id: str, + owner: str, + guild: str, + revision: int, + action: str, + ) -> None: + super().__init__(item) + self.draft_id, self.owner, self.guild, self.revision, self.action = ( + draft_id, + owner, + guild, + revision, + action, + ) + + @classmethod + async def from_custom_id( + cls, + interaction: discord.Interaction[discord.Client], + item: discord.ui.Button, + match: re.Match[str], + /, + ) -> ProfileWizardDynamic: + return cls( + item, + decode_resource_id(match["draft"]), + str(decode_uint(match["owner"])), + str(decode_uint(match["guild"])), + decode_uint(match["revision"]), + match["action"], + ) + + async def callback(self, interaction: discord.Interaction[discord.Client]) -> None: + bot = cast("BillBot", interaction.client) + draft = await _load_draft( + bot, interaction, self.draft_id, self.owner, self.guild, self.revision + ) + if draft is None: + return + message = interaction.message + 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 + ) + return + await interaction.response.edit_message( + view=None, content="Your Bill profile is published." + ) + return + if self.action == "restart": + await interaction.response.send_message( + "Restart this private draft? This replaces unsaved progress.", + view=RestartConfirmView(draft), + ephemeral=True, + ) + return + if message is None: + await interaction.response.send_message( + "Please reopen your wizard with `/profile`.", ephemeral=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)) + return + if self.action == "visibility": + try: + lookup = await bot.require_worker().get_profile( + guild_id=bot.settings.home_guild_id, + user_id=interaction.user.id, + ) + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not load inherited links: {exc}", ephemeral=True + ) + 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 + ) + return + await interaction.response.send_message( + "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( + draft.id, + step=DraftStepKey.LINKS, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + values=_links_step_values(draft), + ) + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not save links: {exc}", ephemeral=True + ) + return + await interaction.response.edit_message(view=profile_wizard_view(updated)) + return + if self.action == "throne": + await interaction.response.send_modal(ThroneModal(draft, message)) + return + if self.action == "skip-throne": + try: + updated = await bot.require_worker().update_draft_step( + draft.id, + step=DraftStepKey.THRONE, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + values={ + "throne_creator_id": None, + "preferred_payment_link_id": draft.document.preferred_payment_link_id, + }, + ) + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not skip Throne: {exc}", ephemeral=True + ) + return + await interaction.response.edit_message(view=profile_wizard_view(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( + 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 + ), + }, + ) + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not rotate that webhook: {exc}", ephemeral=True + ) + return + await interaction.response.edit_message(view=profile_wizard_view(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, + ) + return + await interaction.response.send_message( + "That action is no longer available. Use the latest wizard.", ephemeral=True + ) + + +class _ProfileSelectDynamic( + discord.ui.DynamicItem[discord.ui.Select], + template=re.compile( + r"bill:p:(?P[A-Za-z0-9_-]+):(?P[a-z0-9]+):" + r"(?P[a-z0-9]+):(?P[a-z0-9]+):" + r"(?Porientation|identity-pronouns|identity-honourifics|" + r"identity-labels|link-select|creator-select)$" + ), +): + def __init__( + self, + item: discord.ui.Select, + draft_id: str, + owner: str, + guild: str, + revision: int, + action: str, + ) -> None: + super().__init__(item) + self.draft_id, self.owner, self.guild, self.revision, self.action = ( + draft_id, + owner, + guild, + revision, + action, + ) + + @classmethod + async def from_custom_id( + cls, + interaction: discord.Interaction[discord.Client], + item: discord.ui.Select, + match: re.Match[str], + /, + ) -> _ProfileSelectDynamic: + return cls( + item, + decode_resource_id(match["draft"]), + str(decode_uint(match["owner"])), + str(decode_uint(match["guild"])), + decode_uint(match["revision"]), + match["action"], + ) + + async def callback(self, interaction: discord.Interaction[discord.Client]) -> None: + bot = cast("BillBot", interaction.client) + draft = await _load_draft( + bot, interaction, self.draft_id, self.owner, self.guild, self.revision + ) + if draft is None or not self.item.values: + return + if self.action == "orientation": + try: + updated = 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}, + ) + except (ValueError, WorkerAPIError) as exc: + await interaction.response.send_message( + f"Bill could not save that orientation: {exc}", ephemeral=True + ) + return + await interaction.response.edit_message(view=profile_wizard_view(updated)) + return + if self.action.startswith("identity-"): + field = self.action.removeprefix("identity-") + 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)), + ) + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not save that identity selection: {exc}", + ephemeral=True, + ) + return + await interaction.response.edit_message(view=profile_wizard_view(updated)) + return + message = interaction.message + if message is None: + await interaction.response.send_message( + "Please reopen your wizard with `/profile`.", ephemeral=True + ) + 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 + ) + return + await interaction.response.send_message( + "Manage this link.", view=LinkManagerView(draft, link.id, message), ephemeral=True + ) + return + creator_id = self.item.values[0] + try: + attached = await bot.require_worker().attach_throne( + draft.id, + owner_user_id=interaction.user.id, + expected_revision=draft.revision, + existing_creator_id=creator_id, + ) + 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( + f"Bill could not connect that creator: {exc}", ephemeral=True + ) + return + await interaction.response.edit_message(view=profile_wizard_view(updated)) + if attached.webhook_url: + await interaction.followup.send( + "Your private Throne webhook URL (save it now):\n" + f"```text\n{attached.webhook_url}\n```", + ephemeral=True, + ) + + +ProfileSelectDynamic = _ProfileSelectDynamic + + +def _identity_values( + draft: ProfileDraft, pronouns: str, honourifics: str, labels: str, aliases: str, details: 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 + ) + detail_parts = [part.strip() for part in details.split("|", 2)] + if len(detail_parts) != 3: + raise ValueError("use: DM status | stats on/off | bio") + dm_raw, stats_raw, bio_raw = detail_parts + linked = ( + draft.target_scope is DraftScope.SERVER and draft.server_mode is ServerProfileMode.LINKED + ) + status = ( + None if linked and dm_raw.casefold() == "inherit" else DmStatus(dm_raw.casefold()).value + ) + 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] = [] + existing_overrides = set(draft.document.overridden_fields) + 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 status is not None: + 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, + selected: tuple[str, ...], +) -> dict[str, object]: + pronouns = selected if field == "pronouns" else draft.document.selections.pronouns + honourifics = selected if field == "honourifics" else draft.document.selections.honourifics + labels = selected if field == "labels" else draft.document.selections.submissive_labels + linked = ( + draft.target_scope is DraftScope.SERVER and draft.server_mode is ServerProfileMode.LINKED + ) + overrides = set(draft.document.overridden_fields) + if linked: + overrides.add( + { + "pronouns": "pronouns", + "honourifics": "honourifics", + "labels": "submissive_labels", + }[field] + ) + values: dict[str, object] = { + "pronouns": list(pronouns), + "honourifics": list(honourifics), + "submissive_labels": list(labels), + "dm_status": ( + draft.document.dm_status.value + if draft.document.dm_status + else (None if linked else DmStatus.OPEN.value) + ), + "bio": draft.document.bio, + "public_send_stats": draft.document.public_send_stats, + "aliases": list(draft.document.aliases), + "complete": False, + } + if linked: + values["overrides"] = sorted(overrides) + return values + + +def _links_step_values( + draft: ProfileDraft, *, hidden_inherited_link_ids: Iterable[str] | None = None +) -> dict[str, object]: + links = [ + { + "id": link.id, + "platform": link.platform, + "public_label": link.public_label, + "username": link.username, + "normalized_url": link.normalized_url, + "link_type": link.link_type.value, + "enabled": link.enabled, + } + for link in draft.document.links + ] + if draft.target_scope is DraftScope.SERVER and draft.server_mode is ServerProfileMode.LINKED: + return { + "local_links": links, + "hidden_inherited_link_ids": list( + draft.document.hidden_inherited_link_ids + if hidden_inherited_link_ids is None + else hidden_inherited_link_ids + ), + "preferred_payment_link_id": draft.document.preferred_payment_link_id, + } + return {"links": links} + + +class InheritedLinkSelect(discord.ui.Select): + def __init__( + self, + owner: InheritedLinkVisibilityView, + links: tuple[ProfileLink, ...], + ) -> None: + self.owner = owner + hidden = set(owner.draft.document.hidden_inherited_link_ids) + super().__init__( + placeholder="Select inherited links to hide", + min_values=0, + max_values=len(links), + options=[ + discord.SelectOption( + label=safe_text(link.public_label, limit=80), + value=link.id, + default=link.id in hidden, + ) + for link in links + ], + ) + + async def callback(self, interaction: discord.Interaction[discord.Client]) -> None: + await self.owner.save(interaction, tuple(self.values)) + + +class InheritedLinkVisibilityView(discord.ui.View): + """Short-lived private editor for sparse linked-profile visibility overrides.""" + + def __init__( + self, + draft: ProfileDraft, + links: tuple[ProfileLink, ...], + message: discord.Message, + ) -> None: + super().__init__(timeout=180) + self.draft, self.message = draft, message + self.add_item(InheritedLinkSelect(self, links[:12])) + + 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 change inherited links.", ephemeral=True + ) + return False + + async def save( + self, + interaction: discord.Interaction[discord.Client], + hidden_ids: tuple[str, ...], + ) -> None: + bot = cast("BillBot", interaction.client) + try: + updated = await bot.require_worker().update_draft_step( + self.draft.id, + step=DraftStepKey.LINKS, + owner_user_id=interaction.user.id, + expected_revision=self.draft.revision, + values=_links_step_values( + self.draft, + hidden_inherited_link_ids=hidden_ids, + ), + ) + except WorkerAPIError as exc: + await interaction.response.edit_message( + content=f"Bill could not save inherited visibility: {exc}", + view=None, + ) + return + await self.message.edit(view=profile_wizard_view(updated)) + await interaction.response.edit_message( + content=f"Hidden {len(hidden_ids)} inherited link(s) in this server.", + view=None, + ) + + @discord.ui.button(label="Keep all inherited links", style=discord.ButtonStyle.success) + async def keep_all( + self, + interaction: discord.Interaction[discord.Client], + _: discord.ui.Button, + ) -> None: + await self.save(interaction, ()) + + +class IdentityModal(discord.ui.Modal, title="Profile identity"): + 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), + 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) + dm_default = ( + draft.document.dm_status.value + if draft.document.dm_status + else ("inherit" if linked else DmStatus.OPEN.value) + ) + 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.details = discord.ui.TextInput( + label="DM status | stats on/off | bio (- clears)", + default=f"{dm_default} | {stats_default} | {bio_default}", + required=True, + max_length=380, + ) + for field in (self.aliases, self.details): + self.add_item(field) + + async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: + 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=_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.details.value, + ), + ) + except (ValueError, WorkerAPIError) as exc: + await interaction.response.send_message( + f"Bill could not save identity: {exc}", ephemeral=True + ) + return + await self.message.edit(view=profile_wizard_view(updated)) + await interaction.response.send_message("Identity saved.", 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 + ) -> None: + super().__init__() + self.draft, self.message, self.link_id = draft, message, link_id + 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 + ) + 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 "", + required=False, + max_length=80, + ) + 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: + 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, + "platform": self.platform.value or None, + } + updated = await ( + bot.require_worker().edit_link(self.draft.id, self.link_id, **common) + if self.link_id + else bot.require_worker().add_link(self.draft.id, **common) + ) + except (ValueError, WorkerAPIError) as exc: + await interaction.response.send_message( + f"Bill could not save that link: {exc}", ephemeral=True + ) + return + await self.message.edit(view=profile_wizard_view(updated)) + await interaction.response.send_message( + "Link saved. Choose **Done** when your links are ready.", ephemeral=True + ) + + +class LinkImportModal(discord.ui.Modal, title="Import a link page"): + def __init__(self, draft: ProfileDraft, message: discord.Message) -> None: + super().__init__() + self.draft, self.message = draft, message + self.url = discord.ui.TextInput( + label="HTTPS Linktree, AllMyLinks, Beacons, or page URL", max_length=500 + ) + self.add_item(self.url) + + async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: + bot = cast("BillBot", interaction.client) + try: + result = await bot.require_worker().create_link_import( + self.draft.id, + owner_user_id=interaction.user.id, + expected_revision=self.draft.revision, + source_url=self.url.value, + ) + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not import that page: {exc}", ephemeral=True + ) + return + await self.message.edit(view=profile_wizard_view(result.draft)) + labels = ( + ", ".join( + safe_text(candidate.public_label, limit=40) + for candidate in result.link_import.candidates + ) + or "No public links found" + ) + await interaction.response.send_message( + f"Imported candidates: {labels}", + view=ImportConfirmView( + result.draft, + result.link_import.id, + tuple(candidate.id for candidate in result.link_import.candidates), + self.message, + ), + ephemeral=True, + ) + + +class ThroneModal(discord.ui.Modal, title="Connect Throne"): + def __init__(self, draft: ProfileDraft, message: discord.Message) -> None: + super().__init__() + self.draft, self.message = draft, message + self.throne = discord.ui.TextInput(label="Throne username or profile URL", max_length=200) + self.add_item(self.throne) + + async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: + bot = cast("BillBot", interaction.client) + try: + attached = await bot.require_worker().attach_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( + f"Bill could not connect that Throne account: {exc}", ephemeral=True + ) + return + await self.message.edit(view=profile_wizard_view(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) + + +class ImportConfirmView(discord.ui.View): + def __init__( + self, + draft: ProfileDraft, + import_id: str, + candidate_ids: tuple[str, ...], + message: discord.Message, + ) -> None: + super().__init__(timeout=300) + self.draft, self.import_id, self.candidate_ids, self.message = ( + draft, + import_id, + candidate_ids, + message, + ) + + @discord.ui.button(label="Looks Good!", style=discord.ButtonStyle.success) + async def confirm( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + bot = cast("BillBot", interaction.client) + try: + result = await bot.require_worker().confirm_link_import( + self.draft.id, + self.import_id, + owner_user_id=interaction.user.id, + expected_revision=self.draft.revision, + candidate_ids=self.candidate_ids, + ) + except WorkerAPIError as exc: + await interaction.response.edit_message( + content=f"Bill could not confirm these links: {exc}", view=None + ) + return + await interaction.response.edit_message( + content=f"Added {result.added_link_count} link(s).", view=None + ) + await self.message.edit(view=profile_wizard_view(result.draft)) + + @discord.ui.button(label="Not Quite", style=discord.ButtonStyle.secondary) + async def manual( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + await interaction.response.edit_message( + content="No links were imported. Return to the wizard to add links manually.", view=None + ) + + +class LinkManagerView(discord.ui.View): + def __init__(self, draft: ProfileDraft, link_id: str, message: discord.Message) -> None: + super().__init__(timeout=180) + self.draft, self.link_id, self.message = draft, link_id, message + + @discord.ui.button(label="Edit", style=discord.ButtonStyle.primary) + 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)) + + @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) + try: + updated = await bot.require_worker().delete_link( + self.draft.id, + self.link_id, + owner_user_id=interaction.user.id, + expected_revision=self.draft.revision, + ) + except WorkerAPIError as exc: + await interaction.response.edit_message( + content=f"Bill could not remove that link: {exc}", view=None + ) + return + await self.message.edit(view=profile_wizard_view(updated)) + await interaction.response.edit_message(content="Link removed.", view=None) + + @discord.ui.button(label="Prefer payment", style=discord.ButtonStyle.secondary) + async def preferred( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + link = next((item for item in self.draft.document.links if item.id == self.link_id), None) + if link is None or link.link_type is not LinkType.PAYMENT: + await interaction.response.send_message( + "Only a payment link can be preferred.", ephemeral=True + ) + return + bot = cast("BillBot", interaction.client) + try: + updated = await bot.require_worker().edit_link( + self.draft.id, + link.id, + owner_user_id=interaction.user.id, + expected_revision=self.draft.revision, + public_label=link.public_label, + normalized_url=link.normalized_url, + link_type=link.link_type, + platform=link.platform, + username=link.username, + enabled=link.enabled, + preferred=True, + ) + except WorkerAPIError as exc: + await interaction.response.edit_message( + content=f"Bill could not prefer that link: {exc}", view=None + ) + return + await self.message.edit(view=profile_wizard_view(updated)) + await interaction.response.edit_message( + content="Preferred payment link updated.", view=None + ) + + +class RestartConfirmView(discord.ui.View): + def __init__(self, draft: ProfileDraft) -> None: + super().__init__(timeout=60) + self.draft = draft + + @discord.ui.button(label="Restart draft", style=discord.ButtonStyle.danger) + async def confirm( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + bot = cast("BillBot", interaction.client) + try: + draft = await bot.require_worker().restart_draft( + self.draft.id, + owner_user_id=interaction.user.id, + expected_revision=self.draft.revision, + ) + except WorkerAPIError as exc: + await interaction.response.edit_message( + content=f"Bill could not restart this draft: {exc}", view=None + ) + return + await interaction.response.edit_message( + content="Draft restarted. Use `/profile` to reopen its private wizard.", view=None + ) + try: + await interaction.user.create_dm() + await interaction.user.dm_channel.send(view=profile_wizard_view(draft)) # type: ignore[union-attr] + except discord.Forbidden: + return + + @discord.ui.button(label="Keep draft", style=discord.ButtonStyle.success) + async def cancel( + self, interaction: discord.Interaction[discord.Client], _: discord.ui.Button + ) -> None: + await interaction.response.edit_message(content="Restart cancelled.", view=None) diff --git a/bill/components/public_profile.py b/bill/components/public_profile.py new file mode 100644 index 0000000..8829a54 --- /dev/null +++ b/bill/components/public_profile.py @@ -0,0 +1,228 @@ +"""Public, compact Components V2 profile rendering and viewer-safe link controls.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, cast + +import discord + +from bill.components.profile import ORIENTATION_LABELS, profile_wizard_view +from bill.embeds import format_minor_amount +from bill.worker_client import ( + DraftScope, + LinkType, + PublicProfile, + ServerProfileMode, + WorkerAPIError, +) + +if TYPE_CHECKING: + from bill.bot import BillBot + + +def _escape(value: str, limit: int = 300) -> str: + return discord.utils.escape_mentions(discord.utils.escape_markdown(value))[:limit] + + +def public_profile_view( + profile: PublicProfile, + *, + guild_id: int | str, + owner_view: bool, + display_name: str | None = None, +) -> 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()) + title = f"{ORIENTATION_LABELS[profile.orientation]} profile" + if display_name: + title = f"{_escape(display_name, 80)} — {title}" + container.add_item(discord.ui.TextDisplay(f"## {title}")) + identity = ", ".join( + ( + *profile.selections.pronouns, + *profile.selections.honourifics, + *profile.selections.submissive_labels, + ) + ) + if identity: + container.add_item(discord.ui.TextDisplay(_escape(identity))) + container.add_item( + discord.ui.TextDisplay( + f"**DMs:** {_escape(profile.dm_status.value.replace('_', ' ').title())}" + ) + ) + if profile.bio: + container.add_item(discord.ui.TextDisplay(_escape(profile.bio))) + if profile.aliases: + aliases = ", ".join(f"@{alias}" for alias in profile.aliases) + container.add_item(discord.ui.TextDisplay(f"**Aliases:** {_escape(aliases)}")) + if profile.throne_connected: + container.add_item(discord.ui.TextDisplay("Throne: Connected")) + if profile.public_send_stats and profile.send_stats: + totals = ", ".join( + f"{format_minor_amount(stat.total_amount_minor, stat.currency)} ({stat.count})" + for stat in profile.send_stats + ) + container.add_item(discord.ui.TextDisplay(f"**Public send stats:** {_escape(totals, 500)}")) + controls: list[discord.ui.Button] = [] + if any(link.link_type is LinkType.PAYMENT for link in profile.links): + controls.append( + discord.ui.Button( + label="Payment Links", + custom_id=f"bill:links:{guild_id}:{profile.owner_user_id}:payment", + style=discord.ButtonStyle.primary, + ) + ) + if any(link.link_type is LinkType.SOCIAL for link in profile.links): + controls.append( + discord.ui.Button( + label="Socials", + custom_id=f"bill:links:{guild_id}:{profile.owner_user_id}:social", + style=discord.ButtonStyle.secondary, + ) + ) + if owner_view: + controls.append( + discord.ui.Button( + label="Edit", + custom_id=f"bill:edit:{guild_id}:{profile.owner_user_id}", + style=discord.ButtonStyle.success, + ) + ) + if controls: + container.add_item(discord.ui.ActionRow(*controls)) + view.add_item(container) + return view + + +class ProfileLinksDynamic( + discord.ui.DynamicItem[discord.ui.Button], + template=re.compile(r"bill:links:(?P\d+):(?P\d+):(?Ppayment|social)$"), +): + """Resolve links at click time so link visibility changes are never cached in Discord.""" + + def __init__( + self, item: discord.ui.Button, guild_id: str, owner_id: str, kind: LinkType + ) -> None: + super().__init__(item) + self.guild_id, self.owner_id, self.kind = guild_id, owner_id, kind + + @classmethod + async def from_custom_id( + cls, + interaction: discord.Interaction[discord.Client], + item: discord.ui.Button, + match: re.Match[str], + /, + ) -> ProfileLinksDynamic: + return cls(item, match["guild"], match["owner"], LinkType(match["kind"])) + + async def callback(self, interaction: discord.Interaction[discord.Client]) -> None: + bot = cast("BillBot", interaction.client) + try: + result = await bot.require_worker().get_profile( + guild_id=self.guild_id, user_id=self.owner_id + ) + except WorkerAPIError: + await interaction.response.send_message( + "Bill could not load these links right now.", ephemeral=True + ) + return + links = ( + () + if result.profile is None + else tuple(link for link in result.profile.links if link.link_type is self.kind) + ) + if not links: + await interaction.response.send_message( + "There are no public links in this section.", ephemeral=True + ) + return + view = discord.ui.LayoutView(timeout=180) + container = discord.ui.Container() + container.add_item( + discord.ui.TextDisplay( + f"## {'Payment Links' if self.kind is LinkType.PAYMENT else 'Socials'}" + ) + ) + # Discord link buttons require a direct HTTPS URL; all URLs were validated by Worker. + for link in links[:12]: + container.add_item( + discord.ui.ActionRow( + discord.ui.Button(label=_escape(link.public_label, 80), url=link.normalized_url) + ) + ) + view.add_item(container) + await interaction.response.send_message(view=view, ephemeral=True) + + +class ProfileEditDynamic( + discord.ui.DynamicItem[discord.ui.Button], + template=re.compile(r"bill:edit:(?P\d+):(?P\d+)$"), +): + def __init__(self, item: discord.ui.Button, guild_id: str, owner_id: str) -> None: + super().__init__(item) + self.guild_id, self.owner_id = guild_id, owner_id + + @classmethod + async def from_custom_id( + cls, + interaction: discord.Interaction[discord.Client], + item: discord.ui.Button, + match: re.Match[str], + /, + ) -> ProfileEditDynamic: + return cls(item, match["guild"], match["owner"]) + + async def callback(self, interaction: discord.Interaction[discord.Client]) -> None: + if str(interaction.user.id) != self.owner_id: + await interaction.response.send_message( + "Only the profile owner can edit it.", ephemeral=True + ) + return + bot = cast("BillBot", interaction.client) + try: + lookup = await bot.require_worker().get_profile( + guild_id=self.guild_id, + user_id=interaction.user.id, + ) + if lookup.profile is None: + raise WorkerAPIError("Profile is no longer available") + scope = ( + DraftScope.GLOBAL + if int(self.guild_id) == bot.settings.home_guild_id + else DraftScope.SERVER + ) + started = await bot.require_worker().start_draft( + owner_user_id=interaction.user.id, + origin_guild_id=self.guild_id, + target_scope=scope, + guild_id=self.guild_id if scope is DraftScope.SERVER else None, + server_mode=( + lookup.profile.mode or ServerProfileMode.INDEPENDENT + if scope is DraftScope.SERVER + else None + ), + ) + dm = await interaction.user.create_dm() + await dm.send("Your Bill profile editor is private. Your saved draft is below.") + await dm.send(view=profile_wizard_view(started.draft)) + except discord.Forbidden: + await interaction.response.send_message( + "I couldn't DM you. Please enable direct messages from server members, " + "then try again.", + ephemeral=True, + ) + return + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not open your editor: {exc}", + ephemeral=True, + ) + return + await interaction.response.send_message( + "I sent your private profile editor in a DM.", + ephemeral=True, + ) diff --git a/bill/components/setup.py b/bill/components/setup.py new file mode 100644 index 0000000..3752bc4 --- /dev/null +++ b/bill/components/setup.py @@ -0,0 +1,289 @@ +"""Public, initiator-bound Components V2 renderer for ``/bill setup``.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, cast + +import discord + +from bill.components.custom_ids import ( + decode_resource_id, + decode_uint, + encode_resource_id, + encode_uint, +) +from bill.worker_client import GuildSetupSession, WorkerAPIError + +if TYPE_CHECKING: + from bill.bot import BillBot + + +def missing_channel_permissions(permissions: discord.Permissions) -> tuple[str, ...]: + required = { + "view_channel": "View Channel", + "send_messages": "Send Messages", + "embed_links": "Embed Links", + "read_message_history": "Read Message History", + } + return tuple(label for name, label in required.items() if not getattr(permissions, name)) + + +def setup_custom_id(session: GuildSetupSession, action: str) -> str: + custom_id = ( + f"bill:s:{encode_resource_id(session.id)}:{encode_uint(session.initiator_user_id)}:" + f"{encode_uint(session.guild_id)}:{encode_uint(session.revision)}:{action}" + ) + if len(custom_id) > 100: + raise ValueError("Bill setup component ID exceeds Discord's 100-character limit") + return custom_id + + +def setup_view(session: GuildSetupSession) -> discord.ui.LayoutView: + view = discord.ui.LayoutView(timeout=None) + container = discord.ui.Container(accent_color=discord.Color.blurple()) + container.add_item(discord.ui.TextDisplay("## Set up Bill")) + if session.status == "completed": + container.add_item( + discord.ui.TextDisplay( + f"Bill is configured to post sends in <#{session.selected_channel_id}>." + ) + ) + elif session.selected_channel_id: + container.add_item( + discord.ui.TextDisplay(f"-# Channel: <#{session.selected_channel_id}> (Complete)") + ) + container.add_item(discord.ui.TextDisplay("### Confirm this channel")) + container.add_item( + discord.ui.ActionRow( + discord.ui.Button( + label="Confirm setup", + style=discord.ButtonStyle.success, + custom_id=setup_custom_id(session, "complete"), + ) + ) + ) + else: + container.add_item(discord.ui.TextDisplay("### Choose where Bill posts Throne sends")) + container.add_item( + discord.ui.ActionRow( + discord.ui.ChannelSelect( + custom_id=setup_custom_id(session, "channel"), + channel_types=[discord.ChannelType.text], + placeholder="Select a text channel", + ) + ) + ) + view.add_item(container) + return view + + +async def _authorized_setup( + interaction: discord.Interaction[discord.Client], + session: GuildSetupSession, + initiator: str, + guild: str, +) -> bool: + if ( + interaction.guild_id is None + or str(interaction.guild_id) != guild + or str(interaction.user.id) != initiator + or session.guild_id != guild + or session.initiator_user_id != initiator + ): + await interaction.response.send_message( + "Only the administrator who started this setup can continue it.", ephemeral=True + ) + return False + if ( + not isinstance(interaction.user, discord.Member) + or not interaction.user.guild_permissions.manage_guild + ): + await interaction.response.send_message( + "You need **Manage Server** to configure Bill.", ephemeral=True + ) + return False + return True + + +class SetupChannelDynamic( + discord.ui.DynamicItem[discord.ui.ChannelSelect], + template=re.compile( + r"bill:s:(?P[A-Za-z0-9_-]+):(?P[a-z0-9]+):" + r"(?P[a-z0-9]+):(?P[a-z0-9]+):channel$" + ), +): + def __init__( + self, + item: discord.ui.ChannelSelect, + session_id: str, + initiator: str, + guild: str, + revision: int, + ) -> None: + super().__init__(item) + self.session_id, self.initiator, self.guild, self.revision = ( + session_id, + initiator, + guild, + revision, + ) + + @classmethod + async def from_custom_id( + cls, + interaction: discord.Interaction[discord.Client], + item: discord.ui.ChannelSelect, + match: re.Match[str], + /, + ) -> SetupChannelDynamic: + return cls( + item, + decode_resource_id(match["session"]), + str(decode_uint(match["initiator"])), + str(decode_uint(match["guild"])), + decode_uint(match["revision"]), + ) + + async def callback(self, interaction: discord.Interaction[discord.Client]) -> None: + bot = cast("BillBot", interaction.client) + try: + session = await bot.require_worker().get_guild_setup(self.session_id) + except WorkerAPIError: + await interaction.response.send_message( + "That setup session is no longer available.", ephemeral=True + ) + return + if not await _authorized_setup(interaction, session, self.initiator, self.guild): + return + if session.revision != self.revision or session.status != "active" or not self.item.values: + await interaction.response.send_message( + "That setup control is stale. Please use the latest message.", ephemeral=True + ) + return + channel = self.item.values[0] + if ( + not isinstance(channel, discord.TextChannel) + or interaction.guild is None + or interaction.guild.me is None + ): + await interaction.response.send_message( + "Please choose a standard text channel.", ephemeral=True + ) + return + missing = missing_channel_permissions(channel.permissions_for(interaction.guild.me)) + if missing: + await interaction.response.send_message( + f"Bill needs {', '.join(f'**{name}**' for name in missing)} in {channel.mention}.", + ephemeral=True, + ) + return + try: + updated = await bot.require_worker().set_guild_setup_channel( + self.session_id, + guild_id=interaction.guild_id, + initiator_user_id=interaction.user.id, + expected_revision=self.revision, + channel_id=channel.id, + ) + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not save that channel: {exc}", ephemeral=True + ) + return + await interaction.response.edit_message(view=setup_view(updated)) + + +class SetupCompleteDynamic( + discord.ui.DynamicItem[discord.ui.Button], + template=re.compile( + r"bill:s:(?P[A-Za-z0-9_-]+):(?P[a-z0-9]+):" + r"(?P[a-z0-9]+):(?P[a-z0-9]+):complete$" + ), +): + def __init__( + self, + item: discord.ui.Button, + session_id: str, + initiator: str, + guild: str, + revision: int, + ) -> None: + super().__init__(item) + self.session_id, self.initiator, self.guild, self.revision = ( + session_id, + initiator, + guild, + revision, + ) + + @classmethod + async def from_custom_id( + cls, + interaction: discord.Interaction[discord.Client], + item: discord.ui.Button, + match: re.Match[str], + /, + ) -> SetupCompleteDynamic: + return cls( + item, + decode_resource_id(match["session"]), + str(decode_uint(match["initiator"])), + str(decode_uint(match["guild"])), + decode_uint(match["revision"]), + ) + + async def callback(self, interaction: discord.Interaction[discord.Client]) -> None: + bot = cast("BillBot", interaction.client) + try: + session = await bot.require_worker().get_guild_setup(self.session_id) + except WorkerAPIError: + await interaction.response.send_message( + "That setup session is no longer available.", ephemeral=True + ) + return + if not await _authorized_setup(interaction, session, self.initiator, self.guild): + return + if ( + session.revision != self.revision + or session.status != "active" + or not session.selected_channel_id + ): + await interaction.response.send_message( + "That setup control is stale. Please use the latest message.", ephemeral=True + ) + return + channel = ( + interaction.guild.get_channel(int(session.selected_channel_id)) + if interaction.guild + else None + ) + if ( + not isinstance(channel, discord.TextChannel) + or interaction.guild is None + or interaction.guild.me is None + ): + await interaction.response.send_message( + "The selected channel is no longer available.", ephemeral=True + ) + return + missing = missing_channel_permissions(channel.permissions_for(interaction.guild.me)) + if missing: + await interaction.response.send_message( + f"Bill needs {', '.join(f'**{name}**' for name in missing)} in {channel.mention}.", + ephemeral=True, + ) + return + try: + completed = await bot.require_worker().complete_guild_setup( + self.session_id, + guild_id=interaction.guild_id, + initiator_user_id=interaction.user.id, + expected_revision=self.revision, + ) + except WorkerAPIError as exc: + await interaction.response.send_message( + f"Bill could not complete setup: {exc}", ephemeral=True + ) + return + await interaction.response.edit_message(view=setup_view(completed.session)) diff --git a/bill/settings.py b/bill/settings.py index 2527b12..3560d50 100644 --- a/bill/settings.py +++ b/bill/settings.py @@ -39,6 +39,7 @@ class Settings: discord_token: str worker_base_url: str worker_api_token: str + home_guild_id: int poll_interval_seconds: int = 5 notification_batch_size: int = 10 notification_lease_seconds: int = 60 @@ -55,6 +56,8 @@ def from_env(cls, environ: Mapping[str, str] | None = None) -> Settings: if parsed.scheme != "https" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}: raise SettingsError("BILL_WORKER_BASE_URL must use HTTPS outside local development") + home_guild_id = _snowflake(values, "BILL_HOME_GUILD_ID") + raw_test_guild = values.get("BILL_TEST_GUILD_ID", "").strip() test_guild_id: int | None = None if raw_test_guild: @@ -88,6 +91,15 @@ def from_env(cls, environ: Mapping[str, str] | None = None) -> Settings: 60, maximum=600, ), + home_guild_id=home_guild_id, test_guild_id=test_guild_id, log_level=log_level, ) + + +def _snowflake(environ: Mapping[str, str], name: str) -> int: + """Load a required Discord snowflake without ever exposing surrounding env values.""" + value = _required(environ, name) + if not value.isdecimal(): + raise SettingsError(f"{name} must be a Discord snowflake") + return int(value) diff --git a/bill/worker_client.py b/bill/worker_client.py index 4a02e23..fe79236 100644 --- a/bill/worker_client.py +++ b/bill/worker_client.py @@ -1,18 +1,73 @@ +"""Typed, secret-safe client for Bill's bearer-protected Worker API. + +The Worker owns profile state and revision checks. This module deliberately turns +its JSON boundary into immutable Python contracts so Discord interaction handlers +cannot accidentally use stale dictionaries or log returned webhook secrets. +""" + from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum from typing import Any import aiohttp +type JSONValue = bool | int | float | str | list[JSONValue] | dict[str, JSONValue] | None + class WorkerAPIError(RuntimeError): - def __init__(self, message: str, *, status: int | None = None, code: str | None = None): + """A safe Worker failure; ``code`` is suitable for interaction handling.""" + + def __init__(self, message: str, *, status: int | None = None, code: str | None = None) -> None: super().__init__(message) self.status = status self.code = code +class Orientation(StrEnum): + DOMME = "domme" + SUBMISSIVE = "submissive" + SWITCH_DOMME = "switch_domme" + SWITCH_SUBMISSIVE = "switch_submissive" + + +class DmStatus(StrEnum): + OPEN = "open" + BY_REQUEST = "by_request" + AFTER_TRIBUTE = "after_tribute" + CLOSED = "closed" + + +class DraftScope(StrEnum): + GLOBAL = "global" + SERVER = "server" + + +class ServerProfileMode(StrEnum): + LINKED = "linked" + INDEPENDENT = "independent" + + +class DraftStatus(StrEnum): + ACTIVE = "active" + PUBLISHED = "published" + ABANDONED = "abandoned" + + +class DraftStepKey(StrEnum): + ORIENTATION = "orientation" + IDENTITY = "identity" + LINKS = "links" + THRONE = "throne" + REVIEW = "review" + + +class LinkType(StrEnum): + SOCIAL = "social" + PAYMENT = "payment" + + @dataclass(frozen=True, slots=True) class GuildConfig: guild_id: str @@ -21,6 +76,8 @@ class GuildConfig: @dataclass(frozen=True, slots=True) class DommeRegistration: + """Legacy registration response retained while the bot migrates to profiles.""" + creator_id: str throne_handle: str webhook_url: str | None @@ -47,6 +104,189 @@ class SendNotification: delivery_may_exist: bool +@dataclass(frozen=True, slots=True) +class ProfileSelections: + pronouns: tuple[str, ...] + honourifics: tuple[str, ...] + submissive_labels: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class ProfileLink: + id: str + platform: str + public_label: str + username: str | None + normalized_url: str + link_type: LinkType + enabled: bool = True + sort_order: int = 0 + + +@dataclass(frozen=True, slots=True) +class SendStat: + currency: str + count: int + total_amount_minor: int + + +@dataclass(frozen=True, slots=True) +class PublicProfile: + scope: DraftScope + mode: ServerProfileMode | None + owner_user_id: str + orientation: Orientation + dm_status: DmStatus + bio: str | None + public_send_stats: bool + selections: ProfileSelections + aliases: tuple[str, ...] + links: tuple[ProfileLink, ...] + preferred_payment_link_id: str | None + throne_connected: bool + send_stats: tuple[SendStat, ...] | None + version: int + published_at: str | None + + +@dataclass(frozen=True, slots=True) +class ProfileLookup: + profile: PublicProfile | None + global_available: bool + + +@dataclass(frozen=True, slots=True) +class DraftStep: + key: DraftStepKey + status: str + completed_at: str | None + + +@dataclass(frozen=True, slots=True) +class DraftDocument: + dm_status: DmStatus | None + bio: str | None + public_send_stats: bool + selections: ProfileSelections + aliases: tuple[str, ...] + links: tuple[ProfileLink, ...] + overridden_fields: tuple[str, ...] + hidden_inherited_link_ids: tuple[str, ...] + throne_creator_id: str | None + preferred_payment_link_id: str | None + + +@dataclass(frozen=True, slots=True) +class ThroneCreator: + id: str + handle: str + + +@dataclass(frozen=True, slots=True) +class ThronePrefill: + owned_creators: tuple[ThroneCreator, ...] + existing_registration_creator_id: str | None + + +@dataclass(frozen=True, slots=True) +class ProfileDraft: + id: str + owner_user_id: str + origin_guild_id: str + target_scope: DraftScope + guild_id: str | None + server_mode: ServerProfileMode | None + status: DraftStatus + revision: int + base_version: int + current_step: DraftStepKey | None + next_step: DraftStepKey | None + steps: tuple[DraftStep, ...] + governing_orientation: Orientation | None + document: DraftDocument + throne_prefill: ThronePrefill | None + created_at: str | None + updated_at: str | None + published_at: str | None + + +@dataclass(frozen=True, slots=True) +class StartDraftResult: + resume_required: bool + draft: ProfileDraft + + +@dataclass(frozen=True, slots=True) +class LinkImportCandidate: + id: str + platform: str + public_label: str + username: str | None + normalized_url: str + link_type: LinkType + selected: bool + + +@dataclass(frozen=True, slots=True) +class LinkImport: + id: str + draft_id: str + source_url: str + provider: str + status: str + candidates: tuple[LinkImportCandidate, ...] + + +@dataclass(frozen=True, slots=True) +class CreateLinkImportResult: + """An imported candidate set and the revision it advanced the private draft to.""" + + link_import: LinkImport + draft: ProfileDraft + + +@dataclass(frozen=True, slots=True) +class LinkImportConfirmation: + draft: ProfileDraft + added_link_count: int + skipped_duplicate_count: int + + +@dataclass(frozen=True, slots=True) +class ThroneDraftResult: + draft: ProfileDraft + webhook_url: str | None + webhook_state: str + + +@dataclass(frozen=True, slots=True) +class GuildSetupSession: + id: str + guild_id: str + initiator_user_id: str + status: str + current_step: str + selected_channel_id: str | None + revision: int + public_message_id: str | None + expires_at: str | None + created_at: str | None + updated_at: str | None + completed_at: str | None + + +@dataclass(frozen=True, slots=True) +class StartGuildSetupResult: + resume_required: bool + session: GuildSetupSession + + +@dataclass(frozen=True, slots=True) +class CompleteGuildSetupResult: + session: GuildSetupSession + send_channel_id: str + + def _snowflake(value: int | str) -> str: text = str(value) if not text.isdecimal(): @@ -54,7 +294,47 @@ def _snowflake(value: int | str) -> str: return text +def _record(value: object, what: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise WorkerAPIError(f"Worker returned an invalid {what}") + return value + + +def _string(value: object, field: str) -> str: + if not isinstance(value, str): + raise WorkerAPIError(f"Worker returned an invalid {field}") + return value + + +def _optional_string(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def _integer(value: object, field: str) -> int: + if isinstance(value, bool): + raise WorkerAPIError(f"Worker returned an invalid {field}") + try: + return int(value) + except (TypeError, ValueError) as exc: + raise WorkerAPIError(f"Worker returned an invalid {field}") from exc + + +def _enum(enum_type: type[StrEnum], value: object, field: str) -> StrEnum: + try: + return enum_type(_string(value, field)) + except ValueError as exc: + raise WorkerAPIError(f"Worker returned an invalid {field}") from exc + + +def _strings(value: object, field: str) -> tuple[str, ...]: + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise WorkerAPIError(f"Worker returned an invalid {field}") + return tuple(value) + + class WorkerClient: + """Small typed facade over Worker endpoints; request data is never logged.""" + def __init__( self, *, @@ -75,34 +355,20 @@ async def get_guild_config(self, guild_id: int | str) -> GuildConfig | None: if exc.status == 404: return None raise - return GuildConfig( - guild_id=_snowflake(data["guild_id"]), - send_channel_id=_snowflake(data["send_channel_id"]), - ) + return GuildConfig(_snowflake(data["guild_id"]), _snowflake(data["send_channel_id"])) async def configure_guild( - self, - *, - guild_id: int | str, - send_channel_id: int | str, + self, *, guild_id: int | str, send_channel_id: int | str ) -> GuildConfig: data = await self._request( "PUT", f"/v1/guilds/{_snowflake(guild_id)}/config", json={"send_channel_id": _snowflake(send_channel_id)}, ) - return GuildConfig( - guild_id=_snowflake(data["guild_id"]), - send_channel_id=_snowflake(data["send_channel_id"]), - ) + return GuildConfig(_snowflake(data["guild_id"]), _snowflake(data["send_channel_id"])) async def register_domme( - self, - *, - guild_id: int | str, - discord_user_id: int | str, - throne: str, - reset_webhook: bool, + self, *, guild_id: int | str, discord_user_id: int | str, throne: str, reset_webhook: bool ) -> DommeRegistration: data = await self._request( "POST", @@ -113,20 +379,320 @@ async def register_domme( "reset_webhook": reset_webhook, }, ) - webhook_url = data.get("webhook_url") return DommeRegistration( - creator_id=str(data["creator_id"]), - throne_handle=str(data["throne_handle"]), - webhook_url=str(webhook_url) if webhook_url else None, - webhook_state=str(data["webhook_state"]), + str(data["creator_id"]), + str(data["throne_handle"]), + _optional_string(data.get("webhook_url")), + str(data["webhook_state"]), ) - async def lease_notifications( + async def get_profile(self, *, guild_id: int | str, user_id: int | str) -> ProfileLookup: + data = await self._request( + "GET", f"/v1/guilds/{_snowflake(guild_id)}/profiles/{_snowflake(user_id)}" + ) + profile = data.get("profile") + if profile is not None and not isinstance(profile, dict): + raise WorkerAPIError("Worker returned an invalid profile lookup") + global_available = data.get("global_available") + if not isinstance(global_available, bool): + raise WorkerAPIError("Worker returned an invalid profile lookup") + return ProfileLookup(self._parse_profile(profile) if profile else None, global_available) + + async def start_draft( + self, + *, + owner_user_id: int | str, + origin_guild_id: int | str, + target_scope: DraftScope, + guild_id: int | str | None = None, + server_mode: ServerProfileMode | None = None, + ) -> StartDraftResult: + body: dict[str, JSONValue] = { + "owner_user_id": _snowflake(owner_user_id), + "origin_guild_id": _snowflake(origin_guild_id), + "target_scope": target_scope.value, + } + if target_scope is DraftScope.SERVER: + if guild_id is None or server_mode is None: + raise ValueError("server drafts require guild_id and server_mode") + body.update({"guild_id": _snowflake(guild_id), "server_mode": server_mode.value}) + data = await self._request("POST", "/v1/profile-drafts/start", json=body) + if not isinstance(data.get("resume_required"), bool): + raise WorkerAPIError("Worker returned an invalid draft start") + return StartDraftResult(data["resume_required"], self._parse_draft(data.get("draft"))) + + async def get_draft(self, draft_id: str, *, owner_user_id: int | str) -> ProfileDraft: + data = await self._request( + "GET", f"/v1/profile-drafts/{draft_id}?owner_user_id={_snowflake(owner_user_id)}" + ) + return self._parse_draft(data.get("draft")) + + async def update_draft_step( self, + draft_id: str, *, - owner: str, - limit: int, - lease_seconds: int, + step: DraftStepKey, + owner_user_id: int | str, + expected_revision: int, + values: dict[str, JSONValue], + ) -> ProfileDraft: + data = await self._request( + "PUT", + f"/v1/profile-drafts/{draft_id}/steps/{step.value}", + 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: + data = await self._request( + "POST", + f"/v1/profile-drafts/{draft_id}/restart", + json=self._mutation(owner_user_id, expected_revision), + ) + return self._parse_draft(data.get("draft")) + + async def publish_draft( + self, draft_id: str, *, owner_user_id: int | str, expected_revision: int + ) -> PublicProfile: + data = await self._request( + "POST", + f"/v1/profile-drafts/{draft_id}/publish", + json=self._mutation(owner_user_id, expected_revision), + ) + return self._parse_profile(data.get("profile")) + + async def create_link_import( + self, draft_id: str, *, owner_user_id: int | str, expected_revision: int, source_url: str + ) -> CreateLinkImportResult: + data = await self._request( + "POST", + f"/v1/profile-drafts/{draft_id}/link-imports", + json=self._mutation(owner_user_id, expected_revision, {"source_url": source_url}), + ) + # Link import creation is a mutation: its response revision, rather than + # the caller's prior revision, is the only safe basis for the next action. + import_result = self._parse_import(data.get("import")) + mutation_draft = _record(data.get("draft"), "link import draft") + response_draft_id = _string(mutation_draft.get("id"), "link import draft id") + response_revision = _integer(mutation_draft.get("revision"), "link import draft revision") + if response_draft_id != draft_id: + raise WorkerAPIError("Worker returned a link import for the wrong draft") + draft = await self.get_draft(draft_id, owner_user_id=owner_user_id) + if draft.revision < response_revision: + raise WorkerAPIError("Worker returned an out-of-date link import draft") + return CreateLinkImportResult(import_result, draft) + + async def confirm_link_import( + self, + draft_id: str, + import_id: str, + *, + owner_user_id: int | str, + expected_revision: int, + candidate_ids: tuple[str, ...] | None = None, + ) -> LinkImportConfirmation: + extra: dict[str, JSONValue] = ( + {} if candidate_ids is None else {"candidate_ids": list(candidate_ids)} + ) + data = await self._request( + "POST", + f"/v1/profile-drafts/{draft_id}/link-imports/{import_id}/confirm", + json=self._mutation(owner_user_id, expected_revision, extra), + ) + return LinkImportConfirmation( + await self.get_draft(draft_id, owner_user_id=owner_user_id), + _integer(data.get("added_link_count"), "added_link_count"), + _integer(data.get("skipped_duplicate_count"), "skipped_duplicate_count"), + ) + + async def add_link( + self, + draft_id: str, + *, + owner_user_id: int | str, + expected_revision: int, + public_label: str, + normalized_url: str, + link_type: LinkType, + platform: str | None = None, + username: str | None = None, + enabled: bool = True, + preferred: bool | None = None, + ) -> ProfileDraft: + body: dict[str, JSONValue] = { + "public_label": public_label, + "normalized_url": normalized_url, + "link_type": link_type.value, + "enabled": enabled, + } + if platform is not None: + body["platform"] = platform + if username is not None: + body["username"] = username + if preferred is not None: + body["preferred"] = preferred + _ = await self._request( + "POST", + f"/v1/profile-drafts/{draft_id}/links", + json=self._mutation(owner_user_id, expected_revision, body), + ) + return await self.get_draft(draft_id, owner_user_id=owner_user_id) + + async def edit_link( + self, + draft_id: str, + link_id: str, + *, + owner_user_id: int | str, + expected_revision: int, + public_label: str, + normalized_url: str, + link_type: LinkType, + platform: str | None = None, + username: str | None = None, + enabled: bool = True, + preferred: bool | None = None, + ) -> ProfileDraft: + body: dict[str, JSONValue] = { + "public_label": public_label, + "normalized_url": normalized_url, + "link_type": link_type.value, + "enabled": enabled, + } + if platform is not None: + body["platform"] = platform + if username is not None: + body["username"] = username + if preferred is not None: + body["preferred"] = preferred + _ = await self._request( + "PUT", + f"/v1/profile-drafts/{draft_id}/links/{link_id}", + json=self._mutation(owner_user_id, expected_revision, body), + ) + return await self.get_draft(draft_id, owner_user_id=owner_user_id) + + async def delete_link( + self, draft_id: str, link_id: str, *, owner_user_id: int | str, expected_revision: int + ) -> ProfileDraft: + _ = await self._request( + "DELETE", + f"/v1/profile-drafts/{draft_id}/links/{link_id}", + json=self._mutation(owner_user_id, expected_revision), + ) + return await self.get_draft(draft_id, owner_user_id=owner_user_id) + + async def attach_throne( + self, + draft_id: str, + *, + owner_user_id: int | str, + expected_revision: int, + throne_input: str | None = None, + existing_creator_id: str | None = None, + rotate_webhook: bool = False, + ) -> ThroneDraftResult: + data = await self._request( + "POST", + f"/v1/profile-drafts/{draft_id}/throne", + json=self._mutation( + owner_user_id, + expected_revision, + { + "throne_input": throne_input, + "existing_creator_id": existing_creator_id, + "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"), + ) + + async def rotate_throne( + self, draft_id: str, *, owner_user_id: int | str, expected_revision: int + ) -> ThroneDraftResult: + data = await self._request( + "POST", + 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"), + ) + + async def start_guild_setup( + self, *, guild_id: int | str, initiator_user_id: int | str + ) -> StartGuildSetupResult: + data = await self._request( + "POST", + "/v1/guild-setup-sessions", + json={ + "guild_id": _snowflake(guild_id), + "initiator_user_id": _snowflake(initiator_user_id), + }, + ) + if not isinstance(data.get("resume_required"), bool): + raise WorkerAPIError("Worker returned an invalid setup session") + return StartGuildSetupResult( + data["resume_required"], self._parse_setup_session(data.get("session")) + ) + + async def get_guild_setup(self, session_id: str) -> GuildSetupSession: + return self._parse_setup_session( + (await self._request("GET", f"/v1/guild-setup-sessions/{session_id}")).get("session") + ) + + async def set_guild_setup_channel( + self, + session_id: str, + *, + guild_id: int | str, + initiator_user_id: int | str, + expected_revision: int, + channel_id: int | str, + ) -> GuildSetupSession: + data = await self._request( + "PUT", + f"/v1/guild-setup-sessions/{session_id}/channel", + json={ + "guild_id": _snowflake(guild_id), + "initiator_user_id": _snowflake(initiator_user_id), + "expected_revision": expected_revision, + "channel_id": _snowflake(channel_id), + }, + ) + return self._parse_setup_session(data.get("session")) + + async def complete_guild_setup( + self, + session_id: str, + *, + guild_id: int | str, + initiator_user_id: int | str, + expected_revision: int, + ) -> CompleteGuildSetupResult: + data = await self._request( + "POST", + f"/v1/guild-setup-sessions/{session_id}/complete", + json={ + "guild_id": _snowflake(guild_id), + "initiator_user_id": _snowflake(initiator_user_id), + "expected_revision": expected_revision, + }, + ) + return CompleteGuildSetupResult( + self._parse_setup_session(data.get("session")), _snowflake(data.get("send_channel_id")) + ) + + async def lease_notifications( + self, *, owner: str, limit: int, lease_seconds: int ) -> list[SendNotification]: data = await self._request( "POST", @@ -139,114 +705,299 @@ async def lease_notifications( return [self._parse_notification(row) for row in rows] async def ack_notification( - self, - notification_id: str, - *, - lease_token: str, - discord_message_id: int | str, + self, notification_id: str, *, lease_token: str, discord_message_id: int | str ) -> None: await self._request( "POST", f"/v1/notifications/{notification_id}/ack", - json={ - "lease_token": lease_token, - "discord_message_id": _snowflake(discord_message_id), - }, + json={"lease_token": lease_token, "discord_message_id": _snowflake(discord_message_id)}, ) async def nack_notification( - self, - notification_id: str, - *, - lease_token: str, - error: str, - permanent: bool, + self, notification_id: str, *, lease_token: str, error: str, permanent: bool ) -> None: await self._request( "POST", f"/v1/notifications/{notification_id}/nack", - json={ - "lease_token": lease_token, - "error": error[:300], - "permanent": permanent, - }, + json={"lease_token": lease_token, "error": error[:300], "permanent": permanent}, ) + @staticmethod + def _mutation( + owner_user_id: int | str, expected_revision: int, values: dict[str, JSONValue] | None = None + ) -> dict[str, JSONValue]: + if expected_revision < 0: + raise ValueError("expected_revision must not be negative") + return { + "owner_user_id": _snowflake(owner_user_id), + "expected_revision": expected_revision, + **(values or {}), + } + async def _request( - self, - method: str, - path: str, - *, - json: dict[str, Any] | None = None, + self, method: str, path: str, *, json: dict[str, JSONValue] | None = None ) -> dict[str, Any]: - headers = { - "Authorization": f"Bearer {self._api_token}", - "Accept": "application/json", - } + # This header is passed only to aiohttp; this client never logs request headers or bodies. + headers = {"Authorization": f"Bearer {self._api_token}", "Accept": "application/json"} try: async with self._session.request( - method, - f"{self._base_url}{path}", - headers=headers, - json=json, - timeout=self._timeout, + method, f"{self._base_url}{path}", headers=headers, json=json, timeout=self._timeout ) as response: try: payload = await response.json() except (aiohttp.ContentTypeError, ValueError) as exc: raise WorkerAPIError( - "Worker returned a non-JSON response", - status=response.status, + "Worker returned a non-JSON response", status=response.status ) from exc except TimeoutError as exc: raise WorkerAPIError("Worker request timed out") from exc except aiohttp.ClientError as exc: raise WorkerAPIError("Worker request failed") from exc - if not isinstance(payload, dict): raise WorkerAPIError("Worker returned an invalid response", status=response.status) if response.status >= 400 or payload.get("ok") is not True: error = payload.get("error") - if isinstance(error, dict): - code = str(error.get("code", "worker_error")) - message = str(error.get("message", "Worker request failed")) - else: - code = str(payload.get("code", "worker_error")) - message = str(error or payload.get("message") or "Worker request failed") + code = ( + str(error.get("code", "worker_error")) + if isinstance(error, dict) + else str(payload.get("code", "worker_error")) + ) + message = ( + str(error.get("message", "Worker request failed")) + if isinstance(error, dict) + else str(error or payload.get("message") or "Worker request failed") + ) raise WorkerAPIError(message, status=response.status, code=code) - data = payload.get("data") - if not isinstance(data, dict): - raise WorkerAPIError( - "Worker response did not contain an object", - status=response.status, + return _record(payload.get("data"), "response data") + + @staticmethod + def _parse_profile(value: object) -> PublicProfile: + data = _record(value, "profile") + selections = WorkerClient._parse_selections(data.get("selections")) + links = tuple( + WorkerClient._parse_link(row, include_order=True) + for row in _list(data.get("links"), "profile links") + ) + raw_stats = data.get("send_stats") + stats = ( + None + if raw_stats is None + else tuple( + SendStat( + _string(_record(row, "send stat").get("currency"), "currency"), + _integer(_record(row, "send stat").get("count"), "count"), + _integer( + _record(row, "send stat").get("total_amount_minor"), "total_amount_minor" + ), + ) + for row in _list(raw_stats, "send_stats") ) - return data + ) + mode_value = data.get("mode") + return PublicProfile( + DraftScope(_enum(DraftScope, data.get("scope"), "scope")), + None + if mode_value is None + else ServerProfileMode(_enum(ServerProfileMode, mode_value, "mode")), + _snowflake(data.get("owner_user_id")), + Orientation(_enum(Orientation, data.get("orientation"), "orientation")), + DmStatus(_enum(DmStatus, data.get("dm_status"), "dm_status")), + _optional_string(data.get("bio")), + _bool(data.get("public_send_stats"), "public_send_stats"), + selections, + _strings(data.get("aliases"), "aliases"), + links, + _optional_string(data.get("preferred_payment_link_id")), + _bool(data.get("throne_connected"), "throne_connected"), + stats, + _integer(data.get("version"), "version"), + _optional_string(data.get("published_at")), + ) @staticmethod - def _parse_notification(value: Any) -> SendNotification: - if not isinstance(value, dict): - raise WorkerAPIError("Worker returned an invalid notification") - try: - amount_minor = int(value["amount_minor"]) - except (KeyError, TypeError, ValueError) as exc: - raise WorkerAPIError("Worker returned an invalid notification amount") from exc - return SendNotification( - notification_id=str(value["notification_id"]), - lease_token=str(value["lease_token"]), - send_id=str(value["send_id"]), - guild_id=_snowflake(value["guild_id"]), - channel_id=_snowflake(value["channel_id"]), - recipient_user_id=_snowflake(value["recipient_user_id"]), - throne_handle=str(value["throne_handle"]), - amount_minor=amount_minor, - currency=str(value["currency"]), - sender_name=str(value["sender_name"]) if value.get("sender_name") else None, - is_private=bool(value.get("is_private")), - is_anonymous=bool(value.get("is_anonymous")), - item_name=str(value["item_name"]) if value.get("item_name") else None, - item_image_url=( - str(value["item_image_url"]) if value.get("item_image_url") else None + def _parse_draft(value: object) -> ProfileDraft: + data = _record(value, "draft") + document = _record(data.get("document"), "draft document") + prefill = data.get("throne_prefill") + parsed_prefill = None if prefill is None else WorkerClient._parse_prefill(prefill) + current = data.get("current_step") + next_step = data.get("next_step") + governing = data.get("governing_orientation") + return ProfileDraft( + _string(data.get("id"), "draft id"), + _snowflake(data.get("owner_user_id")), + _snowflake(data.get("origin_guild_id")), + DraftScope(_enum(DraftScope, data.get("target_scope"), "target_scope")), + _nullable_snowflake(data.get("guild_id")), + _nullable_enum(ServerProfileMode, data.get("server_mode"), "server_mode"), + DraftStatus(_enum(DraftStatus, data.get("status"), "status")), + _integer(data.get("revision"), "revision"), + _integer(data.get("base_version"), "base_version"), + None if current is None else DraftStepKey(_enum(DraftStepKey, current, "current_step")), + None + if next_step is None + else DraftStepKey(_enum(DraftStepKey, next_step, "next_step")), + tuple(WorkerClient._parse_step(row) for row in _list(data.get("steps"), "draft steps")), + None + if governing is None + else Orientation(_enum(Orientation, governing, "governing_orientation")), + DraftDocument( + _nullable_enum(DmStatus, document.get("dm_status"), "dm_status"), + _optional_string(document.get("bio")), + _bool(document.get("public_send_stats"), "public_send_stats"), + WorkerClient._parse_selections(document.get("selections")), + _strings(document.get("aliases"), "aliases"), + tuple( + WorkerClient._parse_link(row) + for row in _list(document.get("links"), "draft links") + ), + _strings(document.get("overridden_fields"), "overridden_fields"), + _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")), ), - purchased_at=str(value["purchased_at"]), - delivery_may_exist=bool(value.get("delivery_may_exist")), + parsed_prefill, + _optional_string(data.get("created_at")), + _optional_string(data.get("updated_at")), + _optional_string(data.get("published_at")), + ) + + @staticmethod + def _parse_selections(value: object) -> ProfileSelections: + data = _record(value, "selections") + return ProfileSelections( + _strings(data.get("pronouns"), "pronouns"), + _strings(data.get("honourifics"), "honourifics"), + _strings(data.get("submissive_labels"), "submissive_labels"), + ) + + @staticmethod + def _parse_link(value: object, *, include_order: bool = False) -> ProfileLink: + data = _record(value, "link") + return ProfileLink( + _string(data.get("id"), "link id"), + _string(data.get("platform"), "platform"), + _string(data.get("public_label"), "public_label"), + _optional_string(data.get("username")), + _string(data.get("normalized_url"), "normalized_url"), + LinkType(_enum(LinkType, data.get("link_type"), "link_type")), + _bool(data.get("enabled"), "enabled") if "enabled" in data else True, + _integer(data.get("sort_order"), "sort_order") if include_order else 0, ) + + @staticmethod + def _parse_step(value: object) -> DraftStep: + data = _record(value, "draft step") + return DraftStep( + DraftStepKey(_enum(DraftStepKey, data.get("key"), "step key")), + _string(data.get("status"), "step status"), + _optional_string(data.get("completed_at")), + ) + + @staticmethod + def _parse_prefill(value: object) -> ThronePrefill: + data = _record(value, "throne prefill") + creators = tuple( + ThroneCreator( + _string(_record(row, "creator").get("id"), "creator id"), + _string(_record(row, "creator").get("handle"), "creator handle"), + ) + for row in _list(data.get("owned_creators"), "owned_creators") + ) + return ThronePrefill( + creators, _optional_string(data.get("existing_registration_creator_id")) + ) + + @staticmethod + def _parse_import(value: object) -> LinkImport: + data = _record(value, "link import") + candidates = tuple( + LinkImportCandidate( + _string(_record(row, "import candidate").get("id"), "candidate id"), + _string(_record(row, "import candidate").get("platform"), "platform"), + _string(_record(row, "import candidate").get("public_label"), "public_label"), + _optional_string(_record(row, "import candidate").get("username")), + _string(_record(row, "import candidate").get("normalized_url"), "normalized_url"), + LinkType( + _enum(LinkType, _record(row, "import candidate").get("link_type"), "link_type") + ), + _bool(_record(row, "import candidate").get("selected"), "selected"), + ) + for row in _list(data.get("candidates"), "import candidates") + ) + return LinkImport( + _string(data.get("id"), "import id"), + _string(data.get("draft_id"), "draft_id"), + _string(data.get("source_url"), "source_url"), + _string(data.get("provider"), "provider"), + _string(data.get("status"), "status"), + candidates, + ) + + @staticmethod + def _parse_throne_result(data: dict[str, Any]) -> ThroneDraftResult: + return ThroneDraftResult( + WorkerClient._parse_draft(data.get("draft")), + _optional_string(data.get("webhook_url")), + _string(data.get("webhook_state"), "webhook_state"), + ) + + @staticmethod + def _parse_setup_session(value: object) -> GuildSetupSession: + data = _record(value, "setup session") + return GuildSetupSession( + _string(data.get("id"), "session id"), + _snowflake(data.get("guild_id")), + _snowflake(data.get("initiator_user_id")), + _string(data.get("status"), "status"), + _string(data.get("current_step"), "current_step"), + _nullable_snowflake(data.get("selected_channel_id")), + _integer(data.get("revision"), "revision"), + _nullable_snowflake(data.get("public_message_id")), + _optional_string(data.get("expires_at")), + _optional_string(data.get("created_at")), + _optional_string(data.get("updated_at")), + _optional_string(data.get("completed_at")), + ) + + @staticmethod + def _parse_notification(value: object) -> SendNotification: + data = _record(value, "notification") + return SendNotification( + str(data["notification_id"]), + str(data["lease_token"]), + str(data["send_id"]), + _snowflake(data["guild_id"]), + _snowflake(data["channel_id"]), + _snowflake(data["recipient_user_id"]), + str(data["throne_handle"]), + _integer(data["amount_minor"], "notification amount"), + str(data["currency"]), + _optional_string(data.get("sender_name")), + bool(data.get("is_private")), + bool(data.get("is_anonymous")), + _optional_string(data.get("item_name")), + _optional_string(data.get("item_image_url")), + str(data["purchased_at"]), + bool(data.get("delivery_may_exist")), + ) + + +def _list(value: object, field: str) -> list[object]: + if not isinstance(value, list): + raise WorkerAPIError(f"Worker returned an invalid {field}") + return value + + +def _bool(value: object, field: str) -> bool: + if not isinstance(value, bool): + raise WorkerAPIError(f"Worker returned an invalid {field}") + return value + + +def _nullable_snowflake(value: object) -> str | None: + return None if value is None else _snowflake(value) + + +def _nullable_enum(enum_type: type[StrEnum], value: object, field: str) -> Any: + return None if value is None else _enum(enum_type, value, field) diff --git a/deploy/bill-bot.env.example b/deploy/bill-bot.env.example index 4d175a3..ce44fa1 100644 --- a/deploy/bill-bot.env.example +++ b/deploy/bill-bot.env.example @@ -1,6 +1,7 @@ BILL_DISCORD_TOKEN=replace-with-discord-token BILL_WORKER_BASE_URL=https://usebill.dev BILL_WORKER_API_TOKEN=replace-with-random-worker-token +BILL_HOME_GUILD_ID= BILL_POLL_INTERVAL_SECONDS=5 BILL_NOTIFICATION_BATCH_SIZE=10 BILL_NOTIFICATION_LEASE_SECONDS=60 diff --git a/docs/architecture.md b/docs/architecture.md index bbb0e97..c428416 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,9 +10,10 @@ Discord bot <--leased notifications-- Cloudflare Worker ## Cloudflare Worker -The Worker owns guild configuration, Dom/me registrations, normalized Throne -events, guild send projections, and notification delivery state. Public access -is limited to `/health` and the Throne webhook route. +The Worker owns guild configuration, immutable profile documents, private +drafts, link imports, Throne registrations, normalized Throne events, guild +send projections, and notification delivery state. Public access is limited to +`/health` and the Throne webhook route. Bot routes require `Authorization: Bearer `. The Throne route requires both a per-creator secret in the URL and a current Ed25519 @@ -24,10 +25,11 @@ hash make webhook retries idempotent. ## Discord bot -The bot owns Discord interactions and message delivery only. It configures -guilds and registrations through the Worker API, then polls for leased send -notifications. A stable message nonce and footer marker let a retry reconcile a -post that reached Discord before its acknowledgement reached the Worker. +The bot owns Discord interactions and message delivery only. It renders profile +and setup state returned by the Worker; no wizard state is authoritative in +Python memory. It also polls for leased send notifications. A stable message +nonce and footer marker let a retry reconcile a post that reached Discord +before its acknowledgement reached the Worker. Discord guild, channel, user, and message IDs remain decimal strings across the API and in D1. They are converted to Python integers only when calling Discord. @@ -38,6 +40,10 @@ API and in D1. They are converted to Python integers only when calling Discord. - Duplicate Throne events return success without creating duplicate sends. - Notification leases expire so another bot instance can recover abandoned work. +- Draft and setup mutations compare an expected revision and reject stale + controls without partially updating D1. +- Publication uses one D1 batch and a profile-root version compare-and-swap, so + an older draft cannot overwrite a newer publication. - Permanent channel or permission failures are dead-lettered; transient errors are retried with backoff. - Secrets, raw webhook bodies, and full payloads are never logged. diff --git a/docs/codebase-guide.md b/docs/codebase-guide.md new file mode 100644 index 0000000..6fbec5c --- /dev/null +++ b/docs/codebase-guide.md @@ -0,0 +1,204 @@ +# Codebase guide + +This guide is a reading path for contributors learning how Bill keeps Discord +interaction state, D1 state, and Throne delivery behavior consistent. + +## Suggested reading path + +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. +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, + sparse inheritance, optimistic revisions, and atomic publication. +5. `worker/src/routes/profile*.ts` and `bill/worker_client.py`: the typed + Worker/bot boundary. +6. `bill/cogs/profile.py` and `bill/components/`: Discord command routing, + persistent dynamic controls, and concrete Components V2 renderers. +7. `guildSetupService.ts` and `bill/cogs/setup.py`: the separate public setup + state machine. +8. `webhookThrone.ts`, `aliasAttribution.ts`, and `notifications.py`: signed + ingestion, future-send attribution, leasing, delivery, and reconciliation. + +## Runtime data flow + +```mermaid +flowchart LR + D[Discord interaction] --> B[Python bot] + B -->|bearer JSON| W[Cloudflare Worker] + W -->|prepared statements / batch| DB[(D1)] + T[Throne] -->|signed webhook + route secret| W + W -->|leased notification| B + B -->|Discord message| C[Configured channel] + B -->|ack or nack| W +``` + +The bot is a renderer and Discord authorization boundary. The Worker is the +durable application-state boundary. A process restart may remove Python +objects, but it cannot lose a profile draft or setup session because every +callback reconstructs state from D1. + +## Migrations and immutable documents + +Migration `0001` is preserved exactly because it owns all v1 guild, Throne, +send, and notification data. `0002` adds profile documents, roots, sparse +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. + +A published document is never edited. Starting an edit clones the currently +applicable snapshot into a private draft document. A root points to one +published document and carries a version. Final publication is one D1 batch: + +```mermaid +sequenceDiagram + participant Bot + participant Worker + participant D1 + Bot->>Worker: publish(draft, expected_revision) + Worker->>D1: read draft + validate complete snapshot + Worker->>D1: batch(draft/root CAS, document states, history, registration projection) + D1-->>Worker: changes=1 or no-op/conflict + Worker-->>Bot: resolved profile or precise 409 +``` + +D1 cannot hold an application transaction open across multiple requests. +Mutations therefore use supported batches whose writes share old-revision or +new-state `EXISTS` guards and whose CAS result is checked. Publication also +feeds a guarded scalar into a required history field, turning a stale zero-row +CAS into a constraint failure that rolls the batch back. A zero-row update is +not itself an error, so every batch must either inspect the guard result or use +an equivalent rollback tripwire. + +## Profile wizard state machine + +```mermaid +stateDiagram-v2 + [*] --> Orientation + Orientation --> Identity + Identity --> Links + Links --> Throne: Dom/me or switch + Links --> Review: Submissive + Throne --> Review + Review --> Published: Publish CAS succeeds + Review --> Identity: 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. + +## Global, linked, and independent resolution + +The home guild reads `global_profiles`. Another guild requires a +`server_profiles` root. Independent roots resolve their complete document. +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. + +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. + +## Bot/Worker boundary + +`bill/worker_client.py` owns JSON parsing and converts responses into frozen +dataclasses/enums. Cogs and views should not index arbitrary Worker dictionaries. +HTTP status and Worker error codes remain available so interaction code can +distinguish stale revisions, missing drafts, forbidden callbacks, and validation +errors without turning every failure into a generic message. + +The API token authenticates the bot service. Discord-side callbacks additionally +check the acting user, guild, Manage Server permission where appropriate, +channel type, and Bill's current channel permissions. Never trust a custom ID +alone: it is a routing hint, not authority. + +## Throne, aliases, and delivery + +The profile system reuses the v1 creator and registration projection. Existing +registrations and hash-only route secrets remain compatible. Creator resolution +is idempotent by normalized Throne identity; secret plaintext is returned once +on issue or explicit rotation. Registration projection keeps the established webhook fan-out path unchanged. +Publication and guild-setup completion append this projection to their own D1 +batches. A profile-managed row may be refreshed or deactivated; a conflicting +legacy row produces an explicit publication conflict and remains authoritative +during guild setup. + +Alias attribution happens at webhook time for each recipient guild. Effective +aliases follow the same linked/independent resolver rules as public profiles. +Private and anonymous events bypass attribution, and ambiguous matches produce +no owner. Existing sends are never rewritten. + +Notification leasing remains independent from profile setup. Stable Discord +nonces and footer markers reconcile a post that succeeded before its Worker ack. + +## Tests + +Python: + +```bash +python3 -m compileall -q bill +python3 -m ruff check bill tests +python3 -m pytest -q +``` + +Worker: + +```bash +cd worker +npm ci +npm run check +``` + +Migration tests apply additive SQL over populated fixtures. Contract tests pin +the Python/Worker field names. Importer tests use injected DNS/fetch behavior so +blocked address classes, redirects, timeouts, content types, and size limits are +deterministic. + +## Deployment + +Use one home-guild snowflake in both runtimes. For production, preserve D1 ID +`6333cb0a-0c23-44b2-9022-a6fde1500f77` and `https://usebill.dev`. The live +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. + +See `docs/deployment.md` for the complete host setup and required secrets. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| Global profile is rejected | Both runtimes have the same decimal `BILL_HOME_GUILD_ID`. | +| A button says it is stale | Reload/resume the durable draft; another callback advanced its revision. | +| DM onboarding stops | The member allows DMs from the server; the guild response explains closed-DM recovery. | +| Link import is blocked | Use manual HTTPS entry; static import intentionally rejects unsafe DNS/redirect/content. | +| Setup cannot select a channel | Bill currently has all four required permissions in a text channel. | +| Throne test succeeds but no post appears | Check active registration projection, guild config, notification lease state, and bot channel access. | +| A send has no alias owner | The send is private/anonymous, predates `0003`, has no match, or matches multiple owners. | + +Do not print webhook URLs, API tokens, raw payloads, or private draft contents +while troubleshooting. + +## Safe exercises + +1. Add a new display-only link provider mapping with unit tests; do not weaken + URL validation or importer DNS policy. +2. Add a resolver test showing explicit-empty bio override versus inherited bio. +3. Add a contract parser rejection case for a Discord length limit. +4. Add a setup-session stale-revision test using the existing D1 fixture. +5. Trace one notification lease through ack and reconciliation tests without + changing production retry defaults. diff --git a/docs/deployment.md b/docs/deployment.md index 28d2b6d..572bb9e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -3,36 +3,48 @@ Bill uses a Cloudflare Worker/D1 database and a Python bot on DigitalOcean. Examples contain no production IDs or secrets. -## 1. Create and deploy the Worker +## 1. Prepare the Worker ```bash cd worker npm ci npx wrangler login -npx wrangler d1 create bill ``` -Copy the returned database ID into `wrangler.toml`, then apply the migration: +`worker/wrangler.toml` already contains the production D1 identifier +`6333cb0a-0c23-44b2-9022-a6fde1500f77` and +`PUBLIC_BASE_URL=https://usebill.dev`. Do not create or substitute another +database for the live rollout. -```bash -npx wrangler d1 migrations apply bill --remote -``` - -Set secrets: +Set required Worker deployment values. `BILL_HOME_GUILD_ID` is configuration, +not an application default; enter the real Discord snowflake interactively: ```bash +npx wrangler secret put BILL_HOME_GUILD_ID npx wrangler secret put BILL_BOT_API_TOKEN npx wrangler secret put THRONE_PUBLIC_KEY_PEM ``` -Use a long randomly generated bot API token. Set `THRONE_PUBLIC_KEY_PEM` to -Throne's current Ed25519 public key. Deploy: +## 2. Apply and deploy in order + +Run the checks, then apply every pending additive migration to the existing +database before deploying code that uses the new tables: ```bash +cd worker +npm ci npm run check +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. + +Use a long randomly generated bot API token. Set `THRONE_PUBLIC_KEY_PEM` to +Throne's current Ed25519 public key. + Route `usebill.dev` to the Worker in Cloudflare. Keep `billthebot.xyz` reserved for Bill's future product website; no website is deployed in this milestone. @@ -40,7 +52,7 @@ For local Worker development, copy `worker/.dev.vars.example` to `worker/.dev.vars`, use development-only values, apply migrations locally, and run `npm run dev`. -## 2. Install the Discord bot +## 3. Install the Discord bot On the DigitalOcean host: @@ -54,8 +66,8 @@ sudo install -m 0640 deploy/bill-bot.env.example /etc/bill/bill-bot.env sudo install -m 0644 deploy/bill-bot.service /etc/systemd/system/bill-bot.service ``` -Edit `/etc/bill/bill-bot.env` with the Discord token, Worker URL, and the same -bot API token stored in Wrangler. Then: +Edit `/etc/bill/bill-bot.env` with the Discord token, Worker URL, the same bot +API token stored in Wrangler, and the same real `BILL_HOME_GUILD_ID`. Then: ```bash sudo systemctl daemon-reload @@ -63,13 +75,18 @@ sudo systemctl enable --now bill-bot sudo systemctl status bill-bot ``` -## 3. Configure Discord +## 4. Configure Discord Invite Bill with the `bot` and `applications.commands` scopes. Grant **View Channel**, **Send Messages**, **Embed Links**, and **Read Message History** in the send channel. In each server: -1. Run `/bill setup`. -2. Run `/register domme`. -3. Add the private webhook URL to Throne. -4. Use Throne's webhook test and confirm that a real supported event posts once. +1. A member with **Manage Server** runs `/bill setup` and completes the public + 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. + +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 new file mode 100644 index 0000000..27cdcac --- /dev/null +++ b/docs/profiles.md @@ -0,0 +1,57 @@ +# Profiles + +`/profile [member]` is a global application command that only runs in guild +context. In Bill's configured home guild it resolves the member's global +profile. In every other guild it resolves a published server profile: + +- **linked** profiles inherit the latest global profile and store only explicit + identity overrides, local links, and inherited-link visibility choices; +- **independent** profiles own a complete document for that guild. + +A linked profile never pins or copies a global document. Empty override values +are represented separately from inheritance, so deliberately clearing a bio is +different from inheriting it. The linked wizard can also hide individual +inherited links while retaining the rest. + +## Private setup + +Missing self profiles start with a normal ephemeral guild prompt. After the +member confirms, Bill sends one normal DM introduction and then a direct +Components V2 wizard. Missing profiles for an explicitly selected other 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 are persisted without completing the identity step, +so a restart can reconstruct partial progress. Restart is explicit and +revision-checked. Publication changes the public root only during the final +review action. + +The four orientations are Dom/me, Submissive, Switch leaning Dom/me, and Switch +leaning Submissive. All support pronouns, DM status, an optional 300-character +bio, and social links. Dom/me and switch profiles also support payment links and +Throne. Submissive and switch profiles support up to three aliases and the +public send-stat preference. Switch profiles support both honourifics and +submissive labels. + +## Links and privacy + +Profiles resolve at most twelve enabled links. Labels are at most 40 characters +and URLs at most 500 characters. Public links are HTTPS. Imported Linktree, +AllMyLinks, Beacons, and generic pages are static HTML only; JavaScript is never +executed. The Worker validates DNS and every redirect, blocks private/reserved +destinations, enforces a five-second deadline and 512 KiB body limit, and stores +only normalized candidates rather than raw HTML. + +Public profile cards do not include webhook URLs, route secrets, creator IDs, +or other credentials. Payment and social buttons ask the Worker for the current +guild-resolved profile and open ephemeral link details for the viewer. A Throne +webhook URL appears only to its owner when first issued or explicitly rotated. + +## Send statistics + +Aliases affect future sends only. Private and anonymous sends are never +attributed. A sender is attributed only when their normalized sender +username/display name maps to exactly one effective profile owner in the +recipient guild. Opted-in stats are per guild and grouped by currency; Bill does +not convert currencies. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..54d8be8 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,47 @@ +# Roadmap + +These are design directions, not implemented commands or promises. New +operator-facing behavior should remain disabled until its authorization, +privacy, audit, and rollback model is reviewed. + +## Secure development-only commands + +Future development commands should be registered only in explicitly configured +non-production guilds, require an allowlisted operator plus Discord permission, +and fail closed when environment identity is ambiguous. They must never reveal +webhook secrets, bearer tokens, raw Throne payloads, private drafts, or arbitrary +D1 rows. Production builds should omit registration rather than hide commands +only in UI. + +## Support tickets + +A future support flow may create a minimal ticket containing a random reference, +category, affected guild/user identifiers, timestamps, and user-supplied +description. Private profile values and secrets should be opt-in redacted +attachments, never automatic copies. Tickets need retention limits, explicit +staff roles, access logging, and a deletion path before launch. + +## Diagnostics + +Diagnostics should report bounded health facts: deployment version, migration +level, queue counts, lease age ranges, profile/setup state labels, and +permission checks. They should use typed allowlisted queries and coarse counts +rather than arbitrary SQL or document dumps. Any guild/user-specific diagnostic +must repeat the same authorization checks as the underlying operation. + +## Audit trail + +Profile publications already have immutable history. A broader audit design +could record actor, action, resource type, resource identifier, old/new version, +request correlation ID, and timestamp. It must exclude secrets and large +payloads, define retention, and distinguish user action from automated +projection repair. Audit writes should share the state-changing D1 batch where +the action requires atomic evidence. + +## Feature flags + +Flags should be typed, default off, scoped explicitly (global, guild, or user), +and evaluated in the Worker so bot restarts cannot change authority. Changes +need an actor, reason, expiry, and audit record. Security-sensitive code paths +must not use a client-only flag, and removing a flag should include deleting its +dead branch and tests rather than leaving permanent conditional complexity. diff --git a/docs/send-tracking.md b/docs/send-tracking.md index 186fa3e..18faf9d 100644 --- a/docs/send-tracking.md +++ b/docs/send-tracking.md @@ -5,18 +5,20 @@ An administrator with **Manage Server** runs: ```text -/bill setup send_channel:#sends +/bill setup ``` -Bill checks **View Channel**, **Send Messages**, and **Embed Links** before -saving the channel. +The command posts a public, initiator-only setup flow. Bill checks **View +Channel**, **Send Messages**, **Embed Links**, and **Read Message History** +before saving the selected channel. ## Dom/me setup -In a configured server, the Dom/me runs: +Dom/me and switch members connect Throne privately while creating or editing +their profile: ```text -/register domme throne:your-throne-name +/profile ``` A full `https://throne.com/...` or `https://throne.gifts/...` profile URL also @@ -27,8 +29,8 @@ Webhook URLs use `https://usebill.dev/t//`. Authenticated bot API routes remain under `https://usebill.dev/v1`. The webhook URL contains a secret. Do not post or share it. Bill stores only its -SHA-256 hash. If the URL is exposed, rerun the command with -`reset_webhook:True`; the previous URL stops working. +SHA-256 hash. If the URL is exposed, use the profile editor's explicit rotation +action; the previous URL stops working. The same Discord user can link that Throne creator in more than one server. The existing webhook remains valid and each verified send is posted to every @@ -45,6 +47,8 @@ conversion is not part of this milestone. - Private sends hide the amount and sender. - Anonymous sends keep permitted amount/item details but do not keep the sender identity. +- Non-private, non-anonymous future sends may be attributed when the sender + name unambiguously matches one effective profile alias in that guild. - Repeated delivery of the same Throne event creates no duplicate Discord post. If a channel is deleted or Bill loses access, an administrator should restore diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..9bb3d57 --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,326 @@ +"""Focused regressions for profile contracts and V2 renderers.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any + +import discord +import pytest + +from bill.components.profile import ( + ORIENTATION_LABELS, + _identity_values, + profile_wizard_view, + wizard_custom_id, +) +from bill.components.public_profile import public_profile_view +from bill.components.setup import setup_custom_id, setup_view +from bill.worker_client import ( + CreateLinkImportResult, + DmStatus, + DraftDocument, + DraftScope, + DraftStatus, + DraftStep, + DraftStepKey, + GuildSetupSession, + LinkType, + Orientation, + ProfileDraft, + ProfileLink, + ProfileSelections, + PublicProfile, + ServerProfileMode, + WorkerClient, +) + + +class Response: + def __init__(self, payload: Any) -> None: + self.status = 200 + self.payload = payload + + async def __aenter__(self) -> Response: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def json(self) -> Any: + return self.payload + + +class Session: + def __init__(self, payload: Any) -> None: + self.payload = payload + self.last_kwargs: dict[str, Any] = {} + + def request(self, *_: object, **kwargs: Any) -> Response: + self.last_kwargs = kwargs + return Response(self.payload) + + +def draft(*, next_step: DraftStepKey | None = DraftStepKey.ORIENTATION) -> ProfileDraft: + return ProfileDraft( + id="draft_1", + owner_user_id="1", + origin_guild_id="2", + target_scope=DraftScope.GLOBAL, + guild_id=None, + server_mode=None, + status=DraftStatus.ACTIVE, + revision=3, + base_version=0, + current_step=next_step, + next_step=next_step, + steps=(DraftStep(DraftStepKey.ORIENTATION, "pending", None),), + governing_orientation=None, + document=DraftDocument( + None, + None, + False, + ProfileSelections((), (), ()), + (), + (), + (), + (), + None, + None, + ), + throne_prefill=None, + created_at=None, + updated_at=None, + published_at=None, + ) + + +@pytest.mark.asyncio +async def test_profile_lookup_is_parsed_into_frozen_contracts() -> None: + payload = { + "ok": True, + "data": { + "global_available": True, + "profile": { + "scope": "global", + "mode": None, + "owner_user_id": "1", + "orientation": "domme", + "dm_status": "open", + "bio": "hello", + "public_send_stats": False, + "selections": { + "pronouns": ["She/Her"], + "honourifics": ["Goddess"], + "submissive_labels": [], + }, + "aliases": [], + "links": [ + { + "id": "l", + "platform": "Throne", + "public_label": "Tribute", + "username": "a", + "normalized_url": "https://throne.com/a", + "link_type": "payment", + "sort_order": 0, + } + ], + "preferred_payment_link_id": "l", + "throne_connected": True, + "send_stats": None, + "version": 4, + "published_at": "2026-01-01T00:00:00Z", + }, + }, + } + session = Session(payload) + client = WorkerClient(base_url="https://usebill.dev", api_token="secret", session=session) # type: ignore[arg-type] + + lookup = await client.get_profile(guild_id=2, user_id=1) + + assert lookup.profile is not None + assert lookup.profile.orientation is Orientation.DOMME + assert lookup.profile.links[0].link_type is LinkType.PAYMENT + assert session.last_kwargs["headers"]["Authorization"] == "Bearer secret" + + +@pytest.mark.asyncio +async def test_link_import_creation_uses_the_mutation_revision( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = { + "ok": True, + "data": { + "import": { + "id": "import_1", + "draft_id": "draft_1", + "source_url": "https://example.com", + "provider": "generic", + "status": "ready", + "candidates": [], + }, + "draft": {"id": "draft_1", "revision": 4}, + }, + } + client = WorkerClient( + base_url="https://usebill.dev", + api_token="secret", + session=Session(payload), # type: ignore[arg-type] + ) + updated = replace(draft(), revision=4) + + async def get_updated_draft(*_: object, **__: object) -> ProfileDraft: + return updated + + monkeypatch.setattr(client, "get_draft", get_updated_draft) + + result = await client.create_link_import( + "draft_1", + owner_user_id=1, + expected_revision=3, + source_url="https://example.com", + ) + + assert isinstance(result, CreateLinkImportResult) + assert result.draft.revision == 4 + + +def test_orientation_wizard_uses_v2_container_and_all_four_options() -> None: + view = profile_wizard_view(draft()) + + assert isinstance(view, discord.ui.LayoutView) + assert len(ORIENTATION_LABELS) == 4 + encoded = str(view.to_components()) + assert "bill:p:rdraft_1:1:2:3:orientation" in encoded + + +def test_public_profile_escapes_bio_and_exposes_only_safe_link_controls() -> None: + profile = PublicProfile( + DraftScope.GLOBAL, + None, + "1", + Orientation.DOMME, + DmStatus.OPEN, + "@everyone **not markup**", + False, + ProfileSelections(("She/Her",), (), ()), + (), + (ProfileLink("link", "Throne", "Tribute", None, "https://throne.com/a", LinkType.PAYMENT),), + "link", + True, + None, + 1, + None, + ) + + view = public_profile_view(profile, guild_id=2, owner_view=True) + + encoded = str(view.to_components()) + assert "Payment Links" in encoded + assert "Edit" in encoded + assert "@everyone" not in encoded + + +def test_setup_view_collapses_completed_channel() -> None: + session = GuildSetupSession( + "setup", "2", "1", "active", "confirm", "3", 4, None, None, None, None, None + ) + + assert "Confirm setup" in str(setup_view(session).to_components()) + + +@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(draft(), governing_orientation=orientation), + "She/Her", + ",".join(honourifics), + ",".join(labels), + ",".join(aliases), + "open | 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 = replace( + draft(), + target_scope=DraftScope.SERVER, + guild_id="2", + server_mode=ServerProfileMode.LINKED, + governing_orientation=Orientation.SWITCH_DOMME, + ) + + values = _identity_values(linked, "", "", "", "", "inherit | inherit | ") + + assert values["overrides"] == [] + assert values["dm_status"] is None + assert values["bio"] is None + + +def test_setup_custom_id_binds_initiator_guild_and_revision() -> None: + session = GuildSetupSession( + "setup", + "2", + "1", + "active", + "select_channel", + None, + 4, + None, + None, + None, + None, + None, + ) + + custom_id = setup_custom_id(session, "channel") + + assert custom_id == "bill:s:rsetup:1:2:4:channel" + assert len(custom_id) <= 100 + + +def test_realistic_persistent_ids_fit_discord_limit() -> None: + realistic = replace( + draft(), + id="ffffffff-ffff-ffff-ffff-ffffffffffff", + owner_user_id="9999999999999999999", + origin_guild_id="9999999999999999999", + revision=2_147_483_647, + ) + setup = GuildSetupSession( + "ffffffff-ffff-ffff-ffff-ffffffffffff", + "9999999999999999999", + "9999999999999999999", + "active", + "confirm", + "9999999999999999999", + 2_147_483_647, + None, + None, + None, + None, + None, + ) + + assert len(wizard_custom_id(realistic, "complete-links")) <= 100 + assert len(setup_custom_id(setup, "complete")) <= 100 diff --git a/tests/test_registration.py b/tests/test_registration.py deleted file mode 100644 index 469b509..0000000 --- a/tests/test_registration.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from bill.cogs.registration import registration_embed -from bill.worker_client import DommeRegistration - - -def test_new_registration_explains_webhook_setup() -> None: - embed = registration_embed( - DommeRegistration( - creator_id="creator-id", - throne_handle="alice", - webhook_url="https://usebill.dev/t/creator-id/secret", - webhook_state="issued", - ) - ) - - assert "Add Bill" in (embed.title or "") - assert "Keep this URL private" in (embed.description or "") - assert "creator-id/secret" in (embed.description or "") - - -def test_existing_registration_does_not_request_webhook_change() -> None: - embed = registration_embed( - DommeRegistration( - creator_id="creator-id", - throne_handle="alice", - webhook_url=None, - webhook_state="existing", - ) - ) - - assert "nothing to change" in (embed.description or "") diff --git a/tests/test_settings.py b/tests/test_settings.py index 99ff1bf..b689b8e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,6 +8,7 @@ "BILL_DISCORD_TOKEN": "discord-secret", "BILL_WORKER_BASE_URL": "https://usebill.dev/", "BILL_WORKER_API_TOKEN": "worker-secret", + "BILL_HOME_GUILD_ID": "123456789012345678", } @@ -23,6 +24,16 @@ def test_settings_loads_bill_environment() -> None: assert settings.worker_base_url == "https://usebill.dev" assert settings.poll_interval_seconds == 7 assert settings.test_guild_id == 123456789012345678 + assert settings.home_guild_id == 123456789012345678 + + +def test_settings_requires_valid_home_guild() -> None: + missing_home = {key: value for key, value in BASE_ENV.items() if key != "BILL_HOME_GUILD_ID"} + with pytest.raises(SettingsError, match="BILL_HOME_GUILD_ID is required"): + Settings.from_env(missing_home) + + with pytest.raises(SettingsError, match="Discord snowflake"): + Settings.from_env({**BASE_ENV, "BILL_HOME_GUILD_ID": "not-a-snowflake"}) def test_settings_rejects_insecure_remote_worker() -> None: @@ -31,8 +42,6 @@ def test_settings_rejects_insecure_remote_worker() -> None: def test_settings_allows_local_http_worker() -> None: - settings = Settings.from_env( - {**BASE_ENV, "BILL_WORKER_BASE_URL": "http://127.0.0.1:8787"} - ) + settings = Settings.from_env({**BASE_ENV, "BILL_WORKER_BASE_URL": "http://127.0.0.1:8787"}) assert settings.worker_base_url == "http://127.0.0.1:8787" diff --git a/worker/.dev.vars.example b/worker/.dev.vars.example index 9c04811..b501c7f 100644 --- a/worker/.dev.vars.example +++ b/worker/.dev.vars.example @@ -6,3 +6,6 @@ BILL_BOT_API_TOKEN=dev-local-bot-token-change-me # Throne's current Ed25519 webhook signing public key, PEM-encoded (SPKI). THRONE_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n-----END PUBLIC KEY-----" + +# Required Discord snowflake for the one guild where global profiles apply. +BILL_HOME_GUILD_ID= diff --git a/worker/migrations/0002_profile_system.sql b/worker/migrations/0002_profile_system.sql new file mode 100644 index 0000000..6ca6c11 --- /dev/null +++ b/worker/migrations/0002_profile_system.sql @@ -0,0 +1,216 @@ +-- Bill profile system: private, versioned identity documents plus the +-- global/server "roots" that point at the currently published one. +-- +-- This migration is strictly additive: it only creates new tables/indexes +-- and never touches 0001's `guilds`, Throne, `sends`, or `notifications` +-- tables. It must be safe to apply on top of an already-populated 0001 +-- database without altering or losing a single existing row. +-- +-- Immutable document model +-- ------------------------ +-- `profile_documents` rows are never edited in place once `state` leaves +-- `draft`. Publishing flips a document's `state` from `draft` to +-- `published`; a later edit clones the currently-published document into a +-- brand new `draft` row, and republishing flips the *previous* published +-- document to `superseded`. This gives every user a durable, reviewable +-- publication history and means nothing ever reads a half-written document: +-- a document is either still being drafted (and only visible to its owner) +-- or fully complete and public. +PRAGMA foreign_keys = ON; + +CREATE TABLE profile_documents ( + 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)), + -- Nullable, owner-only association; no FK to keep 0002 self-contained + -- against 0001's `throne_creators` table without reordering either + -- migration. Ownership of the referenced creator is re-checked in the + -- Worker whenever this field is written or read. + throne_creator_id TEXT, + -- References a row in `profile_links` for this same document (or, for a + -- linked server overlay, a currently-visible inherited global link); also + -- left unenforced by FK since the preferred link may live on a different + -- document than the one being resolved (see profile_link_visibility). + preferred_payment_link_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_profile_documents_owner ON profile_documents (owner_user_id); +CREATE INDEX idx_profile_documents_owner_state ON profile_documents (owner_user_id, state); + +-- Pronoun / honourific / submissive-label multi-selects. `category` +-- distinguishes the fixed value sets; the exact allowed `value` strings are +-- enforced by the Worker's typed contracts, not by a CHECK here, so new +-- values within an existing category never require a schema migration. +CREATE TABLE profile_document_selections ( + document_id TEXT NOT NULL REFERENCES profile_documents (id) ON DELETE CASCADE, + category TEXT NOT NULL CHECK (category IN ('pronoun', 'honourific', 'submissive_label')), + value TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (document_id, category, value) +); + +CREATE INDEX idx_profile_document_selections_document ON profile_document_selections (document_id); + +-- At most three per document; enforced by the Worker at write time (a +-- CHECK/trigger cannot count sibling rows in SQLite). +CREATE TABLE profile_aliases ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES profile_documents (id) ON DELETE CASCADE, + display_alias TEXT NOT NULL, + normalized_alias TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (document_id, normalized_alias) +); + +CREATE INDEX idx_profile_aliases_document ON profile_aliases (document_id); + +-- At most twelve per document; social vs payment classification drives +-- which links a viewer's "Socials"/"Payment Links" controls surface. +CREATE TABLE profile_links ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES profile_documents (id) ON DELETE CASCADE, + platform TEXT NOT NULL, + public_label TEXT NOT NULL, + username TEXT, + normalized_url TEXT NOT NULL, + link_type TEXT NOT NULL CHECK (link_type IN ('social', 'payment')), + sort_order INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_profile_links_document ON profile_links (document_id); + +-- Presence of a (document_id, field_name) row on a *linked overlay* +-- document means that field was deliberately set (even to an explicit empty +-- value) and must win over global inheritance during resolution. Absence +-- means "inherit the global document's value for this field" -- this is +-- why overrides are tracked separately from the field values themselves. +CREATE TABLE profile_document_overrides ( + document_id TEXT NOT NULL REFERENCES profile_documents (id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + PRIMARY KEY (document_id, field_name) +); + +-- Lets a linked server overlay hide a specific inherited global link +-- without copying/mutating the global document. Absence of a row means the +-- inherited link is visible (the default); a row with visible=0 hides it. +CREATE TABLE profile_link_visibility ( + document_id TEXT NOT NULL REFERENCES profile_documents (id) ON DELETE CASCADE, + inherited_link_id TEXT NOT NULL REFERENCES profile_links (id) ON DELETE CASCADE, + visible INTEGER NOT NULL DEFAULT 1 CHECK (visible IN (0, 1)), + PRIMARY KEY (document_id, inherited_link_id) +); + +-- One row per user: the "root" pointer for their global identity. Publish +-- bumps `version` and repoints `current_document_id`; see the Worker's +-- publish path for how this row doubles as the optimistic-concurrency guard. +CREATE TABLE global_profiles ( + owner_user_id TEXT PRIMARY KEY, + current_document_id TEXT NOT NULL REFERENCES profile_documents (id), + version INTEGER NOT NULL DEFAULT 1, + published_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- One row per (guild, user): the "root" pointer for a server-scoped +-- profile. `mode = 'linked'` documents are sparse overlays resolved against +-- the live global document at read time; `mode = 'independent'` documents +-- are complete and resolved on their own. +CREATE TABLE server_profiles ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('linked', 'independent')), + current_document_id TEXT NOT NULL REFERENCES profile_documents (id), + version INTEGER NOT NULL DEFAULT 1, + published_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (guild_id, owner_user_id) +); + +CREATE INDEX idx_server_profiles_guild ON server_profiles (guild_id); +CREATE INDEX idx_server_profiles_owner ON server_profiles (owner_user_id); + +-- Immutable publish history, one row per successful publication. Never +-- updated or deleted; used for audit/debugging and future rollback tooling. +CREATE TABLE profile_publications ( + id TEXT PRIMARY KEY, + profile_kind TEXT NOT NULL CHECK (profile_kind IN ('global', 'server')), + owner_user_id TEXT NOT NULL, + guild_id TEXT, + version INTEGER NOT NULL, + document_id TEXT NOT NULL REFERENCES profile_documents (id), + published_at TEXT NOT NULL +); + +CREATE INDEX idx_profile_publications_lookup + ON profile_publications (profile_kind, owner_user_id, guild_id, version); + +-- A document is published exactly once in its lifetime (draft -> published +-- -> eventually superseded); this uniqueness constraint is also the safety +-- net that turns two truly concurrent publish attempts for the same draft +-- into a hard, atomic failure for the loser (a real constraint violation +-- aborts its whole batch) instead of a silently-duplicated history row. +CREATE UNIQUE INDEX idx_profile_publications_document + ON profile_publications (document_id); + +-- Worker-owned wizard state. `base_version` records the root `version` this +-- draft was started/last-restarted against (0 when there is no published +-- document yet), so publish can detect that the root moved underneath the +-- draft even if the draft's own `revision` looks fine. `revision` is bumped +-- on every mutation and must be echoed back by the caller as +-- `expected_revision`; a mismatch means someone/something else already +-- mutated the draft and the caller is working from stale state. +CREATE TABLE profile_drafts ( + 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 +); + +-- Exactly one active global draft per user... +CREATE UNIQUE INDEX idx_profile_drafts_active_global + ON profile_drafts (owner_user_id) + WHERE target_scope = 'global' AND status = 'active'; + +-- ...and exactly one active server draft per guild/user. Both are partial +-- unique indexes (SQLite/D1-supported) rather than a table-wide uniqueness +-- constraint, because completed/published drafts are kept for history and +-- must not block starting a new one. +CREATE UNIQUE INDEX idx_profile_drafts_active_server + ON profile_drafts (guild_id, owner_user_id) + WHERE target_scope = 'server' AND status = 'active'; + +CREATE INDEX idx_profile_drafts_owner ON profile_drafts (owner_user_id); + +-- Collapsed/completed wizard step tracker, keyed by the fixed step_key +-- sequence in the Worker's contracts module. +CREATE TABLE profile_draft_steps ( + draft_id TEXT NOT NULL REFERENCES profile_drafts (id) ON DELETE CASCADE, + step_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed')), + completed_at TEXT, + PRIMARY KEY (draft_id, step_key) +); diff --git a/worker/migrations/0003_links_and_setup.sql b/worker/migrations/0003_links_and_setup.sql new file mode 100644 index 0000000..1534d65 --- /dev/null +++ b/worker/migrations/0003_links_and_setup.sql @@ -0,0 +1,93 @@ +-- Bill link management/importer, guild setup wizard, and send attribution. +-- +-- Strictly additive over 0001 and 0002: only new tables/indexes plus one +-- nullable column added to the existing `sends` table via `ALTER TABLE ... +-- ADD COLUMN` (SQLite/D1 rewrites no existing row data for this). Nothing +-- in 0001 or 0002 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; + +-- Attribution: which Discord user (if any) sent the tribute behind a given +-- `sends` row, resolved via the recipient guild's *effective* alias set at +-- webhook-processing time (see `aliasAttribution.ts`). Nullable because +-- private/anonymous events and unmatched/ambiguous senders are never +-- attributed, and because every row inserted before this migration ran has +-- no attribution data at all -- existing sends are never retroactively +-- attributed, only sends recorded from here on can carry this column. +ALTER TABLE sends ADD COLUMN sender_discord_user_id TEXT; + +CREATE INDEX idx_sends_guild_sender ON sends (guild_id, sender_discord_user_id); + +-- Distinguishes registrations materialized by profile publication from v1 +-- registrations created explicitly through the legacy API. Profile-managed +-- rows can be deactivated when a profile disconnects Throne; legacy rows must +-- remain untouched for rollout compatibility. +ALTER TABLE domme_registrations + ADD COLUMN profile_managed INTEGER NOT NULL DEFAULT 0 + CHECK (profile_managed IN (0, 1)); + +CREATE INDEX idx_domme_registrations_profile_managed + ON domme_registrations (guild_id, discord_user_id, profile_managed); + +-- One link-page import attempt for a draft's links step: `source_url` is +-- the page the caller asked to import, `provider` records which adapter +-- handled it ("linktree"/"allmylinks"/"beacons"/"generic"), and `status` +-- records the SSRF-defended fetch's outcome so the wizard can explain a +-- failure (and fall back to safe manual entry) without re-fetching. +CREATE TABLE profile_link_imports ( + id TEXT PRIMARY KEY, + draft_id TEXT NOT NULL REFERENCES profile_drafts (id) ON DELETE CASCADE, + source_url TEXT NOT NULL, + provider TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('ready', 'no_links_found', 'fetch_failed', 'blocked')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_profile_link_imports_draft ON profile_link_imports (draft_id); + +-- Candidates extracted from one import, offered to the caller for +-- selection before anything is copied into `profile_links`. This table +-- never holds raw HTML -- only the already-normalized candidate fields -- +-- and rows are deleted once their import is confirmed or superseded. +CREATE TABLE profile_link_import_candidates ( + id TEXT PRIMARY KEY, + import_id TEXT NOT NULL REFERENCES profile_link_imports (id) ON DELETE CASCADE, + platform TEXT NOT NULL, + public_label TEXT NOT NULL, + username TEXT, + normalized_url TEXT NOT NULL, + link_type TEXT NOT NULL CHECK (link_type IN ('social', 'payment')), + sort_order INTEGER NOT NULL DEFAULT 0, + selected INTEGER NOT NULL DEFAULT 1 CHECK (selected IN (0, 1)) +); + +CREATE INDEX idx_profile_link_import_candidates_import ON profile_link_import_candidates (import_id); + +-- Persistent, revision-checked session backing the public `/bill setup` +-- wizard -- the same optimistic-concurrency shape as `profile_drafts`, just +-- for guild-wide (not per-user) configuration state. +CREATE TABLE guild_setup_sessions ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + initiator_user_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'completed', 'cancelled', 'expired')), + current_step TEXT NOT NULL DEFAULT 'channel' CHECK (current_step IN ('channel', 'confirm')), + selected_channel_id TEXT, + revision INTEGER NOT NULL DEFAULT 0, + public_message_id TEXT, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT +); + +CREATE INDEX idx_guild_setup_sessions_guild ON guild_setup_sessions (guild_id); + +-- One active session per guild at a time (mirrors profile_drafts' partial +-- unique index); completed/cancelled/expired sessions are kept for history +-- and never block starting a new one. +CREATE UNIQUE INDEX idx_guild_setup_sessions_guild_active + ON guild_setup_sessions (guild_id) + WHERE status = 'active'; diff --git a/worker/src/env.ts b/worker/src/env.ts index c4a8779..1c168d3 100644 --- a/worker/src/env.ts +++ b/worker/src/env.ts @@ -1,3 +1,5 @@ +import { isSnowflake } from "./util/snowflake.js"; + /** Cloudflare Worker bindings for Bill. */ export interface Env { readonly DB: D1Database; @@ -10,6 +12,14 @@ export interface Env { /** Public base URL used to build webhook URLs returned to the bot, e.g. https://usebill.dev */ readonly PUBLIC_BASE_URL: string; + /** + * Required: the Discord snowflake of Bill's home guild. Global profiles + * are only readable/writable while acting in this guild (everywhere else + * only server-scoped profiles apply); the bot enforces the same + * restriction so both sides agree on what "home" means. + */ + readonly BILL_HOME_GUILD_ID: string; + /** Optional comma-separated list of Throne usernames treated as test senders. */ readonly THRONE_TEST_GIFTER_USERNAMES?: string; @@ -34,6 +44,28 @@ export interface ResolvedConfig { testGifterUsernames: ReadonlySet; } +/** Thrown when `BILL_HOME_GUILD_ID` is missing/malformed at request time. */ +export class HomeGuildNotConfiguredError extends Error { + constructor() { + super("BILL_HOME_GUILD_ID is not configured with a valid Discord snowflake"); + } +} + +/** + * `BILL_HOME_GUILD_ID` is a required setting, but Worker env vars are plain + * strings at runtime with no framework-level enforcement of "required" -- + * an empty/missing/malformed value would otherwise silently coerce to `""` + * and could wrongly match an unset `guildId`. Every code path that needs + * home-guild identity must go through this guard instead of reading + * `env.BILL_HOME_GUILD_ID` directly. + */ +export function requireHomeGuildId(env: Env): string { + if (!isSnowflake(env.BILL_HOME_GUILD_ID)) { + throw new HomeGuildNotConfiguredError(); + } + return env.BILL_HOME_GUILD_ID; +} + export function resolveConfig(env: Env): ResolvedConfig { const testGifterUsernames = new Set( (env.THRONE_TEST_GIFTER_USERNAMES ?? "") diff --git a/worker/src/index.ts b/worker/src/index.ts index 697bfdc..541bb29 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -6,6 +6,23 @@ import { handleHealth } from "./routes/health.js"; import { handleThroneWebhook } from "./routes/webhookThrone.js"; import { handleGetGuildConfig, handlePutGuildConfig } from "./routes/guildConfig.js"; import { handleRegisterDomme } from "./routes/registration.js"; +import { handleGetProfile } from "./routes/profiles.js"; +import { + handleGetDraft, + handlePublishDraft, + handlePutDraftStep, + handleRestartDraft, + 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 { + handleCompleteGuildSetupSession, + handleCreateGuildSetupSession, + handleGetGuildSetupSession, + handleSetGuildSetupChannel, +} from "./routes/guildSetupSessions.js"; import { handleAckNotification, handleLeaseNotifications, @@ -34,6 +51,23 @@ router.post("/t/:creatorId/:routeSecret", handleThroneWebhook); router.get("/v1/guilds/:guildId/config", withAuth(handleGetGuildConfig)); router.put("/v1/guilds/:guildId/config", withAuth(handlePutGuildConfig)); router.post("/v1/guilds/:guildId/registrations/domme", withAuth(handleRegisterDomme)); +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.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)); +router.post("/v1/profile-drafts/:draftId/link-imports/:importId/confirm", withAuth(handleConfirmLinkImport)); +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", withAuth(handleAttachDraftThrone)); +router.post("/v1/profile-drafts/:draftId/throne/rotate", withAuth(handleRotateDraftThrone)); +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)); +router.post("/v1/guild-setup-sessions/:sessionId/complete", withAuth(handleCompleteGuildSetupSession)); router.post("/v1/notifications/lease", withAuth(handleLeaseNotifications)); router.post("/v1/notifications/:id/ack", withAuth(handleAckNotification)); router.post("/v1/notifications/:id/nack", withAuth(handleNackNotification)); diff --git a/worker/src/profile/aliasAttribution.ts b/worker/src/profile/aliasAttribution.ts new file mode 100644 index 0000000..af7c895 --- /dev/null +++ b/worker/src/profile/aliasAttribution.ts @@ -0,0 +1,126 @@ +/** + * Webhook-time alias attribution: resolves a Throne gift's sender + * name(s) to at most one Discord user in the *recipient guild's* effective + * alias set, so future sends can be linked back to a submissive/switch + * profile's own stats without ever asking a sender to prove who they are. + * + * "Effective" mirrors exactly what the public resolver shows a viewer in + * that guild: the home guild's global profiles, or per-guild independent + * profiles and linked overlays (which use their own aliases only when the + * `aliases` field is explicitly overridden, otherwise the live global + * document's). Multiple different owners can legitimately pick the same + * alias text (aliases are only unique *within* one document), so this + * tracks every owner a normalized alias maps to and only attributes when + * the sender's name(s) match exactly one owner across the whole guild -- + * an ambiguous match is treated the same as no match at all, favoring + * silence over a wrong attribution. + */ +import type { Env } from "../env.js"; +import { requireHomeGuildId } from "../env.js"; +import { normalizeAlias } from "./contracts.js"; + +interface AliasOwnerRow { + normalized_alias: string; + owner_user_id: string; +} + +async function loadHomeGuildAliasOwners(env: Env): Promise { + const { results } = await env.DB.prepare( + `SELECT a.normalized_alias, gp.owner_user_id + FROM global_profiles gp + JOIN profile_aliases a ON a.document_id = gp.current_document_id`, + ).all(); + return results; +} + +interface LinkedRootRow { + owner_user_id: string; + overlay_document_id: string; + global_document_id: string; +} + +async function loadServerGuildAliasOwners(env: Env, guildId: string): Promise { + const independent = await env.DB.prepare( + `SELECT a.normalized_alias, sp.owner_user_id + FROM server_profiles sp + JOIN profile_aliases a ON a.document_id = sp.current_document_id + WHERE sp.guild_id = ? AND sp.mode = 'independent'`, + ) + .bind(guildId) + .all(); + + const linkedRoots = await env.DB.prepare( + `SELECT sp.owner_user_id AS owner_user_id, + sp.current_document_id AS overlay_document_id, + gp.current_document_id AS global_document_id + FROM server_profiles sp + JOIN global_profiles gp ON gp.owner_user_id = sp.owner_user_id + WHERE sp.guild_id = ? AND sp.mode = 'linked'`, + ) + .bind(guildId) + .all(); + + const rows: AliasOwnerRow[] = [...independent.results]; + for (const root of linkedRoots.results) { + // Same rule the public resolver applies: the overlay's own aliases win only when explicitly + // overridden, otherwise the live global document's aliases apply -- read fresh, never copied. + const overridden = await env.DB.prepare( + "SELECT 1 FROM profile_document_overrides WHERE document_id = ? AND field_name = 'aliases'", + ) + .bind(root.overlay_document_id) + .first(); + const sourceDocumentId = overridden !== null ? root.overlay_document_id : root.global_document_id; + const { results: aliasRows } = await env.DB.prepare( + "SELECT normalized_alias FROM profile_aliases WHERE document_id = ?", + ) + .bind(sourceDocumentId) + .all<{ normalized_alias: string }>(); + for (const aliasRow of aliasRows) { + rows.push({ normalized_alias: aliasRow.normalized_alias, owner_user_id: root.owner_user_id }); + } + } + return rows; +} + +/** Builds "normalized alias -> owning Discord user id(s)" for every profile effectively visible + * in `guildId`, tracking every owner per alias (not just the first) so ambiguity can be detected. */ +async function buildEffectiveAliasIndex(env: Env, guildId: string): Promise>> { + const homeGuildId = requireHomeGuildId(env); + const rows = guildId === homeGuildId ? await loadHomeGuildAliasOwners(env) : await loadServerGuildAliasOwners(env, guildId); + + const index = new Map>(); + for (const row of rows) { + const owners = index.get(row.normalized_alias) ?? new Set(); + owners.add(row.owner_user_id); + index.set(row.normalized_alias, owners); + } + return index; +} + +/** + * Resolves the Discord user a Throne gift's sender name(s) unambiguously + * match against `guildId`'s effective aliases, or `null` if there is no + * match or the match is ambiguous. Callers must not invoke this for + * private/anonymous events -- by the time a webhook payload reaches this + * point, the Throne event parser has already nulled both sender fields for + * those, so there is nothing here to match against and the caller's usual + * "both fields null" guard keeps this from ever running for them. + */ +export async function resolveSenderDiscordUserId( + env: Env, + guildId: string, + rawSenderUsername: string | null, + rawSenderDisplayName: string | null, +): Promise { + const candidates = [rawSenderUsername, rawSenderDisplayName] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map(normalizeAlias); + if (candidates.length === 0) return null; + + const index = await buildEffectiveAliasIndex(env, guildId); + const matchedOwners = new Set(); + for (const candidate of candidates) { + for (const owner of index.get(candidate) ?? []) matchedOwners.add(owner); + } + return matchedOwners.size === 1 ? [...matchedOwners][0]! : null; +} diff --git a/worker/src/profile/contracts.ts b/worker/src/profile/contracts.ts new file mode 100644 index 0000000..e0c969e --- /dev/null +++ b/worker/src/profile/contracts.ts @@ -0,0 +1,586 @@ +/** + * Typed contracts and validation for Bill's profile system. + * + * Everything a caller can send into a draft step, and everything the + * resolver hands back out, is validated/shaped in this module so that the + * route handlers never juggle raw `unknown` JSON bodies themselves. This + * keeps the fixed vocabularies (orientations, pronouns, honourifics, DM + * status) and the size/format limits in exactly one place. + */ + +export const ORIENTATIONS = ["domme", "submissive", "switch_domme", "switch_submissive"] as const; +export type Orientation = (typeof ORIENTATIONS)[number]; + +export const DM_STATUSES = ["open", "by_request", "after_tribute", "closed"] as const; +export type DmStatus = (typeof DM_STATUSES)[number]; + +export const PRONOUNS = [ + "She/Her", + "He/Him", + "They/Them", + "It/Its", + "She/They", + "He/They", + "Any Pronouns", + "Ask Me", +] as const; + +export const HONOURIFICS = [ + "Goddess", + "Mistress", + "Princess", + "Temptress", + "Enchantress", + "Mommy", + "Master", + "Daddy", + "CashMaster", +] as const; + +export const SUBMISSIVE_LABELS = [ + "Submissive", + "Sub", + "Brat", + "Pet", + "Good boy", + "Good girl", + "Good pet", + "Toy", +] as const; + +export const LIMITS = { + bioMaxChars: 300, + aliasMaxChars: 64, + aliasMaxCount: 3, + linkLabelMaxChars: 40, + linkUrlMaxChars: 500, + linkMaxCount: 12, +} as const; + +/** + * Per-orientation feature capabilities. Pronouns are available to every + * orientation; everything else is gated so the wizard (and this Worker's + * validation) never accepts fields that orientation is not entitled to. + * Only "switch" orientations get both label collections at once; only + * orientations that can receive tribute get Throne/payment; only + * orientations whose sends get attributed get aliases/stats. + */ +export interface OrientationCapabilities { + readonly honourifics: boolean; + readonly submissiveLabels: boolean; + readonly aliases: boolean; + readonly stats: boolean; + readonly throne: boolean; + readonly payment: boolean; +} + +export const ORIENTATION_CAPABILITIES: Readonly> = { + domme: { + honourifics: true, + submissiveLabels: false, + aliases: false, + stats: false, + throne: true, + payment: true, + }, + submissive: { + honourifics: false, + submissiveLabels: true, + aliases: true, + stats: true, + throne: false, + payment: false, + }, + switch_domme: { + honourifics: true, + submissiveLabels: true, + aliases: true, + stats: true, + throne: true, + payment: true, + }, + switch_submissive: { + honourifics: true, + submissiveLabels: true, + aliases: true, + stats: true, + throne: true, + payment: true, + }, +}; + +export const STEP_KEYS = ["orientation", "identity", "links", "throne", "review"] as const; +export type StepKey = (typeof STEP_KEYS)[number]; + +/** The step sequence a draft must complete, given its (possibly still-unset) orientation. */ +export function stepsForOrientation(orientation: Orientation | null): readonly StepKey[] { + if (orientation === null) return ["orientation"]; + const caps = ORIENTATION_CAPABILITIES[orientation]; + return STEP_KEYS.filter((step) => step !== "throne" || caps.throne); +} + +/** + * Fields a `linked` server overlay may deliberately override. Orientation + * and Throne ownership are excluded on purpose: they gate capabilities and + * webhook ownership and are always inherited from the owner's global + * identity, never chosen per-guild. + */ +export const OVERRIDABLE_FIELDS = [ + "pronouns", + "honourifics", + "submissive_labels", + "dm_status", + "bio", + "public_send_stats", + "aliases", +] as const; +export type OverridableField = (typeof OVERRIDABLE_FIELDS)[number]; + +/** + * The step sequence for a draft, accounting for target scope/mode. + * + * A `linked` server draft never has its own `orientation` or `throne` + * steps: both are read live from the owner's global document (see the + * resolver), so there is nothing to choose. Its `identity`/`links` steps + * instead ask which fields to override for this guild, not what the values + * should be from scratch. + */ +export function stepsForDraft(targetScope: TargetScope, serverMode: ServerMode | null, orientation: Orientation | null): readonly StepKey[] { + if (targetScope === "server" && serverMode === "linked") { + return ["identity", "links", "review"]; + } + return stepsForOrientation(orientation); +} + +export class ValidationError extends Error { + readonly code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +function fail(code: string, message: string): never { + throw new ValidationError(code, message); +} + +export function isOrientation(value: unknown): value is Orientation { + return typeof value === "string" && (ORIENTATIONS as readonly string[]).includes(value); +} + +export function isDmStatus(value: unknown): value is DmStatus { + return typeof value === "string" && (DM_STATUSES as readonly string[]).includes(value); +} + +/** Validates a step's target scope/mode combination up front (used by draft start). */ +export type TargetScope = "global" | "server"; +export type ServerMode = "linked" | "independent"; + +export interface OrientationStepInput { + readonly orientation: Orientation; +} + +export function parseOrientationStep(body: unknown): OrientationStepInput { + const record = asRecord(body, "orientation step body"); + if (!isOrientation(record.orientation)) { + fail("invalid_orientation", `orientation must be one of: ${ORIENTATIONS.join(", ")}`); + } + return { orientation: record.orientation }; +} + +export interface IdentityStepInput { + readonly pronouns: string[]; + readonly honourifics: string[]; + readonly submissiveLabels: string[]; + readonly dmStatus: DmStatus; + readonly bio: string | null; + readonly publicSendStats: boolean; + readonly aliases: string[]; +} + +/** + * Validates the identity step against the draft's already-chosen + * orientation so a domme profile can never smuggle in submissive labels + * (and vice versa), and so aliases/stats are only accepted where the + * orientation supports them. + */ +export function parseIdentityStep(body: unknown, orientation: Orientation): IdentityStepInput { + const record = asRecord(body, "identity step body"); + const caps = ORIENTATION_CAPABILITIES[orientation]; + + const pronouns = parseFixedMultiSelect(record.pronouns, PRONOUNS, "pronouns"); + + const honourifics = caps.honourifics + ? parseFixedMultiSelect(record.honourifics, HONOURIFICS, "honourifics") + : (requireEmptyOrAbsent(record.honourifics, "honourifics", orientation), []); + + const submissiveLabels = caps.submissiveLabels + ? parseFixedMultiSelect(record.submissive_labels, SUBMISSIVE_LABELS, "submissive_labels") + : (requireEmptyOrAbsent(record.submissive_labels, "submissive_labels", orientation), []); + + if (!isDmStatus(record.dm_status)) { + fail("invalid_dm_status", `dm_status must be one of: ${DM_STATUSES.join(", ")}`); + } + + const bio = parseOptionalBio(record.bio); + + const publicSendStats = caps.stats + ? parseBooleanWithDefault(record.public_send_stats, "public_send_stats", false) + : (requireEmptyOrAbsent(record.public_send_stats, "public_send_stats", orientation), false); + + const aliases = caps.aliases + ? parseAliases(record.aliases) + : (requireEmptyOrAbsent(record.aliases, "aliases", orientation), []); + + return { + pronouns, + honourifics, + submissiveLabels, + dmStatus: record.dm_status, + bio, + publicSendStats, + aliases, + }; +} + +export interface LinkStepInputLink { + /** Echoed back from a prior read to keep an existing link's id stable; omitted/absent for a new link. */ + readonly id: string | null; + readonly platform: string; + readonly publicLabel: string; + readonly username: string | null; + readonly normalizedUrl: string; + readonly linkType: "social" | "payment"; + readonly enabled: boolean; +} + +export interface LinkStepInput { + readonly links: LinkStepInputLink[]; +} + +function parseLinkArray(raw: unknown, caps: OrientationCapabilities, fieldPrefix: string): LinkStepInputLink[] { + if (!Array.isArray(raw)) fail("invalid_links", `${fieldPrefix} must be an array`); + if (raw.length > LIMITS.linkMaxCount) { + fail("too_many_links", `at most ${LIMITS.linkMaxCount} ${fieldPrefix} are allowed`); + } + + const seenUrls = new Set(); + const seenIds = new Set(); + return raw.map((entry, index) => { + const linkRecord = asRecord(entry, `${fieldPrefix}[${index}]`); + + const id = linkRecord.id === undefined || linkRecord.id === null + ? null + : requireNonEmptyString(linkRecord.id, `${fieldPrefix}[${index}].id`); + if (id !== null) { + if (seenIds.has(id)) fail("duplicate_link", `${fieldPrefix}[${index}].id duplicates an earlier link`); + seenIds.add(id); + } + + const platform = requireNonEmptyString(linkRecord.platform, `${fieldPrefix}[${index}].platform`); + + const publicLabel = requireNonEmptyString(linkRecord.public_label, `${fieldPrefix}[${index}].public_label`); + if (publicLabel.length > LIMITS.linkLabelMaxChars) { + fail( + "link_label_too_long", + `${fieldPrefix}[${index}].public_label must be at most ${LIMITS.linkLabelMaxChars} characters`, + ); + } + + const username = linkRecord.username === undefined || linkRecord.username === null + ? null + : requireNonEmptyString(linkRecord.username, `${fieldPrefix}[${index}].username`); + + const url = requireNonEmptyString(linkRecord.normalized_url, `${fieldPrefix}[${index}].normalized_url`); + if (url.length > LIMITS.linkUrlMaxChars) { + fail( + "link_url_too_long", + `${fieldPrefix}[${index}].normalized_url must be at most ${LIMITS.linkUrlMaxChars} characters`, + ); + } + const normalizedUrl = validateHttpsUrl(url, `${fieldPrefix}[${index}].normalized_url`); + if (seenUrls.has(normalizedUrl)) { + fail("duplicate_link", `${fieldPrefix}[${index}].normalized_url duplicates an earlier link`); + } + seenUrls.add(normalizedUrl); + + const linkType = linkRecord.link_type; + if (linkType !== "social" && linkType !== "payment") { + fail("invalid_link_type", `${fieldPrefix}[${index}].link_type must be "social" or "payment"`); + } + if (linkType === "payment" && !caps.payment) { + fail("payment_links_unavailable", "this orientation does not support payment links"); + } + + const enabled = + linkRecord.enabled === undefined ? true : parseOptionalBoolean(linkRecord.enabled, `${fieldPrefix}[${index}].enabled`); + + return { id, platform, publicLabel, username, normalizedUrl, linkType, enabled }; + }); +} + +/** + * Validates the manually-entered links step for a `global`/`independent` + * draft (a complete replacement of the document's link list). This only + * accepts links the caller already typed in (label/username/URL); fetching + * a link page and scraping candidates is the separate, not-yet-implemented + * importer. + */ +export function parseLinkStep(body: unknown, orientation: Orientation): LinkStepInput { + const record = asRecord(body, "links step body"); + const caps = ORIENTATION_CAPABILITIES[orientation]; + const links = parseLinkArray(record.links, caps, "links"); + return { links }; +} + +export interface ThroneStepInput { + readonly throneCreatorId: string | null; + readonly preferredPaymentLinkId: string | null; +} + +export function parseThroneStep(body: unknown, orientation: Orientation): ThroneStepInput { + const caps = ORIENTATION_CAPABILITIES[orientation]; + if (!caps.throne) fail("throne_unavailable", "this orientation does not have a Throne step"); + const record = asRecord(body, "throne step body"); + const throneCreatorId = parseOptionalId(record.throne_creator_id, "throne_creator_id"); + const preferredPaymentLinkId = parseOptionalId(record.preferred_payment_link_id, "preferred_payment_link_id"); + return { throneCreatorId, preferredPaymentLinkId }; +} + +// --- linked server overlay steps ------------------------------------------------------------- +// +// A linked draft never chooses orientation or Throne ownership (both are +// read live from the global document), and its identity/links steps ask +// "which fields should this guild override" rather than "what are the +// values from scratch". `overriddenFields`/`overrides` lists are the +// caller's complete, explicit statement of intent for this submission: a +// field left out of the list means "inherit from global", including when a +// value happens to be present elsewhere in the body (which is ignored). + +export interface LinkedIdentityStepInput { + readonly overriddenFields: ReadonlySet; + readonly pronouns: string[]; + readonly honourifics: string[]; + readonly submissiveLabels: string[]; + readonly dmStatus: DmStatus | null; + readonly bio: string | null; + readonly publicSendStats: boolean; + readonly aliases: string[]; +} + +function parseOverridesList(value: unknown, caps: OrientationCapabilities): Set { + if (!Array.isArray(value)) fail("invalid_overrides", "overrides must be an array of field names"); + const overridden = new Set(); + for (const entry of value) { + if (typeof entry !== "string" || !(OVERRIDABLE_FIELDS as readonly string[]).includes(entry)) { + fail("invalid_overrides", "overrides contains an unrecognized field name"); + } + const field = entry as OverridableField; + if (field === "honourifics" && !caps.honourifics) { + fail("field_not_available", "honourifics cannot be overridden for this orientation"); + } + if (field === "submissive_labels" && !caps.submissiveLabels) { + fail("field_not_available", "submissive_labels cannot be overridden for this orientation"); + } + if ((field === "aliases" || field === "public_send_stats") && !caps.aliases) { + fail("field_not_available", `${field} cannot be overridden for this orientation`); + } + overridden.add(field); + } + return overridden; +} + +/** + * Validates a linked overlay's identity-step submission against the + * owner's *live* global orientation (never the draft's own, since a linked + * draft has no orientation of its own). + */ +export function parseLinkedIdentityStep(body: unknown, globalOrientation: Orientation): LinkedIdentityStepInput { + const record = asRecord(body, "identity step body"); + const caps = ORIENTATION_CAPABILITIES[globalOrientation]; + const overriddenFields = parseOverridesList(record.overrides, caps); + + const pronouns = overriddenFields.has("pronouns") ? parseFixedMultiSelect(record.pronouns, PRONOUNS, "pronouns") : []; + const honourifics = overriddenFields.has("honourifics") + ? parseFixedMultiSelect(record.honourifics, HONOURIFICS, "honourifics") + : []; + const submissiveLabels = overriddenFields.has("submissive_labels") + ? parseFixedMultiSelect(record.submissive_labels, SUBMISSIVE_LABELS, "submissive_labels") + : []; + const dmStatus = overriddenFields.has("dm_status") + ? (isDmStatus(record.dm_status) ? record.dm_status : fail("invalid_dm_status", `dm_status must be one of: ${DM_STATUSES.join(", ")}`)) + : null; + const bio = overriddenFields.has("bio") ? parseOptionalBio(record.bio) : null; + const publicSendStats = overriddenFields.has("public_send_stats") + ? parseOptionalBoolean(record.public_send_stats, "public_send_stats") + : false; + const aliases = overriddenFields.has("aliases") ? parseAliases(record.aliases) : []; + + return { overriddenFields, pronouns, honourifics, submissiveLabels, dmStatus, bio, publicSendStats, aliases }; +} + +export interface LinkedLinksStepInput { + readonly localLinks: LinkStepInputLink[]; + readonly hiddenInheritedLinkIds: string[]; + readonly preferredPaymentLinkId: string | null; +} + +/** + * Validates a linked overlay's links-step submission: server-local + * additions, which inherited global links to hide, and which currently + * resolvable payment link is preferred in this guild. This never touches + * the global document's own link rows. + */ +export function parseLinkedLinksStep(body: unknown, globalOrientation: Orientation): LinkedLinksStepInput { + const record = asRecord(body, "links step body"); + const caps = ORIENTATION_CAPABILITIES[globalOrientation]; + const localLinks = parseLinkArray(record.local_links ?? [], caps, "local_links"); + + const hiddenRaw = record.hidden_inherited_link_ids ?? []; + if (!Array.isArray(hiddenRaw)) fail("invalid_field", "hidden_inherited_link_ids must be an array"); + const hiddenInheritedLinkIds = hiddenRaw.map((entry, index) => + requireNonEmptyString(entry, `hidden_inherited_link_ids[${index}]`), + ); + + const preferredPaymentLinkId = parseOptionalId(record.preferred_payment_link_id, "preferred_payment_link_id"); + + return { localLinks, hiddenInheritedLinkIds, preferredPaymentLinkId }; +} + +// --- shared primitive parsing helpers ------------------------------------------------------- + +function asRecord(value: unknown, what: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail("invalid_body", `${what} must be a JSON object`); + } + return value as Record; +} + +function requireNonEmptyString(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + fail("invalid_field", `${field} must be a non-empty string`); + } + return value; +} + +function parseOptionalId(value: unknown, field: string): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string" || value.trim().length === 0) { + fail("invalid_field", `${field} must be a non-empty string or null`); + } + return value; +} + +function parseOptionalBoolean(value: unknown, field: string): boolean { + if (typeof value !== "boolean") fail("invalid_field", `${field} must be a boolean`); + return value; +} + +/** Like `parseOptionalBoolean`, but a fully-absent value falls back to `defaultValue` instead of failing. */ +function parseBooleanWithDefault(value: unknown, field: string, defaultValue: boolean): boolean { + if (value === undefined) return defaultValue; + return parseOptionalBoolean(value, field); +} + +function requireEmptyOrAbsent(value: unknown, field: string, orientation: Orientation): void { + const isEmpty = + value === undefined || + value === null || + (Array.isArray(value) && value.length === 0) || + value === false; + if (!isEmpty) { + fail("field_not_available", `${field} is not available for orientation ${orientation}`); + } +} + +function parseFixedMultiSelect(value: unknown, allowed: readonly string[], field: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) fail("invalid_field", `${field} must be an array`); + const result: string[] = []; + const seen = new Set(); + for (const entry of value) { + if (typeof entry !== "string" || !allowed.includes(entry)) { + fail("invalid_field_value", `${field} contains an unrecognized value`); + } + if (!seen.has(entry)) { + seen.add(entry); + result.push(entry); + } + } + return result; +} + +function parseOptionalBio(value: unknown): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") fail("invalid_field", "bio must be a string or null"); + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + if (trimmed.length > LIMITS.bioMaxChars) { + fail("bio_too_long", `bio must be at most ${LIMITS.bioMaxChars} characters`); + } + return trimmed; +} + +/** + * Normalizes an alias for storage: Unicode NFKC normalization, case + * folding, a stripped leading "@", and whitespace collapse. This is what + * makes "@Foo_Bar", "foo_bar", and " foo_bar " collide as duplicates and + * what future webhook attribution matches sender names against. + */ +export function normalizeAlias(displayAlias: string): string { + return displayAlias + .normalize("NFKC") + .trim() + .replace(/^@+/, "") + .replace(/\s+/g, " ") + .toLowerCase(); +} + +function parseAliases(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) fail("invalid_field", "aliases must be an array"); + if (value.length > LIMITS.aliasMaxCount) { + fail("too_many_aliases", `at most ${LIMITS.aliasMaxCount} aliases are allowed`); + } + const seenNormalized = new Set(); + const aliases: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") fail("invalid_field", "each alias must be a string"); + const trimmed = entry.trim(); + if (trimmed.length === 0) fail("invalid_alias", "aliases must not be empty"); + if (trimmed.length > LIMITS.aliasMaxChars) { + fail("alias_too_long", `each alias must be at most ${LIMITS.aliasMaxChars} characters`); + } + const normalized = normalizeAlias(trimmed); + if (normalized.length === 0) fail("invalid_alias", "aliases must contain visible characters"); + if (seenNormalized.has(normalized)) { + fail("duplicate_alias", "aliases must be unique once normalized"); + } + seenNormalized.add(normalized); + aliases.push(trimmed); + } + return aliases; +} + +/** + * Format-only URL validation for manually-entered links: HTTPS scheme, no + * embedded credentials, and a length cap. This is deliberately *not* the + * SSRF-hardened fetch/DNS policy the (not-yet-implemented) link importer + * will need, because these URLs are only ever stored and rendered as + * Discord link buttons, never fetched by the Worker. + */ +export function validateHttpsUrl(value: string, field: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + fail("invalid_url", `${field} must be a well-formed URL`); + } + if (parsed.protocol !== "https:") { + fail("invalid_url_scheme", `${field} must use https`); + } + if (parsed.username.length > 0 || parsed.password.length > 0) { + fail("invalid_url_credentials", `${field} must not contain embedded credentials`); + } + return parsed.toString(); +} diff --git a/worker/src/profile/documentStore.ts b/worker/src/profile/documentStore.ts new file mode 100644 index 0000000..c4a2364 --- /dev/null +++ b/worker/src/profile/documentStore.ts @@ -0,0 +1,345 @@ +/** + * Reads and writes the normalized child rows (`profile_document_selections`, + * `profile_aliases`, `profile_links`, `profile_document_overrides`, + * `profile_link_visibility`) that make up one profile document's content, + * as a single in-memory snapshot. Draft start/restart/step-mutation all + * work in terms of this snapshot instead of poking individual tables, so + * "replace this document's content" is always a delete-then-insert of the + * whole child set rather than ad-hoc partial updates that could drift out + * of sync with each other. + */ +import type { Env } from "../env.js"; +import { newId } from "../util/id.js"; +import { normalizeAlias, type DmStatus, type Orientation, type OverridableField } from "./contracts.js"; + +export interface DocumentSelections { + readonly pronouns: string[]; + readonly honourifics: string[]; + readonly submissiveLabels: string[]; +} + +export interface DocumentLinkInput { + /** Existing link id to keep stable across edits, or null to mint a new one. */ + readonly id: string | null; + readonly platform: string; + readonly publicLabel: string; + readonly username: string | null; + readonly normalizedUrl: string; + readonly linkType: "social" | "payment"; + readonly enabled: boolean; +} + +export interface DocumentSnapshot { + readonly orientation: Orientation | null; + readonly dmStatus: DmStatus | null; + readonly bio: string | null; + readonly publicSendStats: boolean; + readonly throneCreatorId: string | null; + readonly preferredPaymentLinkId: string | null; + readonly selections: DocumentSelections; + readonly aliases: string[]; + readonly links: DocumentLinkInput[]; + /** Only meaningful on a linked overlay document. */ + readonly overriddenFields: readonly OverridableField[]; + /** Only meaningful on a linked overlay document; ids of inherited global links this overlay hides. */ + readonly hiddenInheritedLinkIds: readonly string[]; +} + +export const EMPTY_SNAPSHOT: DocumentSnapshot = { + orientation: null, + dmStatus: null, + bio: null, + publicSendStats: false, + throneCreatorId: null, + preferredPaymentLinkId: null, + selections: { pronouns: [], honourifics: [], submissiveLabels: [] }, + aliases: [], + links: [], + overriddenFields: [], + hiddenInheritedLinkIds: [], +}; + +interface DocumentScalarRow { + orientation: Orientation | null; + dm_status: DmStatus | null; + bio: string | null; + public_send_stats: number; + throne_creator_id: string | null; + preferred_payment_link_id: string | 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 + FROM profile_documents WHERE id = ?`, + ) + .bind(documentId) + .first(); + if (doc === null) return null; + + const [selectionsResult, aliasResult, linkResult, overrideResult, visibilityResult] = await Promise.all([ + env.DB.prepare("SELECT category, value FROM profile_document_selections WHERE document_id = ? ORDER BY sort_order, value") + .bind(documentId) + .all<{ category: string; value: string }>(), + env.DB.prepare("SELECT display_alias FROM profile_aliases WHERE document_id = ? ORDER BY sort_order, display_alias") + .bind(documentId) + .all<{ display_alias: string }>(), + env.DB.prepare( + `SELECT id, platform, public_label, username, normalized_url, link_type, enabled + FROM profile_links WHERE document_id = ? ORDER BY sort_order, id`, + ) + .bind(documentId) + .all<{ + id: string; + platform: string; + public_label: string; + username: string | null; + normalized_url: string; + link_type: "social" | "payment"; + enabled: number; + }>(), + env.DB.prepare("SELECT field_name FROM profile_document_overrides WHERE document_id = ?") + .bind(documentId) + .all<{ field_name: string }>(), + env.DB.prepare("SELECT inherited_link_id FROM profile_link_visibility WHERE document_id = ? AND visible = 0") + .bind(documentId) + .all<{ inherited_link_id: string }>(), + ]); + + const selections: DocumentSelections = { pronouns: [], honourifics: [], submissiveLabels: [] }; + for (const row of selectionsResult.results) { + if (row.category === "pronoun") selections.pronouns.push(row.value); + else if (row.category === "honourific") selections.honourifics.push(row.value); + else if (row.category === "submissive_label") selections.submissiveLabels.push(row.value); + } + + return { + orientation: doc.orientation, + dmStatus: doc.dm_status, + bio: doc.bio, + publicSendStats: doc.public_send_stats === 1, + throneCreatorId: doc.throne_creator_id, + preferredPaymentLinkId: doc.preferred_payment_link_id, + selections, + aliases: aliasResult.results.map((row) => row.display_alias), + links: linkResult.results.map((row) => ({ + id: row.id, + platform: row.platform, + publicLabel: row.public_label, + username: row.username, + normalizedUrl: row.normalized_url, + linkType: row.link_type, + enabled: row.enabled === 1, + })), + overriddenFields: overrideResult.results.map((row) => row.field_name) as OverridableField[], + hiddenInheritedLinkIds: visibilityResult.results.map((row) => row.inherited_link_id), + }; +} + +/** + * Only "own" links (the document's own social/payment entries) are part of + * a snapshot's `links`; a linked overlay's *inherited* links live on the + * global document and are never copied here (see `hiddenInheritedLinkIds` + * for how an overlay instead marks specific inherited links unwanted). + */ +export interface DraftGuard { + readonly draftId: string; + readonly expectedRevision: number; +} + +const REVISION_GUARD_SQL = `EXISTS ( + SELECT 1 + FROM profile_drafts d + JOIN profile_documents p ON p.id = d.document_id + WHERE d.id = ? AND d.revision = ? AND d.status = 'active' + AND d.document_id = ? AND p.state = 'draft' +)`; + +function guardSuffix( + guard: DraftGuard | null, + documentId: string, + hasWhere: boolean, +): { sql: string; params: unknown[] } { + if (guard === null) return { sql: "", params: [] }; + return { + sql: `${hasWhere ? " AND " : " WHERE "}${REVISION_GUARD_SQL}`, + params: [guard.draftId, guard.expectedRevision, documentId], + }; +} + +/** + * Builds the statements that create a brand new document row (used by + * draft start) or overwrite an existing one's scalar fields and full child + * set (used by draft restart and step mutation). When `guard` is supplied, + * every statement is a no-op unless the named draft is still active at + * exactly the caller's old `expectedRevision`. The caller places its + * compare-and-swap last in the same D1 batch. A losing batch therefore sees + * the winner's newer revision before any statement runs, making every write + * a no-op; it can never mistake the winner's predictable next revision for + * its own authority. + */ +export function buildDocumentWriteStatements( + env: Env, + documentId: string, + ownerUserId: string, + snapshot: DocumentSnapshot, + now: string, + options: { isNew: boolean; guard: DraftGuard | null }, +): D1PreparedStatement[] { + const { isNew, guard } = options; + const statements: D1PreparedStatement[] = []; + const guardFragment = guardSuffix(guard, documentId, true); + + if (isNew) { + statements.push( + 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', ?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + documentId, + ownerUserId, + snapshot.orientation, + snapshot.dmStatus, + snapshot.bio, + snapshot.publicSendStats ? 1 : 0, + snapshot.throneCreatorId, + snapshot.preferredPaymentLinkId, + now, + now, + ), + ); + } else { + statements.push( + env.DB.prepare( + `UPDATE profile_documents + SET orientation = ?, dm_status = ?, bio = ?, public_send_stats = ?, + throne_creator_id = ?, preferred_payment_link_id = ?, updated_at = ? + WHERE id = ?${guardFragment.sql}`, + ).bind( + snapshot.orientation, + snapshot.dmStatus, + snapshot.bio, + snapshot.publicSendStats ? 1 : 0, + snapshot.throneCreatorId, + snapshot.preferredPaymentLinkId, + now, + documentId, + ...guardFragment.params, + ), + ); + + // Full child-row replacement: delete-then-insert is simpler and safer + // to reason about than diffing, and these tables are always small + // (selections <= a handful, aliases <= 3, links <= 12). + statements.push( + env.DB.prepare(`DELETE FROM profile_document_selections WHERE document_id = ?${guardFragment.sql}`).bind( + documentId, + ...guardFragment.params, + ), + ); + statements.push( + env.DB.prepare(`DELETE FROM profile_aliases WHERE document_id = ?${guardFragment.sql}`).bind( + documentId, + ...guardFragment.params, + ), + ); + statements.push( + env.DB.prepare(`DELETE FROM profile_links WHERE document_id = ?${guardFragment.sql}`).bind( + documentId, + ...guardFragment.params, + ), + ); + statements.push( + env.DB.prepare(`DELETE FROM profile_document_overrides WHERE document_id = ?${guardFragment.sql}`).bind( + documentId, + ...guardFragment.params, + ), + ); + statements.push( + env.DB.prepare(`DELETE FROM profile_link_visibility WHERE document_id = ?${guardFragment.sql}`).bind( + documentId, + ...guardFragment.params, + ), + ); + } + + const insertGuardFragment = guardSuffix(guard, documentId, false); + + snapshot.selections.pronouns.forEach((value, index) => { + statements.push(buildSelectionInsert(env, documentId, "pronoun", value, index, insertGuardFragment)); + }); + snapshot.selections.honourifics.forEach((value, index) => { + statements.push(buildSelectionInsert(env, documentId, "honourific", value, index, insertGuardFragment)); + }); + snapshot.selections.submissiveLabels.forEach((value, index) => { + statements.push(buildSelectionInsert(env, documentId, "submissive_label", value, index, insertGuardFragment)); + }); + + snapshot.aliases.forEach((displayAlias, index) => { + const normalized = normalizeAlias(displayAlias); + statements.push( + env.DB.prepare( + `INSERT INTO profile_aliases (id, document_id, display_alias, normalized_alias, sort_order) + SELECT ?, ?, ?, ?, ?${insertGuardFragment.sql}`, + ).bind(newId(), documentId, displayAlias, normalized, index, ...insertGuardFragment.params), + ); + }); + + snapshot.links.forEach((link, index) => { + statements.push( + env.DB.prepare( + `INSERT INTO profile_links + (id, document_id, platform, public_label, username, normalized_url, link_type, sort_order, enabled, created_at, updated_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?${insertGuardFragment.sql}`, + ).bind( + link.id ?? newId(), + documentId, + link.platform, + link.publicLabel, + link.username, + link.normalizedUrl, + link.linkType, + index, + link.enabled ? 1 : 0, + now, + now, + ...insertGuardFragment.params, + ), + ); + }); + + snapshot.overriddenFields.forEach((fieldName) => { + statements.push( + env.DB.prepare(`INSERT INTO profile_document_overrides (document_id, field_name) SELECT ?, ?${insertGuardFragment.sql}`).bind( + documentId, + fieldName, + ...insertGuardFragment.params, + ), + ); + }); + + snapshot.hiddenInheritedLinkIds.forEach((inheritedLinkId) => { + statements.push( + env.DB.prepare( + `INSERT INTO profile_link_visibility (document_id, inherited_link_id, visible) SELECT ?, ?, 0${insertGuardFragment.sql}`, + ).bind(documentId, inheritedLinkId, ...insertGuardFragment.params), + ); + }); + + return statements; +} + +function buildSelectionInsert( + env: Env, + documentId: string, + category: "pronoun" | "honourific" | "submissive_label", + value: string, + sortOrder: number, + guardFragment: { sql: string; params: unknown[] }, +): D1PreparedStatement { + return env.DB.prepare( + `INSERT INTO profile_document_selections (document_id, category, value, sort_order) SELECT ?, ?, ?, ?${guardFragment.sql}`, + ).bind(documentId, category, value, sortOrder, ...guardFragment.params); +} diff --git a/worker/src/profile/draftService.ts b/worker/src/profile/draftService.ts new file mode 100644 index 0000000..5e31591 --- /dev/null +++ b/worker/src/profile/draftService.ts @@ -0,0 +1,660 @@ +/** + * Draft lifecycle: start (create/resume), read, step mutation, and + * restart. Every mutating operation is optimistic-concurrency-checked + * against the draft's `revision`: the caller must echo back the revision + * it last saw as `expectedRevision`, and a mismatch means somebody else + * (a concurrent request, a second device, a race with restart) already + * changed the draft first. + * + * D1 batches commit all-or-nothing, but a statement whose WHERE clause + * simply matches zero rows is *not* an error -- so "revision doesn't + * match" cannot by itself stop the rest of a batch from applying. Every + * mutation here is instead structured as: the very first statement in the + * batch is the compare-and-swap (`UPDATE ... WHERE revision = ?old`), and + * every earlier write is guarded by that same old revision. D1 serializes + * each atomic batch, so a losing batch starts after the winner committed + * the next revision: all of its guarded writes and final CAS become no-ops. + */ +import type { Env } from "../env.js"; +import { requireHomeGuildId } from "../env.js"; +import { newId, nowIso } from "../util/id.js"; +import { isSnowflake } from "../util/snowflake.js"; +import { + ValidationError, + parseIdentityStep, + parseLinkStep, + parseLinkedIdentityStep, + parseLinkedLinksStep, + parseOrientationStep, + parseThroneStep, + stepsForDraft, + ORIENTATION_CAPABILITIES, + LIMITS, + type Orientation, + type ServerMode, + type StepKey, + type TargetScope, +} from "./contracts.js"; +import { + EMPTY_SNAPSHOT, + buildDocumentWriteStatements, + readDocumentSnapshot, + type DocumentSnapshot, +} from "./documentStore.js"; + +export class DraftError extends Error { + readonly status: number; + readonly code: string; + constructor(status: number, code: string, message: string) { + super(message); + this.status = status; + this.code = code; + } +} + +export function notFound(message = "Draft not found"): never { + throw new DraftError(404, "draft_not_found", message); +} +export function conflict(code: string, message: string): never { + throw new DraftError(409, code, message); +} +export function badRequest(code: string, message: string): never { + throw new DraftError(400, code, message); +} + +export interface DraftRow { + id: string; + owner_user_id: string; + origin_guild_id: string | null; + target_scope: TargetScope; + guild_id: string | null; + server_mode: ServerMode | null; + document_id: string; + base_version: number; + status: "active" | "published"; + current_step: StepKey; + revision: number; + created_at: string; + updated_at: string; + published_at: string | null; +} + +/** Exported so link/Throne draft services (which mutate the same draft/document rows via their + * own dedicated endpoints) share exactly one "load and ownership-check a draft" implementation. */ +export async function loadOwnedDraft(env: Env, draftId: string, ownerUserId: string): Promise { + const row = await env.DB.prepare("SELECT * FROM profile_drafts WHERE id = ?").bind(draftId).first(); + if (row === null || row.owner_user_id !== ownerUserId) notFound(); + return row; +} + +interface StepStatusRow { + step_key: StepKey; + status: "pending" | "completed"; + completed_at: string | null; +} + +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 = ?", + ) + .bind(draftId) + .all(); + return new Map(results.map((row) => [row.step_key, row])); +} + +/** The orientation that governs a draft's capabilities: its own for global/independent, or the live global owner's for a linked overlay. */ +export async function resolveGoverningOrientation(env: Env, draft: DraftRow, ownDocument: DocumentSnapshot): Promise { + if (!(draft.target_scope === "server" && draft.server_mode === "linked")) { + return ownDocument.orientation; + } + 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 }>(); + if (globalRoot === null) return null; + const globalDoc = await readDocumentSnapshot(env, globalRoot.current_document_id); + return globalDoc?.orientation ?? null; +} + +export interface DraftContract { + readonly id: string; + readonly ownerUserId: string; + readonly originGuildId: string | null; + readonly targetScope: TargetScope; + readonly guildId: string | null; + readonly serverMode: ServerMode | null; + readonly status: "active" | "published"; + readonly revision: number; + readonly baseVersion: number; + readonly currentStep: StepKey; + readonly nextStep: StepKey | null; + readonly steps: { key: StepKey; status: "pending" | "completed"; completedAt: string | null }[]; + readonly governingOrientation: Orientation | null; + readonly document: { + dmStatus: DocumentSnapshot["dmStatus"]; + bio: string | null; + publicSendStats: boolean; + selections: DocumentSnapshot["selections"]; + aliases: string[]; + links: DocumentSnapshot["links"]; + overriddenFields: readonly string[]; + hiddenInheritedLinkIds: readonly string[]; + throneCreatorId: string | null; + preferredPaymentLinkId: string | 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" + * instead of always starting Throne resolution from scratch. */ + readonly thronePrefill: { + ownedCreators: { id: string; handle: string }[]; + existingRegistrationCreatorId: string | null; + } | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly publishedAt: string | null; +} + +interface OwnedCreatorRow { + id: string; + handle: string; +} + +/** The guild a Throne registration would apply to for this draft: the home guild for a global + * draft (registrations there back the home-guild fan-out), or the draft's own guild otherwise. */ +function registrationGuildIdForDraft(env: Env, draft: DraftRow): string | null { + if (draft.target_scope === "global") { + try { + return requireHomeGuildId(env); + } catch { + return null; + } + } + return draft.guild_id; +} + +async function loadThronePrefill( + env: Env, + draft: DraftRow, + governingOrientation: Orientation | null, +): Promise { + if (governingOrientation === null || !ORIENTATION_CAPABILITIES[governingOrientation].throne) return null; + + const { results: ownedCreators } = await env.DB.prepare( + "SELECT id, handle FROM throne_creators WHERE owner_discord_user_id = ? ORDER BY updated_at DESC", + ) + .bind(draft.owner_user_id) + .all(); + + const registrationGuildId = registrationGuildIdForDraft(env, draft); + let existingRegistrationCreatorId: string | null = null; + if (registrationGuildId !== null) { + const registration = await env.DB.prepare( + "SELECT creator_id FROM domme_registrations WHERE guild_id = ? AND discord_user_id = ? AND active = 1", + ) + .bind(registrationGuildId, draft.owner_user_id) + .first<{ creator_id: string }>(); + existingRegistrationCreatorId = registration?.creator_id ?? null; + } + + return { ownedCreators, existingRegistrationCreatorId }; +} + +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); + const steps = stepsForDraft(draft.target_scope, draft.server_mode, governingOrientation); + const statuses = await loadStepStatuses(env, draft.id); + const stepList = steps.map((key) => { + const found = statuses.get(key); + return { key, status: found?.status ?? ("pending" as const), completedAt: found?.completed_at ?? null }; + }); + const nextStep = stepList.find((step) => step.status === "pending")?.key ?? null; + const thronePrefill = await loadThronePrefill(env, draft, governingOrientation); + + return { + id: draft.id, + ownerUserId: draft.owner_user_id, + originGuildId: draft.origin_guild_id, + targetScope: draft.target_scope, + guildId: draft.guild_id, + serverMode: draft.server_mode, + status: draft.status, + revision: draft.revision, + baseVersion: draft.base_version, + currentStep: draft.current_step, + nextStep, + steps: stepList, + governingOrientation, + document: { + dmStatus: snapshot.dmStatus, + bio: snapshot.bio, + publicSendStats: snapshot.publicSendStats, + selections: snapshot.selections, + aliases: snapshot.aliases, + links: snapshot.links, + overriddenFields: snapshot.overriddenFields, + hiddenInheritedLinkIds: snapshot.hiddenInheritedLinkIds, + throneCreatorId: snapshot.throneCreatorId, + preferredPaymentLinkId: snapshot.preferredPaymentLinkId, + }, + thronePrefill, + createdAt: draft.created_at, + updatedAt: draft.updated_at, + publishedAt: draft.published_at, + }; +} + +export async function getDraftContract(env: Env, draftId: string, ownerUserId: string): Promise { + const draft = await loadOwnedDraft(env, draftId, ownerUserId); + return buildContract(env, draft); +} + +// --- start ----------------------------------------------------------------------------------- + +export interface StartDraftInput { + readonly ownerUserId: string; + readonly originGuildId: string; + readonly targetScope: TargetScope; + readonly guildId: string | null; + readonly serverMode: ServerMode | null; +} + +export interface StartDraftResult { + readonly resumeRequired: boolean; + readonly draft: DraftContract; +} + +async function findActiveDraftRow(env: Env, input: StartDraftInput): Promise { + if (input.targetScope === "global") { + return env.DB.prepare( + "SELECT * FROM profile_drafts WHERE owner_user_id = ? AND target_scope = 'global' AND status = 'active'", + ) + .bind(input.ownerUserId) + .first(); + } + return env.DB.prepare( + "SELECT * FROM profile_drafts WHERE owner_user_id = ? AND guild_id = ? AND target_scope = 'server' AND status = 'active'", + ) + .bind(input.ownerUserId, input.guildId) + .first(); +} + +/** Loads the current published document (if any) as a starting snapshot, or an empty one. */ +function cloneSnapshotForDraft(snapshot: DocumentSnapshot): DocumentSnapshot { + const remappedLinkIds = new Map(); + const links = snapshot.links.map((link) => { + const clonedId = newId(); + if (link.id !== null) remappedLinkIds.set(link.id, clonedId); + return { ...link, id: clonedId }; + }); + const preferredPaymentLinkId = + snapshot.preferredPaymentLinkId === null + ? null + : (remappedLinkIds.get(snapshot.preferredPaymentLinkId) ?? snapshot.preferredPaymentLinkId); + return { ...snapshot, links, preferredPaymentLinkId }; +} + +async function loadStartingSnapshot(env: Env, input: StartDraftInput): Promise<{ snapshot: DocumentSnapshot; baseVersion: number }> { + if (input.targetScope === "global") { + const root = await env.DB.prepare("SELECT current_document_id, version FROM global_profiles WHERE owner_user_id = ?") + .bind(input.ownerUserId) + .first<{ current_document_id: string; version: number }>(); + if (root === null) return { snapshot: EMPTY_SNAPSHOT, baseVersion: 0 }; + const snapshot = await readDocumentSnapshot(env, root.current_document_id); + return { + snapshot: snapshot === null ? EMPTY_SNAPSHOT : cloneSnapshotForDraft(snapshot), + baseVersion: root.version, + }; + } + + const root = await env.DB.prepare( + "SELECT current_document_id, version, mode FROM server_profiles WHERE guild_id = ? AND owner_user_id = ?", + ) + .bind(input.guildId, input.ownerUserId) + .first<{ current_document_id: string; version: number; mode: ServerMode }>(); + // A prior root in a *different* mode than requested isn't cloned: converting + // linked<->independent starts fresh rather than attempting to materialize + // one shape into the other. This is a known, documented limitation. + if (root === null || root.mode !== input.serverMode) { + return { snapshot: EMPTY_SNAPSHOT, baseVersion: root?.version ?? 0 }; + } + const snapshot = await readDocumentSnapshot(env, root.current_document_id); + return { + snapshot: snapshot === null ? EMPTY_SNAPSHOT : cloneSnapshotForDraft(snapshot), + baseVersion: root.version, + }; +} + +export async function startDraft(env: Env, input: StartDraftInput): Promise { + if (!isSnowflake(input.ownerUserId)) badRequest("invalid_owner_user_id", "owner_user_id must be a Discord snowflake"); + if (!isSnowflake(input.originGuildId)) badRequest("invalid_origin_guild_id", "origin_guild_id must be a Discord snowflake"); + + if (input.targetScope === "global") { + const homeGuildId = requireHomeGuildId(env); + if (input.originGuildId !== homeGuildId) { + badRequest("home_guild_required", "a global profile can only be started while acting in Bill's home guild"); + } + } else { + if (input.guildId === null || !isSnowflake(input.guildId)) { + badRequest("invalid_guild_id", "guild_id must be a Discord snowflake for a server-scoped draft"); + } + if (input.serverMode === null) { + badRequest("invalid_server_mode", "server_mode is required for a server-scoped draft"); + } + const homeGuildId = requireHomeGuildId(env); + if (input.guildId === homeGuildId) { + badRequest("server_scope_not_allowed_in_home_guild", "the home guild always uses the global profile directly"); + } + } + + const existing = await findActiveDraftRow(env, input); + if (existing !== null) { + return { resumeRequired: true, draft: await buildContract(env, existing) }; + } + + const { snapshot, baseVersion } = await loadStartingSnapshot(env, input); + const now = nowIso(); + const documentId = newId(); + const draftId = newId(); + + const statements = [ + ...buildDocumentWriteStatements(env, documentId, input.ownerUserId, snapshot, now, { isNew: true, guard: null }), + 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 (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'orientation', 0, ?, ?)`, + ).bind( + draftId, + input.ownerUserId, + input.originGuildId, + input.targetScope, + input.guildId, + input.serverMode, + documentId, + baseVersion, + now, + now, + ), + ]; + + try { + await env.DB.batch(statements); + } catch { + // Somebody else's concurrent start() won the race for the same active-draft + // slot (the partial unique index on profile_drafts enforces this as a real + // constraint violation, which aborts this whole batch -- including the + // document insert -- leaving no orphaned rows behind). + const raced = await findActiveDraftRow(env, input); + if (raced !== null) return { resumeRequired: true, draft: await buildContract(env, raced) }; + throw new DraftError(409, "start_conflict", "Could not start a new draft; please retry"); + } + + const created = await loadOwnedDraft(env, draftId, input.ownerUserId); + return { resumeRequired: false, draft: await buildContract(env, created) }; +} + +// --- step mutation ----------------------------------------------------------------------------- + +function normalizeSnapshotForOrientation(snapshot: DocumentSnapshot, orientation: Orientation): DocumentSnapshot { + const caps = ORIENTATION_CAPABILITIES[orientation]; + return { + ...snapshot, + orientation, + selections: { + pronouns: snapshot.selections.pronouns, + honourifics: caps.honourifics ? snapshot.selections.honourifics : [], + submissiveLabels: caps.submissiveLabels ? snapshot.selections.submissiveLabels : [], + }, + aliases: caps.aliases ? snapshot.aliases : [], + publicSendStats: caps.stats ? snapshot.publicSendStats : false, + throneCreatorId: caps.throne ? snapshot.throneCreatorId : null, + links: caps.payment ? snapshot.links : snapshot.links.filter((link) => link.linkType !== "payment"), + preferredPaymentLinkId: caps.payment ? snapshot.preferredPaymentLinkId : null, + }; +} + +export interface ApplyStepInput { + readonly draftId: string; + readonly stepKey: StepKey; + readonly ownerUserId: string; + readonly expectedRevision: number; + readonly body: unknown; +} + +async function computeNewSnapshot( + env: Env, + draft: DraftRow, + current: DocumentSnapshot, + stepKey: StepKey, + governingOrientation: Orientation | null, + body: unknown, +): Promise { + const linked = draft.target_scope === "server" && draft.server_mode === "linked"; + + if (stepKey === "orientation") { + if (linked) badRequest("step_not_applicable", "a linked server profile inherits orientation from the global profile"); + const parsed = parseOrientationStep(body); + return normalizeSnapshotForOrientation(current, parsed.orientation); + } + + if (governingOrientation === null) { + badRequest("orientation_required", "orientation must be chosen before this step"); + } + + if (stepKey === "identity") { + if (linked) { + const parsed = parseLinkedIdentityStep(body, governingOrientation); + return { + ...current, + dmStatus: parsed.dmStatus, + bio: parsed.bio, + publicSendStats: parsed.publicSendStats, + selections: { + pronouns: parsed.pronouns, + honourifics: parsed.honourifics, + submissiveLabels: parsed.submissiveLabels, + }, + aliases: parsed.aliases, + overriddenFields: Array.from(parsed.overriddenFields), + }; + } + const parsed = parseIdentityStep(body, governingOrientation); + return { + ...current, + dmStatus: parsed.dmStatus, + bio: parsed.bio, + publicSendStats: parsed.publicSendStats, + selections: { pronouns: parsed.pronouns, honourifics: parsed.honourifics, submissiveLabels: parsed.submissiveLabels }, + aliases: parsed.aliases, + }; + } + + if (stepKey === "links") { + if (linked) { + const parsed = parseLinkedLinksStep(body, governingOrientation); + await validateReferencedLinkIds(current, parsed.localLinks); + const totalVisible = await countResolvedVisibleLinks(env, draft, parsed); + if (totalVisible > LIMITS.linkMaxCount) { + badRequest("too_many_links", `at most ${LIMITS.linkMaxCount} resolved links are allowed`); + } + return { + ...current, + links: parsed.localLinks, + hiddenInheritedLinkIds: parsed.hiddenInheritedLinkIds, + preferredPaymentLinkId: parsed.preferredPaymentLinkId, + }; + } + const parsed = parseLinkStep(body, governingOrientation); + await validateReferencedLinkIds(current, parsed.links); + return { ...current, links: parsed.links }; + } + + 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 = ?") + .bind(parsed.throneCreatorId, draft.owner_user_id) + .first(); + if (owned === null) badRequest("throne_creator_not_owned", "that Throne creator is not owned by this user"); + } + if (parsed.preferredPaymentLinkId !== null) { + if (!current.links.some((link) => link.id === parsed.preferredPaymentLinkId && link.linkType === "payment")) { + badRequest("invalid_preferred_payment_link", "preferred_payment_link_id must reference an existing payment link"); + } + } + return { ...current, throneCreatorId: parsed.throneCreatorId, preferredPaymentLinkId: parsed.preferredPaymentLinkId }; + } + + // "review" carries no field mutations of its own; it just gets marked completed. + return current; +} + +/** Any link `id` the caller echoes back must belong to a link this document already has. */ +async function validateReferencedLinkIds( + current: DocumentSnapshot, + links: readonly { id: string | null }[], +): Promise { + const knownIds = new Set(current.links.map((link) => link.id).filter((id): id is string => id !== null)); + for (const link of links) { + if (link.id !== null && !knownIds.has(link.id)) { + badRequest("unknown_link_id", "id must reference a link already present on this document"); + } + } +} + +export async function countResolvedVisibleLinks( + env: Env, + draft: DraftRow, + parsed: { localLinks: { enabled: boolean }[]; hiddenInheritedLinkIds: string[] }, +): Promise { + 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 }>(); + if (globalRoot === null) return parsed.localLinks.filter((link) => link.enabled).length; + const globalSnapshot = await readDocumentSnapshot(env, globalRoot.current_document_id); + const inheritedCount = (globalSnapshot?.links ?? []).filter( + (link) => link.enabled && link.id !== null && !parsed.hiddenInheritedLinkIds.includes(link.id), + ).length; + return inheritedCount + parsed.localLinks.filter((link) => link.enabled).length; +} + +export async function applyDraftStep(env: Env, input: ApplyStepInput): 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); + const applicableSteps = stepsForDraft(draft.target_scope, draft.server_mode, governingOrientation); + if (!applicableSteps.includes(input.stepKey)) { + badRequest("step_not_applicable", `${input.stepKey} is not part of this draft's step sequence`); + } + const bodyRecord = + typeof input.body === "object" && input.body !== null && !Array.isArray(input.body) + ? (input.body as Record) + : null; + const completeValue = bodyRecord?.complete; + if (completeValue !== undefined && typeof completeValue !== "boolean") { + badRequest("invalid_complete", "complete must be a boolean when provided"); + } + const completeStep = completeValue !== false; + if (!completeStep && input.stepKey !== "identity") { + badRequest("partial_step_not_supported", "only identity supports partial draft persistence"); + } + + let newSnapshot: DocumentSnapshot; + try { + newSnapshot = await computeNewSnapshot(env, draft, current, input.stepKey, governingOrientation, input.body); + } catch (error) { + if (error instanceof ValidationError) badRequest(error.code, error.message); + throw error; + } + + const now = nowIso(); + const newRevision = draft.revision + 1; + const guard = { draftId: draft.id, expectedRevision: draft.revision }; + + const statements: D1PreparedStatement[] = [ + ...buildDocumentWriteStatements(env, draft.document_id, draft.owner_user_id, newSnapshot, now, { + isNew: false, + guard, + }), + ]; + if (completeStep) { + statements.push(env.DB.prepare( + `INSERT INTO profile_draft_steps (draft_id, step_key, status, completed_at) + SELECT ?, ?, 'completed', ? + WHERE EXISTS (SELECT 1 FROM profile_drafts WHERE id = ? AND revision = ? AND status = 'active') + ON CONFLICT (draft_id, step_key) DO UPDATE SET status = 'completed', completed_at = excluded.completed_at`, + ).bind(draft.id, input.stepKey, now, draft.id, draft.revision)); + } + statements.push( + env.DB.prepare( + `UPDATE profile_drafts + SET revision = ?, current_step = ?, updated_at = ? + 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), + ); + + const results = await env.DB.batch(statements); + 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); +} + +// --- restart ------------------------------------------------------------------------------- + +export interface RestartDraftInput { + readonly draftId: string; + readonly ownerUserId: string; + readonly expectedRevision: number; +} + +export async function restartDraft(env: Env, input: RestartDraftInput): Promise { + const draft = await loadOwnedDraft(env, input.draftId, input.ownerUserId); + if (draft.status !== "active") conflict("draft_not_active", "this draft has already been published"); + if (draft.revision !== input.expectedRevision) { + conflict("stale_revision", "expected_revision does not match the draft's current revision"); + } + + const { snapshot, baseVersion } = await loadStartingSnapshot(env, { + ownerUserId: draft.owner_user_id, + originGuildId: draft.origin_guild_id ?? "", + targetScope: draft.target_scope, + guildId: draft.guild_id, + serverMode: draft.server_mode, + }); + + const now = nowIso(); + const newRevision = draft.revision + 1; + const guard = { draftId: draft.id, expectedRevision: draft.revision }; + + const statements: D1PreparedStatement[] = [ + env.DB.prepare( + `DELETE FROM profile_draft_steps WHERE draft_id = ? AND EXISTS (SELECT 1 FROM profile_drafts WHERE id = ? AND revision = ? AND status = 'active')`, + ).bind(draft.id, draft.id, draft.revision), + ...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 = ? + 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), + ]; + + const results = await env.DB.batch(statements); + 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/guildSetupService.ts b/worker/src/profile/guildSetupService.ts new file mode 100644 index 0000000..6c06acf --- /dev/null +++ b/worker/src/profile/guildSetupService.ts @@ -0,0 +1,296 @@ +/** + * Persistent, revision-checked session backing the public `/bill setup` + * wizard: create (or resume the guild's one active session), read, choose + * a channel, and complete -- which atomically writes the `guilds` config + * row and bridges any already-published connected profiles in that guild + * into the legacy registration projection. + * + * Educational note (session/state-machine expiry): unlike the profile + * draft system (which never expires on its own -- a user can return to a + * DM wizard days later), a public per-guild setup message is only useful + * for as long as it stays valid on Discord's side, so every session + * carries a fixed `expires_at`. There is no background job that flips + * expired sessions over; instead, "is this session actually still usable" + * is re-checked lazily on every read/mutation (the same principle as an + * HTTP cookie's `Max-Age`: the check happens when the value is *used*, not + * on a wall-clock timer), and the first caller to notice an expired + * session persists that fact so every later caller sees it too. + */ +import type { Env } from "../env.js"; +import { requireHomeGuildId } from "../env.js"; +import { isSnowflake } from "../util/snowflake.js"; +import { newId, nowIso } from "../util/id.js"; +import { + buildRegistrationProjectionStatements, + collectGuildSetupRegistrationProjections, +} from "./registrationSync.js"; + +export class GuildSetupError extends Error { + readonly status: number; + readonly code: string; + constructor(status: number, code: string, message: string) { + super(message); + this.status = status; + this.code = code; + } +} + +function notFound(message = "Setup session not found"): never { + throw new GuildSetupError(404, "setup_session_not_found", message); +} +function conflict(code: string, message: string): never { + throw new GuildSetupError(409, code, message); +} +function badRequest(code: string, message: string): never { + throw new GuildSetupError(400, code, message); +} +function forbidden(code: string, message: string): never { + throw new GuildSetupError(403, code, message); +} + +const DEFAULT_TTL_SECONDS = 15 * 60; + +type SessionStatus = "active" | "completed" | "cancelled" | "expired"; +type SessionStep = "channel" | "confirm"; + +interface SessionRow { + id: string; + guild_id: string; + initiator_user_id: string; + status: SessionStatus; + current_step: SessionStep; + selected_channel_id: string | null; + revision: number; + public_message_id: string | null; + expires_at: string; + created_at: string; + updated_at: string; + completed_at: string | null; +} + +export interface GuildSetupSessionContract { + readonly id: string; + readonly guildId: string; + readonly initiatorUserId: string; + readonly status: SessionStatus; + readonly currentStep: SessionStep; + readonly selectedChannelId: string | null; + readonly revision: number; + readonly publicMessageId: string | null; + readonly expiresAt: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly completedAt: string | null; +} + +function toContract(row: SessionRow): GuildSetupSessionContract { + return { + id: row.id, + guildId: row.guild_id, + initiatorUserId: row.initiator_user_id, + status: row.status, + currentStep: row.current_step, + selectedChannelId: row.selected_channel_id, + revision: row.revision, + publicMessageId: row.public_message_id, + expiresAt: row.expires_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + completedAt: row.completed_at, + }; +} + +async function findActiveSession(env: Env, guildId: string): Promise { + return env.DB.prepare("SELECT * FROM guild_setup_sessions WHERE guild_id = ? AND status = 'active'") + .bind(guildId) + .first(); +} + +/** Loads a session by id and, if it is `active` but past `expires_at`, lazily flips it to + * `expired` (persisting that so every subsequent read/mutation sees the same terminal state) + * before returning it. This is the single place expiry is actually enforced. */ +async function loadSessionCheckingExpiry(env: Env, sessionId: string): Promise { + const row = await env.DB.prepare("SELECT * FROM guild_setup_sessions WHERE id = ?").bind(sessionId).first(); + if (row === null) notFound(); + if (row.status === "active" && row.expires_at <= nowIso()) { + const now = nowIso(); + await env.DB.prepare("UPDATE guild_setup_sessions SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'active'") + .bind(now, row.id) + .run(); + row.status = "expired"; + row.updated_at = now; + } + return row; +} + +export interface CreateGuildSetupSessionInput { + readonly guildId: string; + readonly initiatorUserId: string; + readonly ttlSeconds?: number; +} + +export interface CreateGuildSetupSessionResult { + readonly resumeRequired: boolean; + readonly session: GuildSetupSessionContract; +} + +/** Starts a new setup session for `guildId`, or returns the guild's already-active one (mirroring + * `profile_drafts`' "resume_required" shape) so re-running `/bill setup` never creates a second, + * conflicting public message. */ +export async function createGuildSetupSession( + env: Env, + input: CreateGuildSetupSessionInput, +): Promise { + if (!isSnowflake(input.guildId)) badRequest("invalid_guild_id", "guild_id must be a Discord snowflake"); + if (!isSnowflake(input.initiatorUserId)) badRequest("invalid_initiator_user_id", "initiator_user_id must be a Discord snowflake"); + + const existing = await findActiveSession(env, input.guildId); + if (existing !== null) { + // An existing session may itself be stale (past its expiry) -- re-check via the id path so + // that case transparently starts a fresh session instead of "resuming" a dead one. + const rechecked = await loadSessionCheckingExpiry(env, existing.id); + if (rechecked.status === "active") return { resumeRequired: true, session: toContract(rechecked) }; + } + + const now = nowIso(); + const ttlSeconds = input.ttlSeconds ?? DEFAULT_TTL_SECONDS; + const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString(); + const sessionId = newId(); + + try { + await env.DB.prepare( + `INSERT INTO guild_setup_sessions + (id, guild_id, initiator_user_id, status, current_step, revision, expires_at, created_at, updated_at) + VALUES (?, ?, ?, 'active', 'channel', 0, ?, ?, ?)`, + ) + .bind(sessionId, input.guildId, input.initiatorUserId, expiresAt, now, now) + .run(); + } catch { + // Lost the race for this guild's one-active-session slot to a concurrent create. + const raced = await findActiveSession(env, input.guildId); + if (raced !== null) return { resumeRequired: true, session: toContract(raced) }; + throw new GuildSetupError(409, "start_conflict", "Could not start a new setup session; please retry"); + } + + const created = await loadSessionCheckingExpiry(env, sessionId); + return { resumeRequired: false, session: toContract(created) }; +} + +export async function getGuildSetupSession(env: Env, sessionId: string): Promise { + const row = await loadSessionCheckingExpiry(env, sessionId); + return toContract(row); +} + +function assertCallbackIdentity(row: SessionRow, guildId: string, initiatorUserId: string): void { + if (row.guild_id !== guildId) forbidden("guild_mismatch", "this session does not belong to that guild"); + if (row.initiator_user_id !== initiatorUserId) { + forbidden("not_initiator", "only the member who started this setup session can act on it"); + } +} + +function assertActiveAndCurrent(row: SessionRow, expectedRevision: number): void { + if (row.status !== "active") conflict("session_not_active", `this setup session is already ${row.status}`); + if (row.revision !== expectedRevision) { + conflict("stale_revision", "expected_revision does not match the session's current revision"); + } +} + +export interface SetGuildSetupChannelInput { + readonly sessionId: string; + readonly guildId: string; + readonly initiatorUserId: string; + readonly expectedRevision: number; + readonly channelId: string; +} + +/** Records the chosen send-notifications channel and advances the session to `confirm`. Channel + * *type* and Bill's own permissions in it are Discord-side facts the bot must verify itself + * before calling this -- the Worker only owns session/guild-config state, not live Discord state. */ +export async function setGuildSetupChannel(env: Env, input: SetGuildSetupChannelInput): Promise { + if (!isSnowflake(input.channelId)) badRequest("invalid_channel_id", "channel_id must be a Discord snowflake"); + + const row = await loadSessionCheckingExpiry(env, input.sessionId); + assertCallbackIdentity(row, input.guildId, input.initiatorUserId); + assertActiveAndCurrent(row, input.expectedRevision); + + const now = nowIso(); + const newRevision = row.revision + 1; + const result = await env.DB.prepare( + `UPDATE guild_setup_sessions + SET selected_channel_id = ?, current_step = 'confirm', revision = ?, updated_at = ? + WHERE id = ? AND revision = ? AND status = 'active'`, + ) + .bind(input.channelId, newRevision, now, row.id, row.revision) + .run(); + if (result.meta.changes === 0) conflict("stale_revision", "expected_revision does not match the session's current revision"); + + return getGuildSetupSession(env, row.id); +} + +export interface CompleteGuildSetupInput { + readonly sessionId: string; + readonly guildId: string; + readonly initiatorUserId: string; + readonly expectedRevision: number; +} + +export interface CompleteGuildSetupResult { + readonly session: GuildSetupSessionContract; + readonly sendChannelId: string; +} + +/** + * Finalizes setup: atomically upserts the guild's `guilds` config row, + * marks the session `completed`, and materializes every non-conflicting + * profile registration. Explicit v1 rows remain authoritative. + */ +export async function completeGuildSetupSession(env: Env, input: CompleteGuildSetupInput): Promise { + const row = await loadSessionCheckingExpiry(env, input.sessionId); + assertCallbackIdentity(row, input.guildId, input.initiatorUserId); + assertActiveAndCurrent(row, input.expectedRevision); + if (row.current_step !== "confirm" || row.selected_channel_id === null) { + badRequest("channel_required", "a channel must be selected before completing setup"); + } + + const now = nowIso(); + const newRevision = row.revision + 1; + const channelId = row.selected_channel_id; + const registrationProjections = await collectGuildSetupRegistrationProjections(env, row.guild_id); + const completedGuard = { + sql: "EXISTS (SELECT 1 FROM guild_setup_sessions WHERE id = ? AND revision = ? AND status = 'completed')", + params: [row.id, newRevision], + }; + + const statements: D1PreparedStatement[] = [ + env.DB.prepare( + `UPDATE guild_setup_sessions + SET status = 'completed', revision = ?, completed_at = ?, updated_at = ? + WHERE id = ? AND revision = ? AND status = 'active'`, + ).bind(newRevision, now, now, row.id, row.revision), + env.DB.prepare( + `INSERT INTO guilds (guild_id, send_channel_id, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (guild_id) DO UPDATE SET + send_channel_id = excluded.send_channel_id, + updated_at = excluded.updated_at + WHERE EXISTS (SELECT 1 FROM guild_setup_sessions WHERE id = ? AND revision = ? AND status = 'completed')`, + ).bind(row.guild_id, channelId, now, now, row.id, newRevision), + ...buildRegistrationProjectionStatements(env, registrationProjections, completedGuard), + ]; + + const results = await env.DB.batch(statements); + const guardResult = results[0]; + if (guardResult === undefined || guardResult.meta.changes === 0) { + conflict("stale_revision", "expected_revision does not match the session's current revision"); + } + + const completed = await getGuildSetupSession(env, row.id); + return { session: completed, sendChannelId: channelId }; +} + +/** Re-exported so route handlers can distinguish "home guild required" style checks if a future + * caller needs it; unused today but keeps this module the single home-guild-aware entry point + * for guild setup. */ +export function isHomeGuild(env: Env, guildId: string): boolean { + return guildId === requireHomeGuildId(env); +} diff --git a/worker/src/profile/importer/extract.ts b/worker/src/profile/importer/extract.ts new file mode 100644 index 0000000..6a0d871 --- /dev/null +++ b/worker/src/profile/importer/extract.ts @@ -0,0 +1,48 @@ +/** + * Static HTML anchor extraction using Cloudflare's `HTMLRewriter`, a + * streaming HTML transform/parser (not a JavaScript engine): it can select + * elements by CSS selector and read their attributes/text as the markup + * streams past, but it never executes any ``; + const anchors = await extractAnchors(html, "a[href]", 100); + expect(anchors).toEqual([]); + }); + + it("never executes a script tag's contents even if it looks like markup", async () => { + const html = ``; + const anchors = await extractAnchors(html, "a[href]", 100); + expect(anchors).toEqual([]); + }); + + it("caps the number of extracted anchors at maxAnchors", async () => { + const links = Array.from({ length: 10 }, (_, i) => `Link ${i}`).join("\n"); + const anchors = await extractAnchors(links, "a[href]", 3); + expect(anchors).toHaveLength(3); + expect(anchors[0]?.href).toBe("https://example.com/0"); + }); +}); diff --git a/worker/test/importer/fetchSafely.test.ts b/worker/test/importer/fetchSafely.test.ts new file mode 100644 index 0000000..1920d9a --- /dev/null +++ b/worker/test/importer/fetchSafely.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { fetchHtmlSafely, MAX_REDIRECTS, MAX_RESPONSE_BYTES, type ImporterDeps } from "../../src/profile/importer/fetchSafely"; +import { ImportBlockedError } from "../../src/profile/importer/ssrf"; + +const PUBLIC_IP = "93.184.216.34"; + +function htmlResponse(body: string, extraHeaders?: Record): Response { + return new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8", ...extraHeaders } }); +} + +function redirectResponse(location: string): Response { + return new Response(null, { status: 302, headers: { location } }); +} + +/** A deps object whose DNS resolver always says "public" and whose fetch is driven by a queue of + * canned responses (or a handler function), so every SSRF-relevant behavior can be tested without + * any real network access. */ +function makeDeps(responder: (url: string) => Response | Promise): ImporterDeps { + return { + fetchImpl: (async (input: RequestInfo | URL) => responder(input.toString())) as typeof fetch, + resolveIps: async () => [PUBLIC_IP], + }; +} + +describe("fetchHtmlSafely", () => { + it("fetches and returns HTML for a simple public URL", async () => { + const deps = makeDeps(() => htmlResponse("hi")); + const result = await fetchHtmlSafely("https://example.com/page", deps); + expect(result.html).toContain("hi"); + expect(result.finalUrl).toBe("https://example.com/page"); + }); + + it("follows up to MAX_REDIRECTS redirects, re-validating and re-resolving each hop", async () => { + let calls = 0; + const resolvedHosts: string[] = []; + const deps: ImporterDeps = { + fetchImpl: (async (input: RequestInfo | URL) => { + calls++; + const url = input.toString(); + if (url === "https://example.com/start") return redirectResponse("https://example.com/mid"); + if (url === "https://example.com/mid") return redirectResponse("https://example.com/final"); + return htmlResponse("done"); + }) as typeof fetch, + resolveIps: async (hostname: string) => { + resolvedHosts.push(hostname); + return [PUBLIC_IP]; + }, + }; + const result = await fetchHtmlSafely("https://example.com/start", deps); + expect(result.finalUrl).toBe("https://example.com/final"); + expect(calls).toBe(3); + expect(resolvedHosts).toEqual(["example.com", "example.com", "example.com"]); + }); + + it("rejects once redirects exceed MAX_REDIRECTS", async () => { + let hop = 0; + const deps = makeDeps(() => { + hop++; + return redirectResponse(`https://example.com/hop${hop}`); + }); + await expect(fetchHtmlSafely("https://example.com/start", deps)).rejects.toThrowError( + expect.objectContaining({ code: "too_many_redirects" }), + ); + expect(hop).toBe(MAX_REDIRECTS + 1); + }); + + it("re-validates and blocks a redirect target that resolves to a private address", async () => { + const deps: ImporterDeps = { + fetchImpl: (async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://public.example/") return redirectResponse("https://internal.example/"); + return htmlResponse("should never get here"); + }) as typeof fetch, + resolveIps: async (hostname: string) => (hostname === "internal.example" ? ["10.0.0.5"] : [PUBLIC_IP]), + }; + await expect(fetchHtmlSafely("https://public.example/", deps)).rejects.toThrowError( + expect.objectContaining({ code: "blocked_destination" }), + ); + }); + + it("rejects a redirect to a non-https target", async () => { + const deps = makeDeps((url) => (url === "https://example.com/" ? redirectResponse("http://example.com/insecure") : htmlResponse(""))); + await expect(fetchHtmlSafely("https://example.com/", deps)).rejects.toThrowError( + expect.objectContaining({ code: "invalid_scheme" }), + ); + }); + + it("rejects a redirect response with no Location header", async () => { + const deps = makeDeps(() => new Response(null, { status: 302 })); + await expect(fetchHtmlSafely("https://example.com/", deps)).rejects.toThrowError( + expect.objectContaining({ code: "invalid_redirect" }), + ); + }); + + it("rejects non-HTML content types", async () => { + const deps = makeDeps(() => new Response("{}", { status: 200, headers: { "content-type": "application/json" } })); + await expect(fetchHtmlSafely("https://example.com/", deps)).rejects.toThrowError( + expect.objectContaining({ code: "unsupported_content_type" }), + ); + }); + + it("rejects a response whose Content-Length exceeds the size cap", async () => { + const deps = makeDeps(() => htmlResponse("", { "content-length": String(MAX_RESPONSE_BYTES + 1) })); + await expect(fetchHtmlSafely("https://example.com/", deps)).rejects.toThrowError( + expect.objectContaining({ code: "response_too_large" }), + ); + }); + + it("aborts a stream that exceeds the size cap even without a Content-Length header", async () => { + const oversized = "a".repeat(MAX_RESPONSE_BYTES + 1024); + const deps = makeDeps(() => htmlResponse(oversized)); + await expect(fetchHtmlSafely("https://example.com/", deps)).rejects.toThrowError( + expect.objectContaining({ code: "response_too_large" }), + ); + }); + + it("rejects a non-2xx, non-redirect response status", async () => { + const deps = makeDeps(() => new Response("nope", { status: 500 })); + await expect(fetchHtmlSafely("https://example.com/", deps)).rejects.toThrowError( + expect.objectContaining({ code: "fetch_failed" }), + ); + }); + + it("wraps a thrown fetch error (e.g. an aborted/timed-out request) as fetch_failed", async () => { + const deps: ImporterDeps = { + fetchImpl: (async () => { + throw new Error("simulated network failure"); + }) as unknown as typeof fetch, + resolveIps: async () => [PUBLIC_IP], + }; + await expect(fetchHtmlSafely("https://example.com/", deps)).rejects.toThrowError( + expect.objectContaining({ code: "fetch_failed" }), + ); + }); + + it("uses one total timeout across DNS, redirects, and body fetching", async () => { + const deps: ImporterDeps = { + timeoutMs: 5, + resolveIps: async () => [PUBLIC_IP], + fetchImpl: (async (_input: RequestInfo | URL, init?: RequestInit) => { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 25); + init?.signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(new DOMException("aborted", "AbortError")); + }, + { once: true }, + ); + }); + return redirectResponse("https://example.com/next"); + }) as typeof fetch, + }; + + await expect(fetchHtmlSafely("https://example.com/start", deps)).rejects.toThrowError( + expect.objectContaining({ code: "fetch_failed" }), + ); + }); + + it("runs the DNS preflight before ever calling fetch, and blocks if it fails", async () => { + let fetchCalled = false; + const deps: ImporterDeps = { + fetchImpl: (async () => { + fetchCalled = true; + return htmlResponse(""); + }) as typeof fetch, + resolveIps: async () => ["10.0.0.1"], + }; + await expect(fetchHtmlSafely("https://blocked.example/", deps)).rejects.toBeInstanceOf(ImportBlockedError); + expect(fetchCalled).toBe(false); + }); + + it("rejects the initial URL before any network call for format violations", async () => { + let fetchCalled = false; + const deps: ImporterDeps = { + fetchImpl: (async () => { + fetchCalled = true; + return htmlResponse(""); + }) as typeof fetch, + resolveIps: async () => [PUBLIC_IP], + }; + await expect(fetchHtmlSafely("http://example.com/", deps)).rejects.toThrowError( + expect.objectContaining({ code: "invalid_scheme" }), + ); + expect(fetchCalled).toBe(false); + }); +}); diff --git a/worker/test/importer/index.test.ts b/worker/test/importer/index.test.ts new file mode 100644 index 0000000..46eda4b --- /dev/null +++ b/worker/test/importer/index.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { classifyImportFailureStatus, runLinkImport } from "../../src/profile/importer/index"; +import { ImportBlockedError } from "../../src/profile/importer/ssrf"; +import type { ImporterDeps } from "../../src/profile/importer/fetchSafely"; + +const PUBLIC_IP = "93.184.216.34"; + +function htmlResponse(body: string): Response { + return new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }); +} + +function depsFor(html: string): ImporterDeps { + return { + fetchImpl: (async () => htmlResponse(html)) as typeof fetch, + resolveIps: async () => [PUBLIC_IP], + }; +} + +describe("runLinkImport", () => { + it("extracts, classifies, and normalizes candidates from a generic page", async () => { + const html = ` + Twitter + Tip me + About this page + `; + const outcome = await runLinkImport("https://mypage.example/", depsFor(html)); + expect(outcome.provider).toBe("generic"); + expect(outcome.status).toBe("ready"); + expect(outcome.candidates).toEqual([ + { platform: "twitter", publicLabel: "Twitter", username: null, normalizedUrl: "https://twitter.com/example", linkType: "social" }, + { platform: "cashapp", publicLabel: "Tip me", username: null, normalizedUrl: "https://cash.app/$example", linkType: "payment" }, + ]); + }); + + it("filters out same-host links (nav/footer) and unknown-provider defaults to social", async () => { + const html = ` + Privacy + My unknown service + `; + const outcome = await runLinkImport("https://mypage.example/", depsFor(html)); + expect(outcome.candidates).toEqual([ + { + platform: "unknown-service.example", + publicLabel: "My unknown service", + username: null, + normalizedUrl: "https://unknown-service.example/u/x", + linkType: "social", + }, + ]); + }); + + it("uses the Linktree-specific selector when the host is linktr.ee", async () => { + const html = ` + Should be ignored (generic-only nav link) + OnlyFans + `; + const outcome = await runLinkImport("https://linktr.ee/example", depsFor(html)); + expect(outcome.provider).toBe("linktree"); + expect(outcome.candidates).toEqual([ + { platform: "onlyfans", publicLabel: "OnlyFans", username: null, normalizedUrl: "https://onlyfans.com/example", linkType: "social" }, + ]); + }); + + it("falls back to the generic selector when a provider's specific selector matches nothing", async () => { + const html = `OnlyFans`; + const outcome = await runLinkImport("https://linktr.ee/example", depsFor(html)); + expect(outcome.provider).toBe("linktree"); + expect(outcome.candidates).toEqual([ + { platform: "onlyfans", publicLabel: "OnlyFans", username: null, normalizedUrl: "https://onlyfans.com/example", linkType: "social" }, + ]); + }); + + it("reports no_links_found for a JS-only page with no anchors at all", async () => { + const outcome = await runLinkImport("https://mypage.example/", depsFor(`
`)); + expect(outcome.status).toBe("no_links_found"); + expect(outcome.candidates).toEqual([]); + }); + + it("deduplicates candidates that normalize to the same URL", async () => { + const html = ` + Twitter + Twitter again + `; + const outcome = await runLinkImport("https://mypage.example/", depsFor(html)); + expect(outcome.candidates).toHaveLength(1); + }); + + it("drops credential-bearing and oversized public link candidates", async () => { + const oversized = `https://example.com/${"x".repeat(600)}`; + const html = ` + Secret + Oversized + Safe + `; + const outcome = await runLinkImport("https://mypage.example/", depsFor(html)); + expect(outcome.candidates).toEqual([ + { + platform: "twitter", + publicLabel: "Safe", + username: null, + normalizedUrl: "https://twitter.com/example", + linkType: "social", + }, + ]); + }); + + it("caps candidates at twelve even with many unique anchors", async () => { + const html = Array.from({ length: 20 }, (_, i) => `Service ${i}`).join("\n"); + const outcome = await runLinkImport("https://mypage.example/", depsFor(html)); + expect(outcome.candidates).toHaveLength(12); + }); + + it("propagates an ImportBlockedError for a policy-violating source URL", async () => { + await expect(runLinkImport("http://mypage.example/", depsFor(""))).rejects.toBeInstanceOf(ImportBlockedError); + }); +}); + +describe("classifyImportFailureStatus", () => { + it("maps SSRF/format policy violations to blocked", () => { + expect(classifyImportFailureStatus(new ImportBlockedError("invalid_scheme", "x"))).toBe("blocked"); + expect(classifyImportFailureStatus(new ImportBlockedError("ip_literal_blocked", "x"))).toBe("blocked"); + expect(classifyImportFailureStatus(new ImportBlockedError("blocked_destination", "x"))).toBe("blocked"); + expect(classifyImportFailureStatus(new ImportBlockedError("too_many_redirects", "x"))).toBe("blocked"); + }); + + it("maps ordinary fetch/content problems to fetch_failed", () => { + expect(classifyImportFailureStatus(new ImportBlockedError("fetch_failed", "x"))).toBe("fetch_failed"); + expect(classifyImportFailureStatus(new ImportBlockedError("unsupported_content_type", "x"))).toBe("fetch_failed"); + expect(classifyImportFailureStatus(new ImportBlockedError("response_too_large", "x"))).toBe("fetch_failed"); + }); +}); diff --git a/worker/test/importer/ssrf.test.ts b/worker/test/importer/ssrf.test.ts new file mode 100644 index 0000000..87ccaf7 --- /dev/null +++ b/worker/test/importer/ssrf.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { + ImportBlockedError, + isBlockedIpAddress, + isBlockedIpv4, + isBlockedIpv6, + parseIpv4, + parseIpv6, + preflightDns, + validateCandidateUrl, +} from "../../src/profile/importer/ssrf"; + +describe("parseIpv4/parseIpv6", () => { + it("parses valid dotted-quad IPv4 literals", () => { + expect(parseIpv4("192.168.1.1")).toEqual([192, 168, 1, 1]); + expect(parseIpv4("8.8.8.8")).toEqual([8, 8, 8, 8]); + }); + + it("rejects malformed IPv4-looking strings", () => { + expect(parseIpv4("999.1.1.1")).toBeNull(); + expect(parseIpv4("example.com")).toBeNull(); + expect(parseIpv4("1.2.3")).toBeNull(); + }); + + it("parses compressed and full IPv6 literals", () => { + expect(parseIpv6("::1")).toEqual([0, 0, 0, 0, 0, 0, 0, 1]); + expect(parseIpv6("2001:db8::1")).toEqual([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1]); + expect(parseIpv6("fe80::1")).toEqual([0xfe80, 0, 0, 0, 0, 0, 0, 1]); + }); + + it("parses IPv4-mapped IPv6 literals", () => { + expect(parseIpv6("::ffff:169.254.169.254")).toEqual([0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe]); + }); + + it("rejects non-IPv6 strings", () => { + expect(parseIpv6("not-an-ip")).toBeNull(); + expect(parseIpv6("192.168.1.1")).toBeNull(); + }); +}); + +describe("isBlockedIpv4", () => { + it("blocks loopback, private, link-local (incl. cloud metadata), and CGNAT ranges", () => { + expect(isBlockedIpv4([127, 0, 0, 1])).toBe(true); + expect(isBlockedIpv4([10, 1, 2, 3])).toBe(true); + expect(isBlockedIpv4([172, 16, 0, 1])).toBe(true); + expect(isBlockedIpv4([172, 31, 255, 255])).toBe(true); + expect(isBlockedIpv4([172, 32, 0, 1])).toBe(false); + expect(isBlockedIpv4([192, 168, 0, 1])).toBe(true); + expect(isBlockedIpv4([169, 254, 169, 254])).toBe(true); // cloud metadata + expect(isBlockedIpv4([100, 64, 0, 1])).toBe(true); + expect(isBlockedIpv4([100, 128, 0, 1])).toBe(false); + }); + + it("blocks multicast, reserved, documentation, and broadcast ranges", () => { + expect(isBlockedIpv4([224, 0, 0, 1])).toBe(true); + expect(isBlockedIpv4([0, 0, 0, 0])).toBe(true); + expect(isBlockedIpv4([192, 0, 2, 1])).toBe(true); + expect(isBlockedIpv4([198, 51, 100, 1])).toBe(true); + expect(isBlockedIpv4([203, 0, 113, 1])).toBe(true); + expect(isBlockedIpv4([255, 255, 255, 255])).toBe(true); + }); + + it("allows ordinary public addresses", () => { + expect(isBlockedIpv4([8, 8, 8, 8])).toBe(false); + expect(isBlockedIpv4([93, 184, 216, 34])).toBe(false); + }); +}); + +describe("isBlockedIpv6", () => { + it("blocks unspecified, loopback, unique-local, link-local, and multicast", () => { + expect(isBlockedIpv6([0, 0, 0, 0, 0, 0, 0, 0])).toBe(true); + expect(isBlockedIpv6([0, 0, 0, 0, 0, 0, 0, 1])).toBe(true); + expect(isBlockedIpv6([0xfc00, 0, 0, 0, 0, 0, 0, 1])).toBe(true); + expect(isBlockedIpv6([0xfe80, 0, 0, 0, 0, 0, 0, 1])).toBe(true); + expect(isBlockedIpv6([0xff02, 0, 0, 0, 0, 0, 0, 1])).toBe(true); + }); + + it("blocks documentation and an IPv4-mapped address that maps to a blocked IPv4 range", () => { + expect(isBlockedIpv6([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1])).toBe(true); + expect(isBlockedIpv6([0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe])).toBe(true); // ::ffff:169.254.169.254 + }); + + it("allows an IPv4-mapped address that maps to a public IPv4 range", () => { + expect(isBlockedIpv6([0, 0, 0, 0, 0, 0xffff, 0x0808, 0x0808])).toBe(false); // ::ffff:8.8.8.8 + }); + + it("allows an ordinary public IPv6 address", () => { + expect(isBlockedIpv6([0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111])).toBe(false); + }); +}); + +describe("isBlockedIpAddress", () => { + it("dispatches to the IPv4/IPv6 checkers and blocks unparsable input", () => { + expect(isBlockedIpAddress("127.0.0.1")).toBe(true); + expect(isBlockedIpAddress("8.8.8.8")).toBe(false); + expect(isBlockedIpAddress("::1")).toBe(true); + expect(isBlockedIpAddress("not-an-ip-at-all")).toBe(true); + }); +}); + +describe("validateCandidateUrl", () => { + it("accepts a well-formed public https URL", () => { + const url = validateCandidateUrl("https://linktr.ee/someone"); + expect(url.hostname).toBe("linktr.ee"); + }); + + it("rejects non-https schemes", () => { + expect(() => validateCandidateUrl("http://example.com")).toThrow(ImportBlockedError); + try { + validateCandidateUrl("http://example.com"); + } catch (error) { + expect((error as ImportBlockedError).code).toBe("invalid_scheme"); + } + }); + + it("rejects embedded credentials", () => { + expect(() => validateCandidateUrl("https://user:pass@example.com")).toThrowError( + expect.objectContaining({ code: "credentials_in_url" }), + ); + }); + + it("rejects non-default ports", () => { + expect(() => validateCandidateUrl("https://example.com:8443/")).toThrowError( + expect.objectContaining({ code: "unexpected_port" }), + ); + }); + + it("rejects IPv4 and IPv6 literal hosts", () => { + expect(() => validateCandidateUrl("https://192.168.1.1/")).toThrowError( + expect.objectContaining({ code: "ip_literal_blocked" }), + ); + expect(() => validateCandidateUrl("https://[::1]/")).toThrowError( + expect.objectContaining({ code: "ip_literal_blocked" }), + ); + }); + + it("rejects localhost-style and internal-looking hostnames", () => { + for (const host of ["localhost", "foo.localhost", "printer.local", "service.internal", "metadata.google.internal"]) { + expect(() => validateCandidateUrl(`https://${host}/`)).toThrowError( + expect.objectContaining({ code: "blocked_destination" }), + ); + } + }); + + it("rejects malformed URLs", () => { + expect(() => validateCandidateUrl("not a url")).toThrowError(expect.objectContaining({ code: "invalid_url" })); + }); +}); + +describe("preflightDns", () => { + it("passes when every resolved address is public", async () => { + await expect(preflightDns("example.com", async () => ["93.184.216.34"])).resolves.toBeUndefined(); + }); + + it("rejects when any resolved address is private/internal", async () => { + await expect(preflightDns("evil.example", async () => ["93.184.216.34", "10.0.0.5"])).rejects.toThrowError( + expect.objectContaining({ code: "blocked_destination" }), + ); + }); + + it("rejects when resolution returns no addresses at all", async () => { + await expect(preflightDns("nowhere.example", async () => [])).rejects.toThrowError( + expect.objectContaining({ code: "dns_resolution_failed" }), + ); + }); +}); diff --git a/worker/test/linkImportService.test.ts b/worker/test/linkImportService.test.ts new file mode 100644 index 0000000..313d804 --- /dev/null +++ b/worker/test/linkImportService.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; +import { env, fetchMock } from "cloudflare:test"; +import { authHeaders, callWorker, jsonRequest, readJson, TEST_HOME_GUILD_ID } from "./helpers"; +import { createLinkImport, confirmLinkImport } from "../src/profile/linkImportService"; +import type { ImporterDeps } from "../src/profile/importer/fetchSafely"; + +interface DraftEnvelope { + data: { draft: { id: string; revision: number } }; +} + +async function startGlobalDraft(owner: string) { + const response = await callWorker( + jsonRequest("POST", "/v1/profile-drafts/start", { owner_user_id: owner, origin_guild_id: TEST_HOME_GUILD_ID, target_scope: "global" }, authHeaders()), + ); + const parsed = await readJson(response); + return parsed.data.draft; +} + +async function setOrientation(draftId: string, owner: string, revision: number, orientation: string) { + const response = await callWorker( + jsonRequest("PUT", `/v1/profile-drafts/${draftId}/steps/orientation`, { owner_user_id: owner, expected_revision: revision, orientation }, authHeaders()), + ); + const parsed = await readJson(response); + return parsed.data.draft; +} + +const PUBLIC_IP = "93.184.216.34"; + +function depsFor(html: string): ImporterDeps { + return { + fetchImpl: (async () => new Response(html, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } })) as typeof fetch, + resolveIps: async () => [PUBLIC_IP], + }; +} + +describe("link import service (direct, injected deps)", () => { + it("creates an import with normalized/classified candidates", async () => { + const owner = "700000000000000001"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const html = `TwitterTip`; + const created = await createLinkImport( + env, + { draftId: started.id, ownerUserId: owner, expectedRevision: 1, sourceUrl: "https://linkpage.example/me" }, + depsFor(html), + ); + const importContract = created.importContract; + expect(importContract.status).toBe("ready"); + expect(importContract.provider).toBe("generic"); + expect(importContract.candidates).toHaveLength(2); + expect(importContract.candidates.every((c) => c.selected)).toBe(true); + expect(created.draft.revision).toBe(2); + }); + + it("records a blocked import (no candidates) for an SSRF-violating source URL, without failing the request", async () => { + const owner = "700000000000000002"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const created = await createLinkImport( + env, + { draftId: started.id, ownerUserId: owner, expectedRevision: 1, sourceUrl: "https://10.0.0.5/" }, + { fetchImpl: (async () => new Response("")) as typeof fetch, resolveIps: async () => [] }, + ); + const importContract = created.importContract; + expect(importContract.status).toBe("blocked"); + expect(importContract.candidates).toEqual([]); + expect(created.draft.revision).toBe(2); + }); + + it("confirms an import: promotes selected candidates into the draft's own links atomically", async () => { + const owner = "700000000000000003"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const html = `TwitterTip`; + const created = await createLinkImport( + env, + { draftId: started.id, ownerUserId: owner, expectedRevision: 1, sourceUrl: "https://linkpage.example/me" }, + depsFor(html), + ); + const importContract = created.importContract; + + const confirmed = await confirmLinkImport(env, { + draftId: started.id, + importId: importContract.id, + ownerUserId: owner, + expectedRevision: created.draft.revision, + candidateIds: null, + }); + expect(confirmed.addedLinkCount).toBe(2); + expect(confirmed.skippedDuplicateCount).toBe(0); + expect(confirmed.draft.document.links).toHaveLength(2); + expect(confirmed.draft.revision).toBe(3); + }); + + it("confirming only a subset of candidate ids promotes just those", async () => { + const owner = "700000000000000004"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const html = `TwitterTip`; + const created = await createLinkImport( + env, + { draftId: started.id, ownerUserId: owner, expectedRevision: 1, sourceUrl: "https://linkpage.example/me" }, + depsFor(html), + ); + const importContract = created.importContract; + const twitterCandidateId = importContract.candidates.find((c) => c.platform === "twitter")!.id; + + const confirmed = await confirmLinkImport(env, { + draftId: started.id, + importId: importContract.id, + ownerUserId: owner, + expectedRevision: created.draft.revision, + candidateIds: [twitterCandidateId], + }); + expect(confirmed.addedLinkCount).toBe(1); + expect(confirmed.draft.document.links).toHaveLength(1); + expect(confirmed.draft.document.links[0]?.platform).toBe("twitter"); + }); + + it("skips a candidate that duplicates an already-existing link", async () => { + const owner = "700000000000000005"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + // Manually add a link first via the manual-CRUD route. + const addResponse = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${started.id}/links`, + { owner_user_id: owner, expected_revision: 1, public_label: "Twitter", normalized_url: "https://twitter.com/example" }, + authHeaders(), + ), + ); + const added = await readJson(addResponse); + + const html = `TwitterTip`; + const created = await createLinkImport( + env, + { draftId: started.id, ownerUserId: owner, expectedRevision: added.data.draft.revision, sourceUrl: "https://linkpage.example/me" }, + depsFor(html), + ); + const importContract = created.importContract; + + const confirmed = await confirmLinkImport(env, { + draftId: started.id, + importId: importContract.id, + ownerUserId: owner, + expectedRevision: created.draft.revision, + candidateIds: null, + }); + expect(confirmed.addedLinkCount).toBe(1); + expect(confirmed.skippedDuplicateCount).toBe(1); + expect(confirmed.draft.document.links).toHaveLength(2); + }); + + it("filters payment candidates for a submissive profile", async () => { + const owner = "700000000000000006"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "submissive"); + + const created = await createLinkImport( + env, + { + draftId: started.id, + ownerUserId: owner, + expectedRevision: 1, + sourceUrl: "https://linkpage.example/me", + }, + depsFor( + `TwitterTip`, + ), + ); + + expect(created.importContract.candidates).toEqual([ + expect.objectContaining({ platform: "twitter", linkType: "social" }), + ]); + }); + + it("revalidates stored candidates before promoting them to public links", async () => { + const owner = "700000000000000007"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + const created = await createLinkImport( + env, + { + draftId: started.id, + ownerUserId: owner, + expectedRevision: 1, + sourceUrl: "https://linkpage.example/me", + }, + depsFor(`Twitter`), + ); + await env.DB.prepare( + "UPDATE profile_link_import_candidates SET normalized_url = ? WHERE import_id = ?", + ) + .bind("https://user:password@example.com/private", created.importContract.id) + .run(); + + await expect( + confirmLinkImport(env, { + draftId: started.id, + importId: created.importContract.id, + ownerUserId: owner, + expectedRevision: created.draft.revision, + candidateIds: null, + }), + ).rejects.toThrowError(expect.objectContaining({ code: "invalid_url_credentials" })); + }); +}); + +describe("link import HTTP routes (real fetch wiring via fetchMock)", () => { + it("fetches and stores candidates through the actual route, hitting DNS-over-HTTPS and the target host", async () => { + fetchMock.activate(); + fetchMock.disableNetConnect(); + + const owner = "700000000000000010"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + fetchMock + .get("https://cloudflare-dns.com") + .intercept({ method: "GET", path: /\/dns-query\?name=mypage\.example&type=A/ }) + .reply(200, { Status: 0, Answer: [{ type: 1, data: "93.184.216.34" }] }, { headers: { "content-type": "application/dns-json" } }); + fetchMock + .get("https://cloudflare-dns.com") + .intercept({ method: "GET", path: /\/dns-query\?name=mypage\.example&type=AAAA/ }) + .reply(200, { Status: 0 }, { headers: { "content-type": "application/dns-json" } }); + fetchMock + .get("https://mypage.example") + .intercept({ method: "GET", path: "/" }) + .reply(200, `Twitter`, { headers: { "content-type": "text/html" } }); + + const response = await callWorker( + jsonRequest( + "POST", + `/v1/profile-drafts/${started.id}/link-imports`, + { owner_user_id: owner, expected_revision: 1, source_url: "https://mypage.example/" }, + authHeaders(), + ), + ); + expect(response.status).toBe(201); + const body = await readJson<{ + data: { + import: { status: string; candidates: { platform: string }[] }; + draft: { revision: number }; + }; + }>(response); + expect(body.data.import.status).toBe("ready"); + expect(body.data.import.candidates).toEqual([expect.objectContaining({ platform: "twitter" })]); + expect(body.data.draft.revision).toBe(2); + }); +}); diff --git a/worker/test/linkProviders.test.ts b/worker/test/linkProviders.test.ts new file mode 100644 index 0000000..6a02eb5 --- /dev/null +++ b/worker/test/linkProviders.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { classifyKnownProvider } from "../src/profile/linkProviders"; + +describe("classifyKnownProvider", () => { + it("classifies known social platforms", () => { + expect(classifyKnownProvider("https://twitter.com/example")).toEqual({ platform: "twitter", linkType: "social" }); + expect(classifyKnownProvider("https://x.com/example")).toEqual({ platform: "twitter", linkType: "social" }); + expect(classifyKnownProvider("https://www.instagram.com/example")).toEqual({ platform: "instagram", linkType: "social" }); + }); + + it("classifies known payment platforms", () => { + expect(classifyKnownProvider("https://cash.app/$example")).toEqual({ platform: "cashapp", linkType: "payment" }); + expect(classifyKnownProvider("https://throne.com/example")).toEqual({ platform: "throne", linkType: "payment" }); + expect(classifyKnownProvider("https://paypal.me/example")).toEqual({ platform: "paypal", linkType: "payment" }); + }); + + it("strips a leading www. before matching", () => { + expect(classifyKnownProvider("https://www.cash.app/$example")).toEqual({ platform: "cashapp", linkType: "payment" }); + }); + + it("returns null for unrecognized hosts", () => { + expect(classifyKnownProvider("https://my-own-site.example/")).toBeNull(); + }); + + it("returns null for a malformed URL", () => { + expect(classifyKnownProvider("not a url")).toBeNull(); + }); +}); diff --git a/worker/test/migrations.test.ts b/worker/test/migrations.test.ts new file mode 100644 index 0000000..30cf456 --- /dev/null +++ b/worker/test/migrations.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { seedGuild, seedCreator, seedNotificationChain } from "./helpers"; + +/** + * `vitest.config.ts`'s global setup already applies every migration (0001 + * and 0002) before each test file, so by the time a normal test runs the + * profile tables already exist. To actually exercise "0002 applies cleanly + * on top of an already-populated 0001 database", this test rewinds D1 back + * to a 0001-only state -- drop 0002's tables and forget its bookkeeping row + * -- inserts data through the *existing* 0001 tables, and then reapplies + * migrations so 0002 runs for real against that populated database. + */ +describe("0002 migration additive safety", () => { + it("applies over populated 0001 data without altering or losing any existing row", async () => { + const profileTables = [ + "profile_draft_steps", + "profile_drafts", + "profile_publications", + "server_profiles", + "global_profiles", + "profile_link_visibility", + "profile_document_overrides", + "profile_links", + "profile_aliases", + "profile_document_selections", + "profile_documents", + ]; + + // Rewind: drop everything 0002 created and forget that it ran. + for (const table of profileTables) { + await env.DB.prepare(`DROP TABLE IF EXISTS ${table}`).run(); + } + await env.DB.prepare("DELETE FROM d1_migrations WHERE name = '0002_profile_system.sql'").run(); + + const remainingTables = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ).all<{ name: string }>(); + const remainingNames = remainingTables.results.map((row) => row.name); + for (const table of profileTables) { + expect(remainingNames).not.toContain(table); + } + // 0001's tables must still be intact after the rewind. + expect(remainingNames).toEqual( + expect.arrayContaining(["guilds", "throne_creators", "domme_registrations", "throne_events", "sends", "notifications"]), + ); + + // Populate the 0001-only schema exactly like a live, already-running deployment would. + await seedGuild("900000000000000001", "900000000000000002"); + const creator = await seedCreator({ id: "creator-preexisting", ownerDiscordUserId: "900000000000000003" }); + const chain = await seedNotificationChain({ + guildId: "900000000000000001", + creatorId: creator.id, + senderUsername: "existing-supporter", + amountMinor: 2500, + }); + + const guildRowBefore = await env.DB.prepare("SELECT * FROM guilds WHERE guild_id = ?") + .bind("900000000000000001") + .first(); + const creatorRowBefore = await env.DB.prepare("SELECT * FROM throne_creators WHERE id = ?") + .bind(creator.id) + .first(); + const sendRowBefore = await env.DB.prepare("SELECT * FROM sends WHERE id = ?").bind(chain.sendId).first(); + const notificationRowBefore = await env.DB.prepare("SELECT * FROM notifications WHERE id = ?") + .bind(chain.notificationId) + .first(); + + // Reapply migrations: 0001 is already recorded as applied and is skipped; + // 0002 is not, so it runs for real against this populated database. + await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); + + const tablesAfter = await env.DB.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all<{ + name: string; + }>(); + const namesAfter = tablesAfter.results.map((row) => row.name); + for (const table of profileTables) { + expect(namesAfter).toContain(table); + } + + const guildRowAfter = await env.DB.prepare("SELECT * FROM guilds WHERE guild_id = ?") + .bind("900000000000000001") + .first(); + const creatorRowAfter = await env.DB.prepare("SELECT * FROM throne_creators WHERE id = ?") + .bind(creator.id) + .first(); + const sendRowAfter = await env.DB.prepare("SELECT * FROM sends WHERE id = ?").bind(chain.sendId).first(); + const notificationRowAfter = await env.DB.prepare("SELECT * FROM notifications WHERE id = ?") + .bind(chain.notificationId) + .first(); + + expect(guildRowAfter).toEqual(guildRowBefore); + expect(creatorRowAfter).toEqual(creatorRowBefore); + expect(sendRowAfter).toEqual(sendRowBefore); + expect(notificationRowAfter).toEqual(notificationRowBefore); + + // And the new profile schema is immediately usable. + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO profile_documents (id, owner_user_id, state, orientation, dm_status, created_at, updated_at) + VALUES ('doc-1', '900000000000000003', 'draft', 'domme', 'open', ?, ?)`, + ) + .bind(now, now) + .run(); + const doc = await env.DB.prepare("SELECT * FROM profile_documents WHERE id = 'doc-1'").first(); + expect(doc).not.toBeNull(); + }); +}); diff --git a/worker/test/migrations0003.test.ts b/worker/test/migrations0003.test.ts new file mode 100644 index 0000000..7027da6 --- /dev/null +++ b/worker/test/migrations0003.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { seedGuild, seedCreator, seedNotificationChain } from "./helpers"; +import { seedAlias, seedDocument, seedGlobalProfile } from "./profileHelpers"; + +/** + * Mirrors `migrations.test.ts`'s approach for 0002: rewind D1 back to a + * 0001+0002-only state (dropping only what 0003 created and forgetting its + * bookkeeping row), insert data through the tables that already existed at + * that point (including a `sends` row with no `sender_discord_user_id`), + * then reapply migrations so 0003 runs for real against that populated + * database and must not alter or lose a single existing row. + */ +describe("0003 migration additive safety", () => { + it("applies over populated 0001+0002 data without altering or losing any existing row", async () => { + const newTables = ["guild_setup_sessions", "profile_link_import_candidates", "profile_link_imports"]; + + for (const table of newTables) { + await env.DB.prepare(`DROP TABLE IF EXISTS ${table}`).run(); + } + // SQLite cannot drop individual columns. Rewind the two altered v1 tables + // to their exact pre-0003 shapes before replaying the migration. + await env.DB.prepare( + `CREATE TABLE sends_pre0003 ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL REFERENCES throne_events (id), + guild_id TEXT NOT NULL REFERENCES guilds (guild_id), + registration_id TEXT NOT NULL REFERENCES domme_registrations (id), + created_at TEXT NOT NULL + )`, + ).run(); + await env.DB.prepare( + "INSERT INTO sends_pre0003 (id, event_id, guild_id, registration_id, created_at) SELECT id, event_id, guild_id, registration_id, created_at FROM sends", + ).run(); + await env.DB.prepare("DROP TABLE sends").run(); + await env.DB.prepare( + `CREATE TABLE domme_registrations_pre0003 ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL REFERENCES guilds (guild_id), + creator_id TEXT NOT NULL REFERENCES throne_creators (id), + discord_user_id TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + ).run(); + await env.DB.prepare( + `INSERT INTO domme_registrations_pre0003 + (id, guild_id, creator_id, discord_user_id, active, created_at, updated_at) + SELECT id, guild_id, creator_id, discord_user_id, active, created_at, updated_at + FROM domme_registrations`, + ).run(); + await env.DB.prepare("DROP TABLE domme_registrations").run(); + await env.DB.prepare( + "ALTER TABLE domme_registrations_pre0003 RENAME TO domme_registrations", + ).run(); + await env.DB.prepare( + "CREATE UNIQUE INDEX idx_domme_registrations_guild_creator ON domme_registrations (guild_id, creator_id)", + ).run(); + await env.DB.prepare( + "CREATE UNIQUE INDEX idx_domme_registrations_guild_user ON domme_registrations (guild_id, discord_user_id)", + ).run(); + await env.DB.prepare( + "CREATE INDEX idx_domme_registrations_creator_active ON domme_registrations (creator_id, active)", + ).run(); + await env.DB.prepare("ALTER TABLE sends_pre0003 RENAME TO sends").run(); + await env.DB.prepare( + "CREATE UNIQUE INDEX idx_sends_event_guild ON sends (event_id, guild_id)", + ).run(); + await env.DB.prepare("CREATE INDEX idx_sends_guild ON sends (guild_id)").run(); + await env.DB.prepare("DELETE FROM d1_migrations WHERE name = '0003_links_and_setup.sql'").run(); + + const remainingTables = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ).all<{ name: string }>(); + const remainingNames = remainingTables.results.map((row) => row.name); + for (const table of newTables) { + expect(remainingNames).not.toContain(table); + } + expect(remainingNames).toEqual( + expect.arrayContaining(["guilds", "throne_creators", "domme_registrations", "sends", "profile_documents", "global_profiles"]), + ); + + // Populate through the pre-0003 schema, exactly like an already-running deployment would. + await seedGuild("910100000000000001", "910100000000000002"); + const creator = await seedCreator({ id: "creator-pre0003", ownerDiscordUserId: "910100000000000003" }); + const chain = await seedNotificationChain({ + guildId: "910100000000000001", + creatorId: creator.id, + senderUsername: "existing-supporter", + amountMinor: 1500, + }); + await seedDocument({ id: "doc-pre0003", ownerUserId: "910100000000000003", orientation: "submissive" }); + await seedAlias("doc-pre0003", "PreExisting", "preexisting"); + await seedGlobalProfile("910100000000000003", "doc-pre0003"); + + const sendRowBefore = await env.DB.prepare("SELECT * FROM sends WHERE id = ?").bind(chain.sendId).first(); + const registrationRowBefore = await env.DB.prepare( + "SELECT * FROM domme_registrations WHERE id = ?", + ) + .bind(chain.registrationId) + .first(); + const aliasRowBefore = await env.DB.prepare("SELECT * FROM profile_aliases WHERE document_id = ?") + .bind("doc-pre0003") + .first(); + + await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); + + const tablesAfter = await env.DB.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all<{ + name: string; + }>(); + const namesAfter = tablesAfter.results.map((row) => row.name); + for (const table of newTables) { + expect(namesAfter).toContain(table); + } + + const sendRowAfter = await env.DB.prepare("SELECT * FROM sends WHERE id = ?").bind(chain.sendId).first<{ + sender_discord_user_id: string | null; + }>(); + const aliasRowAfter = await env.DB.prepare("SELECT * FROM profile_aliases WHERE document_id = ?") + .bind("doc-pre0003") + .first(); + const registrationRowAfter = await env.DB.prepare( + "SELECT * FROM domme_registrations WHERE id = ?", + ) + .bind(chain.registrationId) + .first<{ profile_managed: number }>(); + + expect(sendRowAfter).toEqual({ ...(sendRowBefore as object), sender_discord_user_id: null }); + expect(registrationRowAfter).toEqual({ + ...(registrationRowBefore as object), + profile_managed: 0, + }); + expect(aliasRowAfter).toEqual(aliasRowBefore); + + // The new tables are immediately usable. + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO guild_setup_sessions (id, guild_id, initiator_user_id, status, current_step, revision, expires_at, created_at, updated_at) + VALUES ('session-1', '910100000000000001', '910100000000000003', 'active', 'channel', 0, ?, ?, ?)`, + ) + .bind(now, now, now) + .run(); + const session = await env.DB.prepare("SELECT * FROM guild_setup_sessions WHERE id = 'session-1'").first(); + expect(session).not.toBeNull(); + }); +}); diff --git a/worker/test/profileDrafts.test.ts b/worker/test/profileDrafts.test.ts new file mode 100644 index 0000000..31a9256 --- /dev/null +++ b/worker/test/profileDrafts.test.ts @@ -0,0 +1,609 @@ +import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:test"; +import { authHeaders, callWorker, jsonRequest, readJson, TEST_HOME_GUILD_ID } from "./helpers"; + +const OWNER = "500000000000000001"; +const OTHER_GUILD = "500000000000000002"; +const OTHER_GUILD_2 = "500000000000000003"; + +interface DraftEnvelope { + data: { resume_required?: boolean; draft: DraftBody }; +} +interface DraftBody { + id: string; + revision: number; + status: string; + current_step: string; + next_step: string | null; + steps: { key: string; status: string }[]; + document: Record; +} +interface ProfileEnvelope { + data: { profile: Record | null; global_available: boolean }; +} + +async function startDraft(body: Record) { + const response = await callWorker(jsonRequest("POST", "/v1/profile-drafts/start", body, authHeaders())); + const parsed = await readJson(response); + return { status: response.status, ...parsed.data }; +} + +async function getDraft(draftId: string, ownerUserId: string) { + const response = await callWorker( + jsonRequest("GET", `/v1/profile-drafts/${draftId}?owner_user_id=${ownerUserId}`, undefined, authHeaders()), + ); + const parsed = await readJson(response); + return { status: response.status, draft: 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 as unknown as { error?: { code: string } }).error }; +} + +async function restart(draftId: string, body: Record) { + const response = await callWorker(jsonRequest("POST", `/v1/profile-drafts/${draftId}/restart`, body, authHeaders())); + const parsed = await readJson(response); + return { status: response.status, draft: parsed.data?.draft }; +} + +async function publish(draftId: string, body: Record) { + const response = await callWorker(jsonRequest("POST", `/v1/profile-drafts/${draftId}/publish`, body, 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()), + ); + const parsed = await readJson(response); + return { status: response.status, ...parsed.data }; +} + +describe("profile draft lifecycle (global scope, domme orientation)", () => { + it("persists partial identity selections without completing the step", async () => { + const owner = "500000000000000090"; + 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", + }); + + const partial = await putStep(draftId, "identity", { + owner_user_id: owner, + expected_revision: 1, + complete: false, + pronouns: ["She/Her"], + honourifics: [], + submissive_labels: [], + dm_status: "open", + bio: null, + public_send_stats: false, + aliases: [], + }); + expect(partial.status).toBe(200); + expect(partial.draft?.revision).toBe(2); + expect(partial.draft?.next_step).toBe("identity"); + expect(partial.draft?.steps.find((step) => step.key === "identity")?.status).toBe("pending"); + expect(partial.draft?.document.selections).toEqual({ + pronouns: ["She/Her"], + honourifics: [], + submissive_labels: [], + }); + + const completed = await putStep(draftId, "identity", { + owner_user_id: owner, + expected_revision: 2, + pronouns: ["She/Her"], + honourifics: [], + submissive_labels: [], + dm_status: "open", + bio: null, + public_send_stats: false, + aliases: [], + }); + expect(completed.draft?.next_step).toBe("links"); + }); + + it("runs the full start -> steps -> publish -> lookup cycle", async () => { + const started = await startDraft({ + owner_user_id: OWNER, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + expect(started.status).toBe(200); + expect(started.resume_required).toBe(false); + const draftId = started.draft.id; + expect(started.draft.revision).toBe(0); + expect(started.draft.steps.map((s) => s.key)).toEqual(["orientation"]); + + const afterOrientation = await putStep(draftId, "orientation", { + owner_user_id: OWNER, + expected_revision: 0, + orientation: "domme", + }); + expect(afterOrientation.status).toBe(200); + expect(afterOrientation.draft?.revision).toBe(1); + expect(afterOrientation.draft?.steps.map((s) => s.key)).toEqual(["orientation", "identity", "links", "throne", "review"]); + + const afterIdentity = await putStep(draftId, "identity", { + owner_user_id: OWNER, + expected_revision: 1, + pronouns: ["She/Her"], + honourifics: ["Goddess"], + dm_status: "open", + bio: "Hello there", + public_send_stats: false, + }); + expect(afterIdentity.status).toBe(200); + expect(afterIdentity.draft?.revision).toBe(2); + + const afterLinks = await putStep(draftId, "links", { + owner_user_id: OWNER, + expected_revision: 2, + links: [ + { + platform: "twitter", + public_label: "Twitter", + normalized_url: "https://twitter.com/example", + link_type: "social", + }, + { + platform: "cashapp", + public_label: "CashApp", + normalized_url: "https://cash.app/$example", + link_type: "payment", + }, + ], + }); + expect(afterLinks.status).toBe(200); + expect(afterLinks.draft?.revision).toBe(3); + const linkId = (afterLinks.draft?.document.links as { id: string; link_type: string }[]).find( + (l) => l.link_type === "payment", + )?.id; + expect(linkId).toBeTruthy(); + + const afterThrone = await putStep(draftId, "throne", { + owner_user_id: OWNER, + expected_revision: 3, + throne_creator_id: null, + preferred_payment_link_id: linkId, + }); + expect(afterThrone.status).toBe(200); + expect(afterThrone.draft?.revision).toBe(4); + + const afterReview = await putStep(draftId, "review", { owner_user_id: OWNER, expected_revision: 4 }); + expect(afterReview.status).toBe(200); + expect(afterReview.draft?.steps.every((s) => s.status === "completed")).toBe(true); + + const published = await publish(draftId, { owner_user_id: OWNER, expected_revision: 5 }); + expect(published.status).toBe(200); + expect(published.profile?.orientation).toBe("domme"); + expect(published.profile?.version).toBe(1); + + const looked = await lookup(TEST_HOME_GUILD_ID, OWNER); + expect(looked.status).toBe(200); + expect(looked.global_available).toBe(true); + expect(looked.profile?.bio).toBe("Hello there"); + expect(looked.profile?.preferred_payment_link_id).toBe(linkId); + + const edit = await startDraft({ + owner_user_id: OWNER, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + expect(edit.status).toBe(200); + expect(edit.resume_required).toBe(false); + const clonedLinks = edit.draft.document.links as { id: string; normalized_url: string }[]; + expect(clonedLinks).toHaveLength(2); + expect(clonedLinks.map((link) => link.id)).not.toContain(linkId); + expect(clonedLinks.map((link) => link.normalized_url)).toContain("https://cash.app/$example"); + + // The draft is no longer active; further mutation must be refused. + const stalePut = await putStep(draftId, "review", { owner_user_id: OWNER, expected_revision: 5 }); + expect(stalePut.status).toBe(409); + }); + + it("returns resume_required with the same draft when starting again while one is active", async () => { + const owner = "500000000000000010"; + const first = await startDraft({ owner_user_id: owner, origin_guild_id: TEST_HOME_GUILD_ID, target_scope: "global" }); + expect(first.resume_required).toBe(false); + + const second = await startDraft({ owner_user_id: owner, origin_guild_id: TEST_HOME_GUILD_ID, target_scope: "global" }); + expect(second.status).toBe(200); + expect(second.resume_required).toBe(true); + expect(second.draft.id).toBe(first.draft.id); + }); + + it("rejects a global draft started outside the home guild", async () => { + const response = await callWorker( + jsonRequest( + "POST", + "/v1/profile-drafts/start", + { owner_user_id: "500000000000000011", origin_guild_id: OTHER_GUILD, target_scope: "global" }, + authHeaders(), + ), + ); + expect(response.status).toBe(400); + const body = await readJson<{ error: { code: string } }>(response); + expect(body.error.code).toBe("home_guild_required"); + }); + + it("rejects a server draft targeting the home guild", async () => { + const response = await callWorker( + jsonRequest( + "POST", + "/v1/profile-drafts/start", + { + owner_user_id: "500000000000000012", + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "server", + guild_id: TEST_HOME_GUILD_ID, + server_mode: "independent", + }, + authHeaders(), + ), + ); + expect(response.status).toBe(400); + const body = await readJson<{ error: { code: string } }>(response); + expect(body.error.code).toBe("server_scope_not_allowed_in_home_guild"); + }); + + it("rejects mismatched expected_revision with 409 stale_revision", async () => { + const owner = "500000000000000013"; + const started = await startDraft({ owner_user_id: owner, origin_guild_id: TEST_HOME_GUILD_ID, target_scope: "global" }); + const draftId = started.draft.id; + + const result = await putStep(draftId, "orientation", { + owner_user_id: owner, + expected_revision: 99, + orientation: "domme", + }); + expect(result.status).toBe(409); + expect(result.error?.code).toBe("stale_revision"); + }); + + it("never mutates a document after it has left draft state", async () => { + const owner = "500000000000000019"; + const started = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + const afterOrientation = await putStep(started.draft.id, "orientation", { + owner_user_id: owner, + expected_revision: 0, + orientation: "domme", + }); + expect(afterOrientation.status).toBe(200); + + await env.DB.prepare( + `UPDATE profile_documents + SET state = 'published' + WHERE id = (SELECT document_id FROM profile_drafts WHERE id = ?)`, + ) + .bind(started.draft.id) + .run(); + + const result = await putStep(started.draft.id, "identity", { + owner_user_id: owner, + expected_revision: 1, + dm_status: "open", + bio: "must not be written", + }); + expect(result.status).toBe(409); + expect(result.error?.code).toBe("stale_revision"); + + const row = await env.DB.prepare( + `SELECT d.revision, p.state, p.bio + FROM profile_drafts d + JOIN profile_documents p ON p.id = d.document_id + WHERE d.id = ?`, + ) + .bind(started.draft.id) + .first<{ revision: number; state: string; bio: string | null }>(); + expect(row).toEqual({ revision: 1, state: "published", bio: null }); + }); + + it("submissive orientation has no throne step and rejects throne step mutation", async () => { + const owner = "500000000000000014"; + const started = await startDraft({ owner_user_id: owner, origin_guild_id: TEST_HOME_GUILD_ID, target_scope: "global" }); + const draftId = started.draft.id; + const afterOrientation = await putStep(draftId, "orientation", { + owner_user_id: owner, + expected_revision: 0, + orientation: "submissive", + }); + expect(afterOrientation.draft?.steps.map((s) => s.key)).toEqual(["orientation", "identity", "links", "review"]); + + const throneAttempt = await putStep(draftId, "throne", { + owner_user_id: owner, + expected_revision: 1, + throne_creator_id: null, + preferred_payment_link_id: null, + }); + expect(throneAttempt.status).toBe(400); + expect(throneAttempt.error?.code).toBe("step_not_applicable"); + }); + + it("restart resets step completion and document content back to the published baseline", async () => { + const owner = "500000000000000015"; + const started = await startDraft({ owner_user_id: owner, origin_guild_id: TEST_HOME_GUILD_ID, target_scope: "global" }); + const draftId = started.draft.id; + const afterOrientation = await putStep(draftId, "orientation", { + owner_user_id: owner, + expected_revision: 0, + orientation: "domme", + }); + expect(afterOrientation.draft?.current_step).toBe("orientation"); + + const restarted = await restart(draftId, { owner_user_id: owner, expected_revision: 1 }); + expect(restarted.status).toBe(200); + expect(restarted.draft?.revision).toBe(2); + expect(restarted.draft?.current_step).toBe("orientation"); + expect(restarted.draft?.steps.every((s) => s.status === "pending")).toBe(true); + expect((restarted.draft?.document as { orientation?: unknown } | undefined)?.orientation).toBeUndefined(); + + const reread = await getDraft(draftId, owner); + expect(reread.draft?.document.selections).toEqual({ pronouns: [], honourifics: [], submissive_labels: [] }); + }); + + it("rejects publish when required steps are incomplete", async () => { + const owner = "500000000000000016"; + 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" }); + + const result = await publish(draftId, { owner_user_id: owner, expected_revision: 1 }); + expect(result.status).toBe(400); + expect(result.error?.code).toBe("steps_incomplete"); + }); + + it("detects a version conflict at publish time with a clear 409", async () => { + const owner = "500000000000000017"; + async function completeDraft(): Promise { + 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, + dm_status: "open", + }); + 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 }); + return draftId; + } + + const draftId = await completeDraft(); + + // Two concurrent publish attempts for the very same completed draft: only one + // may ever win the compare-and-swap on the root's version, and the batch's + // EXISTS-guarded statements ensure the loser leaves no partial trace (no + // second publication row, no document flipped to `published` twice). + const [first, second] = await Promise.all([ + publish(draftId, { owner_user_id: owner, expected_revision: 5 }), + publish(draftId, { owner_user_id: owner, expected_revision: 5 }), + ]); + const statuses = [first.status, second.status].sort(); + expect(statuses).toEqual([200, 409]); + const loser = first.status === 409 ? first : second; + expect(["publish_conflict", "stale_revision", "draft_not_active"]).toContain(loser.error?.code); + + const publicationCount = await env.DB.prepare( + "SELECT COUNT(*) as count FROM profile_publications WHERE owner_user_id = ?", + ) + .bind(owner) + .first<{ count: number }>(); + expect(publicationCount?.count).toBe(1); + }); + + it("does not let a stale draft overwrite a root published after the draft started", async () => { + const owner = "500000000000000018"; + 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, + dm_status: "open", + }); + 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, + }); + + const newerDocumentId = "newer-published-document"; + const now = new Date().toISOString(); + await env.DB.batch([ + env.DB + .prepare( + `INSERT INTO profile_documents + (id, owner_user_id, state, orientation, dm_status, created_at, updated_at) + VALUES (?, ?, 'published', 'domme', 'closed', ?, ?)`, + ) + .bind(newerDocumentId, owner, now, now), + env.DB + .prepare( + `INSERT INTO global_profiles + (owner_user_id, current_document_id, version, published_at, created_at, updated_at) + VALUES (?, ?, 1, ?, ?, ?)`, + ) + .bind(owner, newerDocumentId, now, now, now), + ]); + + const result = await publish(draftId, { + owner_user_id: owner, + expected_revision: 5, + }); + expect(result.status).toBe(409); + expect(result.error?.code).toBe("publish_conflict"); + + const root = await env.DB.prepare( + "SELECT current_document_id, version FROM global_profiles WHERE owner_user_id = ?", + ) + .bind(owner) + .first<{ current_document_id: string; version: number }>(); + expect(root).toEqual({ current_document_id: newerDocumentId, version: 1 }); + + const staleDocument = await env.DB.prepare( + `SELECT state + FROM profile_documents + WHERE id = (SELECT document_id FROM profile_drafts WHERE id = ?)`, + ) + .bind(draftId) + .first<{ state: string }>(); + expect(staleDocument?.state).toBe("draft"); + }); +}); + +describe("profile draft lifecycle (server scope)", () => { + it("publishes an independent server profile distinct from any global profile", async () => { + const owner = "500000000000000020"; + const started = await startDraft({ + owner_user_id: owner, + origin_guild_id: OTHER_GUILD, + target_scope: "server", + guild_id: OTHER_GUILD, + server_mode: "independent", + }); + const draftId = started.draft.id; + await putStep(draftId, "orientation", { owner_user_id: owner, expected_revision: 0, orientation: "switch_domme" }); + await putStep(draftId, "identity", { + owner_user_id: owner, + expected_revision: 1, + pronouns: ["He/Him"], + honourifics: ["Master"], + submissive_labels: ["Pet"], + dm_status: "by_request", + aliases: ["Buddy"], + public_send_stats: 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 }); + + const published = await publish(draftId, { owner_user_id: owner, expected_revision: 5 }); + expect(published.status).toBe(200); + expect(published.profile?.scope).toBe("server"); + expect(published.profile?.mode).toBe("independent"); + + const looked = await lookup(OTHER_GUILD, owner); + expect(looked.profile?.mode).toBe("independent"); + expect(looked.global_available).toBe(false); + }); + + it("publishes a linked overlay that overrides one field and adds a local link", async () => { + const owner = "500000000000000021"; + // Publish a global profile first. + const globalStart = await startDraft({ owner_user_id: owner, origin_guild_id: TEST_HOME_GUILD_ID, target_scope: "global" }); + const globalDraftId = globalStart.draft.id; + await putStep(globalDraftId, "orientation", { owner_user_id: owner, expected_revision: 0, orientation: "domme" }); + await putStep(globalDraftId, "identity", { + owner_user_id: owner, + expected_revision: 1, + pronouns: ["She/Her"], + dm_status: "open", + bio: "global bio", + }); + await putStep(globalDraftId, "links", { + owner_user_id: owner, + expected_revision: 2, + links: [ + { platform: "cashapp", public_label: "CashApp", normalized_url: "https://cash.app/$owner21", link_type: "payment" }, + ], + }); + await putStep(globalDraftId, "throne", { + owner_user_id: owner, + expected_revision: 3, + throne_creator_id: null, + preferred_payment_link_id: null, + }); + await putStep(globalDraftId, "review", { owner_user_id: owner, expected_revision: 4 }); + await publish(globalDraftId, { owner_user_id: owner, expected_revision: 5 }); + + // Now start a linked draft in a different guild. + const linkedStart = await startDraft({ + owner_user_id: owner, + origin_guild_id: OTHER_GUILD_2, + target_scope: "server", + guild_id: OTHER_GUILD_2, + server_mode: "linked", + }); + expect(linkedStart.draft.steps.map((s: { key: string }) => s.key)).toEqual(["identity", "links", "review"]); + const linkedDraftId = linkedStart.draft.id; + + const afterIdentity = await putStep(linkedDraftId, "identity", { + owner_user_id: owner, + expected_revision: 0, + overrides: ["dm_status"], + dm_status: "closed", + }); + expect(afterIdentity.status).toBe(200); + + const afterLinks = await putStep(linkedDraftId, "links", { + owner_user_id: owner, + expected_revision: 1, + local_links: [ + { platform: "onlyfans", public_label: "Local Only", normalized_url: "https://example.com/local21", link_type: "social" }, + ], + hidden_inherited_link_ids: [], + preferred_payment_link_id: null, + }); + expect(afterLinks.status).toBe(200); + + await putStep(linkedDraftId, "review", { owner_user_id: owner, expected_revision: 2 }); + const published = await publish(linkedDraftId, { owner_user_id: owner, expected_revision: 3 }); + expect(published.status).toBe(200); + expect(published.profile?.mode).toBe("linked"); + expect(published.profile?.dm_status).toBe("closed"); + expect(published.profile?.bio).toBe("global bio"); + + const looked = await lookup(OTHER_GUILD_2, owner); + expect(looked.profile?.dm_status).toBe("closed"); + expect(looked.profile?.bio).toBe("global bio"); + const links = looked.profile?.links as { platform: string }[]; + expect(links.some((l) => l.platform === "onlyfans")).toBe(true); + expect(links.some((l) => l.platform === "cashapp")).toBe(true); + }); +}); diff --git a/worker/test/profileHelpers.ts b/worker/test/profileHelpers.ts new file mode 100644 index 0000000..5fbadc8 --- /dev/null +++ b/worker/test/profileHelpers.ts @@ -0,0 +1,137 @@ +import { env } from "cloudflare:test"; + +/** Direct D1 seeding helpers for profile-system tests, bypassing the draft/publish routes + * so resolver and schema behavior can be tested in isolation from wizard mechanics. */ + +export interface SeedDocumentOptions { + id: string; + ownerUserId: string; + state?: "draft" | "published" | "superseded"; + orientation?: string | null; + dmStatus?: string | null; + bio?: string | null; + publicSendStats?: boolean; + throneCreatorId?: string | null; + preferredPaymentLinkId?: string | null; +} + +export async function seedDocument(options: SeedDocumentOptions): 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + options.id, + options.ownerUserId, + options.state ?? "published", + options.orientation ?? null, + options.dmStatus ?? null, + options.bio ?? null, + options.publicSendStats ? 1 : 0, + options.throneCreatorId ?? null, + options.preferredPaymentLinkId ?? null, + now, + now, + ) + .run(); +} + +export async function seedSelection( + documentId: string, + category: "pronoun" | "honourific" | "submissive_label", + value: string, + sortOrder = 0, +): Promise { + await env.DB.prepare( + "INSERT INTO profile_document_selections (document_id, category, value, sort_order) VALUES (?, ?, ?, ?)", + ) + .bind(documentId, category, value, sortOrder) + .run(); +} + +export async function seedAlias(documentId: string, displayAlias: string, normalizedAlias: string, sortOrder = 0): Promise { + await env.DB.prepare( + "INSERT INTO profile_aliases (id, document_id, display_alias, normalized_alias, sort_order) VALUES (?, ?, ?, ?, ?)", + ) + .bind(crypto.randomUUID(), documentId, displayAlias, normalizedAlias, sortOrder) + .run(); +} + +export interface SeedLinkOptions { + id: string; + documentId: string; + platform: string; + publicLabel: string; + username?: string | null; + normalizedUrl: string; + linkType: "social" | "payment"; + sortOrder?: number; + enabled?: boolean; +} + +export async function seedLink(options: SeedLinkOptions): Promise { + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO profile_links + (id, document_id, platform, public_label, username, normalized_url, link_type, sort_order, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + options.id, + options.documentId, + options.platform, + options.publicLabel, + options.username ?? null, + options.normalizedUrl, + options.linkType, + options.sortOrder ?? 0, + options.enabled === false ? 0 : 1, + now, + now, + ) + .run(); +} + +export async function seedOverride(documentId: string, fieldName: string): Promise { + await env.DB.prepare("INSERT INTO profile_document_overrides (document_id, field_name) VALUES (?, ?)") + .bind(documentId, fieldName) + .run(); +} + +export async function seedHiddenLink(documentId: string, inheritedLinkId: string): Promise { + await env.DB.prepare( + "INSERT INTO profile_link_visibility (document_id, inherited_link_id, visible) VALUES (?, ?, 0)", + ) + .bind(documentId, inheritedLinkId) + .run(); +} + +export async function seedGlobalProfile(ownerUserId: string, documentId: string, version = 1): Promise { + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO global_profiles (owner_user_id, current_document_id, version, published_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .bind(ownerUserId, documentId, version, now, now, now) + .run(); +} + +export async function seedServerProfile(options: { + id: string; + guildId: string; + ownerUserId: string; + mode: "linked" | "independent"; + documentId: string; + version?: number; +}): Promise { + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO server_profiles (id, guild_id, owner_user_id, mode, current_document_id, version, published_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind(options.id, options.guildId, options.ownerUserId, options.mode, options.documentId, options.version ?? 1, now, now, now) + .run(); +} diff --git a/worker/test/profileLinks.test.ts b/worker/test/profileLinks.test.ts new file mode 100644 index 0000000..82d3b56 --- /dev/null +++ b/worker/test/profileLinks.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest"; +import { authHeaders, callWorker, jsonRequest, readJson, TEST_HOME_GUILD_ID } from "./helpers"; + +interface DraftEnvelope { + data: { resume_required?: boolean; draft: DraftBody }; +} +interface DraftBody { + id: string; + revision: number; + document: { links: { id: string; platform: string; public_label: string; link_type: string; enabled: boolean }[]; preferred_payment_link_id: string | null }; +} +interface ErrorEnvelope { + error: { code: string; message: string }; +} + +async function startGlobalDraft(owner: string) { + const response = await callWorker( + jsonRequest("POST", "/v1/profile-drafts/start", { owner_user_id: owner, origin_guild_id: TEST_HOME_GUILD_ID, target_scope: "global" }, authHeaders()), + ); + const parsed = await readJson(response); + return parsed.data.draft; +} + +async function setOrientation(draftId: string, owner: string, revision: number, orientation: string) { + const response = await callWorker( + jsonRequest("PUT", `/v1/profile-drafts/${draftId}/steps/orientation`, { owner_user_id: owner, expected_revision: revision, orientation }, authHeaders()), + ); + const parsed = await readJson(response); + return parsed.data.draft; +} + +async function addLink(draftId: string, body: Record) { + const response = await callWorker(jsonRequest("POST", `/v1/profile-drafts/${draftId}/links`, body, authHeaders())); + const parsed = await readJson(response); + return { status: response.status, draft: parsed.data?.draft, error: parsed.error }; +} + +async function editLink(draftId: string, linkId: string, body: Record) { + const response = await callWorker(jsonRequest("PUT", `/v1/profile-drafts/${draftId}/links/${linkId}`, body, authHeaders())); + const parsed = await readJson(response); + return { status: response.status, draft: parsed.data?.draft, error: parsed.error }; +} + +async function deleteLink(draftId: string, linkId: string, body: Record) { + const response = await callWorker(jsonRequest("DELETE", `/v1/profile-drafts/${draftId}/links/${linkId}`, body, authHeaders())); + const parsed = await readJson(response); + return { status: response.status, draft: parsed.data?.draft, error: parsed.error }; +} + +describe("manual draft link CRUD", () => { + it("adds a link with automatic known-provider classification", async () => { + const owner = "600000000000000001"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const added = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "My Twitter", + normalized_url: "https://twitter.com/example", + // link_type deliberately omitted/wrong -- known-provider classification must win. + link_type: "payment", + }); + expect(added.status).toBe(201); + expect(added.draft?.document.links).toHaveLength(1); + expect(added.draft?.document.links[0]).toMatchObject({ platform: "twitter", link_type: "social", public_label: "My Twitter" }); + }); + + it("requires an explicit link_type/platform for an unrecognized provider", async () => { + const owner = "600000000000000002"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const missingType = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "My Site", + normalized_url: "https://my-own-site.example/", + }); + expect(missingType.status).toBe(400); + expect(missingType.error?.code).toBe("platform_required"); + + const withType = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "My Site", + normalized_url: "https://my-own-site.example/", + platform: "personal-site", + link_type: "social", + }); + expect(withType.status).toBe(201); + expect(withType.draft?.document.links[0]).toMatchObject({ platform: "personal-site", link_type: "social" }); + }); + + it("rejects non-https URLs and embedded credentials", async () => { + const owner = "600000000000000003"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const insecure = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "Bad", + normalized_url: "http://twitter.com/example", + }); + expect(insecure.status).toBe(400); + expect(insecure.error?.code).toBe("invalid_url_scheme"); + + const withCreds = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "Bad", + normalized_url: "https://user:pass@twitter.com/example", + }); + expect(withCreds.status).toBe(400); + expect(withCreds.error?.code).toBe("invalid_url_credentials"); + }); + + it("rejects payment links for an orientation without payment capability", async () => { + const owner = "600000000000000004"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "submissive"); + + const result = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "Cash", + normalized_url: "https://cash.app/$example", + }); + expect(result.status).toBe(400); + expect(result.error?.code).toBe("payment_links_unavailable"); + }); + + it("enforces the twelve-link cap", async () => { + const owner = "600000000000000005"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + let revision = 1; + for (let i = 0; i < 12; i++) { + const result = await addLink(started.id, { + owner_user_id: owner, + expected_revision: revision, + public_label: `Site ${i}`, + normalized_url: `https://site${i}.example/`, + platform: `site${i}`, + link_type: "social", + }); + expect(result.status).toBe(201); + revision = result.draft!.revision; + } + + const thirteenth = await addLink(started.id, { + owner_user_id: owner, + expected_revision: revision, + public_label: "One too many", + normalized_url: "https://site13.example/", + platform: "site13", + link_type: "social", + }); + expect(thirteenth.status).toBe(400); + expect(thirteenth.error?.code).toBe("too_many_links"); + }); + + it("rejects a duplicate normalized URL", async () => { + const owner = "600000000000000006"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const first = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "Twitter", + normalized_url: "https://twitter.com/example", + }); + expect(first.status).toBe(201); + + const duplicate = await addLink(started.id, { + owner_user_id: owner, + expected_revision: first.draft!.revision, + public_label: "Twitter Again", + normalized_url: "https://twitter.com/example", + }); + expect(duplicate.status).toBe(400); + expect(duplicate.error?.code).toBe("duplicate_link"); + }); + + it("edits an existing link in place, preserving its id", async () => { + const owner = "600000000000000007"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const added = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "Twitter", + normalized_url: "https://twitter.com/example", + }); + const linkId = added.draft!.document.links[0]!.id; + + const edited = await editLink(started.id, linkId, { + owner_user_id: owner, + expected_revision: added.draft!.revision, + public_label: "Updated Label", + normalized_url: "https://twitter.com/example", + enabled: false, + }); + expect(edited.status).toBe(200); + expect(edited.draft?.document.links).toHaveLength(1); + expect(edited.draft?.document.links[0]).toMatchObject({ id: linkId, public_label: "Updated Label", enabled: false }); + }); + + it("marks a payment link preferred, and clearing it via delete nulls preferred_payment_link_id", async () => { + const owner = "600000000000000008"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const added = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "CashApp", + normalized_url: "https://cash.app/$example", + preferred: true, + }); + expect(added.status).toBe(201); + const linkId = added.draft!.document.links[0]!.id; + expect(added.draft?.document.preferred_payment_link_id).toBe(linkId); + + const removed = await deleteLink(started.id, linkId, { owner_user_id: owner, expected_revision: added.draft!.revision }); + expect(removed.status).toBe(200); + expect(removed.draft?.document.links).toHaveLength(0); + expect(removed.draft?.document.preferred_payment_link_id).toBeNull(); + }); + + it("rejects marking a non-payment link as preferred", async () => { + const owner = "600000000000000009"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const result = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 1, + public_label: "Twitter", + normalized_url: "https://twitter.com/example", + preferred: true, + }); + expect(result.status).toBe(400); + expect(result.error?.code).toBe("preferred_requires_payment"); + }); + + it("returns 409 stale_revision for a mismatched expected_revision", async () => { + const owner = "600000000000000010"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const result = await addLink(started.id, { + owner_user_id: owner, + expected_revision: 999, + public_label: "Twitter", + normalized_url: "https://twitter.com/example", + }); + expect(result.status).toBe(409); + expect(result.error?.code).toBe("stale_revision"); + }); + + it("returns 400 unknown_link_id when editing/removing a link id that does not exist", async () => { + const owner = "600000000000000011"; + const started = await startGlobalDraft(owner); + await setOrientation(started.id, owner, 0, "domme"); + + const edited = await editLink(started.id, "not-a-real-link-id", { + owner_user_id: owner, + expected_revision: 1, + public_label: "X", + normalized_url: "https://twitter.com/example", + }); + expect(edited.status).toBe(400); + expect(edited.error?.code).toBe("unknown_link_id"); + + const removed = await deleteLink(started.id, "not-a-real-link-id", { owner_user_id: owner, expected_revision: 1 }); + expect(removed.status).toBe(400); + expect(removed.error?.code).toBe("unknown_link_id"); + }); +}); diff --git a/worker/test/profilePublicationRegistration.test.ts b/worker/test/profilePublicationRegistration.test.ts new file mode 100644 index 0000000..7a6d486 --- /dev/null +++ b/worker/test/profilePublicationRegistration.test.ts @@ -0,0 +1,121 @@ +import { env } from "cloudflare:test"; +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"; + +async function seedCompletedDraft(options: { + owner: string; + draftId: string; + documentId: string; + creatorId: string; +}): Promise { + await seedDocument({ + id: options.documentId, + ownerUserId: options.owner, + state: "draft", + orientation: "domme", + dmStatus: "open", + throneCreatorId: options.creatorId, + }); + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO profile_drafts + (id, owner_user_id, origin_guild_id, target_scope, document_id, base_version, + status, current_step, revision, created_at, updated_at) + VALUES (?, ?, ?, 'global', ?, 0, 'active', 'review', 4, ?, ?)`, + ) + .bind(options.draftId, options.owner, TEST_HOME_GUILD_ID, options.documentId, now, now) + .run(); + for (const step of ["orientation", "identity", "links", "throne"]) { + await env.DB.prepare( + `INSERT INTO profile_draft_steps (draft_id, step_key, status, completed_at) + VALUES (?, ?, 'completed', ?)`, + ) + .bind(options.draftId, step, now) + .run(); + } +} + +describe("atomic publication registration projection", () => { + it("publishes the profile and registration together", async () => { + const owner = "950000000000000001"; + await seedGuild(TEST_HOME_GUILD_ID); + const creator = await seedCreator({ + id: "publish-projection-creator", + ownerDiscordUserId: owner, + }); + await seedCompletedDraft({ + owner, + draftId: "publish-projection-draft", + documentId: "publish-projection-document", + creatorId: creator.id, + }); + + await publishDraft(env, { + draftId: "publish-projection-draft", + ownerUserId: owner, + expectedRevision: 4, + }); + + const row = await env.DB.prepare( + `SELECT creator_id, active, profile_managed + FROM domme_registrations + WHERE guild_id = ? AND discord_user_id = ?`, + ) + .bind(TEST_HOME_GUILD_ID, owner) + .first<{ creator_id: string; active: number; profile_managed: number }>(); + expect(row).toEqual({ creator_id: creator.id, active: 1, profile_managed: 1 }); + }); + + it("rejects a conflicting legacy registration without publishing", async () => { + const owner = "950000000000000002"; + await seedGuild(TEST_HOME_GUILD_ID); + const [profileCreator, legacyCreator] = await Promise.all([ + seedCreator({ + id: "publish-conflict-profile-creator", + ownerDiscordUserId: owner, + }), + seedCreator({ + id: "publish-conflict-legacy-creator", + ownerDiscordUserId: owner, + }), + ]); + await seedCompletedDraft({ + owner, + draftId: "publish-conflict-draft", + documentId: "publish-conflict-document", + creatorId: profileCreator.id, + }); + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO domme_registrations + (id, guild_id, creator_id, discord_user_id, active, profile_managed, created_at, updated_at) + VALUES ('publish-conflict-legacy', ?, ?, ?, 1, 0, ?, ?)`, + ) + .bind(TEST_HOME_GUILD_ID, legacyCreator.id, owner, now, now) + .run(); + + await expect( + publishDraft(env, { + draftId: "publish-conflict-draft", + ownerUserId: owner, + expectedRevision: 4, + }), + ).rejects.toEqual( + expect.objectContaining>({ + code: "legacy_registration_conflict", + status: 409, + }), + ); + const document = await env.DB.prepare( + "SELECT state FROM profile_documents WHERE id = 'publish-conflict-document'", + ).first<{ state: string }>(); + const root = await env.DB.prepare("SELECT 1 FROM global_profiles WHERE owner_user_id = ?") + .bind(owner) + .first(); + expect(document?.state).toBe("draft"); + expect(root).toBeNull(); + }); +}); diff --git a/worker/test/profileThrone.test.ts b/worker/test/profileThrone.test.ts new file mode 100644 index 0000000..88b0656 --- /dev/null +++ b/worker/test/profileThrone.test.ts @@ -0,0 +1,91 @@ +import { env } from "cloudflare:test"; +import { 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"; + +interface DraftEnvelope { + data: { draft: { id: string; revision: number } }; +} + +async function startDommeDraft(owner: string): Promise<{ id: string; revision: number }> { + const startedResponse = await callWorker( + jsonRequest( + "POST", + "/v1/profile-drafts/start", + { + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }, + authHeaders(), + ), + ); + const started = await readJson(startedResponse); + const orientationResponse = await callWorker( + jsonRequest( + "PUT", + `/v1/profile-drafts/${started.data.draft.id}/steps/orientation`, + { + owner_user_id: owner, + expected_revision: 0, + orientation: "domme", + }, + authHeaders(), + ), + ); + const oriented = await readJson(orientationResponse); + return oriented.data.draft; +} + +describe("profile Throne mutations", () => { + it("lets only one racing rotation change the live webhook secret", async () => { + const owner = "930000000000000001"; + const draft = await startDommeDraft(owner); + const creator = await seedCreator({ + id: "profile-throne-race", + ownerDiscordUserId: owner, + secret: "original-secret", + }); + + const attempts = await Promise.allSettled([ + attachThroneToDraft(env, { + draftId: draft.id, + ownerUserId: owner, + expectedRevision: draft.revision, + throneInput: null, + existingCreatorId: creator.id, + rotateWebhook: true, + }), + attachThroneToDraft(env, { + draftId: draft.id, + ownerUserId: owner, + expectedRevision: draft.revision, + throneInput: null, + existingCreatorId: creator.id, + rotateWebhook: true, + }), + ]); + 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 webhookUrl = fulfilled[0]?.value.webhookUrl; + expect(webhookUrl).toBeTruthy(); + const secret = webhookUrl?.split("/").at(-1); + const row = await env.DB.prepare( + "SELECT route_secret_hash FROM throne_creators WHERE id = ?", + ) + .bind(creator.id) + .first<{ route_secret_hash: string }>(); + expect(row?.route_secret_hash).toBe(await sha256Hex(secret as string)); + expect(row?.route_secret_hash).not.toBe(await sha256Hex("original-secret")); + }); +}); diff --git a/worker/test/registrationSync.test.ts b/worker/test/registrationSync.test.ts new file mode 100644 index 0000000..c285fba --- /dev/null +++ b/worker/test/registrationSync.test.ts @@ -0,0 +1,100 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { syncRegistrationForGuild } from "../src/profile/registrationSync"; +import { seedCreator, seedGuild } from "./helpers"; +import { seedDocument, seedGlobalProfile } from "./profileHelpers"; + +const HOME_GUILD = "100000000000000001"; + +describe("profile registration projection", () => { + it("creates a profile-managed registration for a connected profile", async () => { + const owner = "920000000000000001"; + await seedGuild(HOME_GUILD, "920000000000000002"); + const creator = await seedCreator({ + id: "sync-creator-connected", + ownerDiscordUserId: owner, + }); + await seedDocument({ + id: "sync-doc-connected", + ownerUserId: owner, + orientation: "domme", + throneCreatorId: creator.id, + }); + await seedGlobalProfile(owner, "sync-doc-connected"); + + await syncRegistrationForGuild(env, HOME_GUILD, owner); + + const row = await env.DB.prepare( + `SELECT creator_id, active, profile_managed + FROM domme_registrations + WHERE guild_id = ? AND discord_user_id = ?`, + ) + .bind(HOME_GUILD, owner) + .first<{ creator_id: string; active: number; profile_managed: number }>(); + expect(row).toEqual({ + creator_id: creator.id, + active: 1, + profile_managed: 1, + }); + }); + + it("deactivates only profile-managed registration after disconnect", async () => { + const owner = "920000000000000003"; + await seedGuild(HOME_GUILD, "920000000000000004"); + const creator = await seedCreator({ + id: "sync-creator-disconnected", + ownerDiscordUserId: owner, + }); + await seedDocument({ + id: "sync-doc-disconnected", + ownerUserId: owner, + orientation: "submissive", + }); + await seedGlobalProfile(owner, "sync-doc-disconnected"); + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO domme_registrations + (id, guild_id, creator_id, discord_user_id, active, profile_managed, created_at, updated_at) + VALUES ('sync-managed', ?, ?, ?, 1, 1, ?, ?)`, + ) + .bind(HOME_GUILD, creator.id, owner, now, now) + .run(); + + await syncRegistrationForGuild(env, HOME_GUILD, owner); + + const active = await env.DB.prepare( + "SELECT active FROM domme_registrations WHERE id = 'sync-managed'", + ).first<{ active: number }>(); + expect(active?.active).toBe(0); + }); + + it("never deactivates an explicit legacy registration", async () => { + const owner = "920000000000000005"; + await seedGuild(HOME_GUILD, "920000000000000006"); + const creator = await seedCreator({ + id: "sync-creator-legacy", + ownerDiscordUserId: owner, + }); + await seedDocument({ + id: "sync-doc-legacy", + ownerUserId: owner, + orientation: "submissive", + }); + await seedGlobalProfile(owner, "sync-doc-legacy"); + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO domme_registrations + (id, guild_id, creator_id, discord_user_id, active, profile_managed, created_at, updated_at) + VALUES ('sync-legacy', ?, ?, ?, 1, 0, ?, ?)`, + ) + .bind(HOME_GUILD, creator.id, owner, now, now) + .run(); + + await syncRegistrationForGuild(env, HOME_GUILD, owner); + + const row = await env.DB.prepare( + "SELECT active, profile_managed FROM domme_registrations WHERE id = 'sync-legacy'", + ).first<{ active: number; profile_managed: number }>(); + expect(row).toEqual({ active: 1, profile_managed: 0 }); + }); +}); diff --git a/worker/test/resolver.test.ts b/worker/test/resolver.test.ts new file mode 100644 index 0000000..bdea32b --- /dev/null +++ b/worker/test/resolver.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:test"; +import { resolveProfile } from "../src/profile/resolver"; +import { TEST_HOME_GUILD_ID } from "./helpers"; +import { + seedAlias, + seedDocument, + seedGlobalProfile, + seedHiddenLink, + seedLink, + seedOverride, + seedSelection, + seedServerProfile, +} from "./profileHelpers"; + +const OTHER_GUILD = "200000000000000001"; + +describe("resolveProfile", () => { + it("returns null with global_available=false when the user has no global profile at all", async () => { + const result = await resolveProfile(env, TEST_HOME_GUILD_ID, "1"); + expect(result).toEqual({ profile: null, globalAvailable: false }); + }); + + it("resolves the global document directly in the home guild", async () => { + await seedDocument({ id: "doc-global-1", ownerUserId: "10", orientation: "domme", dmStatus: "open", bio: "hi" }); + await seedSelection("doc-global-1", "pronoun", "She/Her"); + await seedSelection("doc-global-1", "honourific", "Goddess"); + await seedGlobalProfile("10", "doc-global-1", 3); + + const result = await resolveProfile(env, TEST_HOME_GUILD_ID, "10"); + expect(result.globalAvailable).toBe(true); + expect(result.profile).toMatchObject({ + scope: "global", + mode: null, + orientation: "domme", + dmStatus: "open", + bio: "hi", + version: 3, + }); + expect(result.profile?.selections.pronouns).toEqual(["She/Her"]); + expect(result.profile?.selections.honourifics).toEqual(["Goddess"]); + }); + + 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"); + + const result = await resolveProfile(env, OTHER_GUILD, "11"); + expect(result.profile).toBeNull(); + expect(result.globalAvailable).toBe(true); + }); + + it("resolves an independent server profile entirely on its own document", async () => { + await seedDocument({ id: "doc-global-3", ownerUserId: "12", orientation: "domme", dmStatus: "open", bio: "global bio" }); + await seedGlobalProfile("12", "doc-global-3"); + + await seedDocument({ id: "doc-indep-3", ownerUserId: "12", orientation: "domme", dmStatus: "closed", bio: "server-only bio" }); + await seedServerProfile({ id: "srv-3", guildId: OTHER_GUILD, ownerUserId: "12", mode: "independent", documentId: "doc-indep-3", version: 5 }); + + const result = await resolveProfile(env, OTHER_GUILD, "12"); + expect(result.profile).toMatchObject({ scope: "server", mode: "independent", dmStatus: "closed", bio: "server-only bio", version: 5 }); + }); + + describe("linked overlays", () => { + it("inherits every field from the live global document when nothing is overridden", async () => { + await seedDocument({ id: "doc-global-4", ownerUserId: "13", orientation: "switch_domme", dmStatus: "open", bio: "global bio" }); + await seedSelection("doc-global-4", "pronoun", "They/Them"); + await seedGlobalProfile("13", "doc-global-4"); + + await seedDocument({ id: "doc-overlay-4", ownerUserId: "13", state: "published" }); + await seedServerProfile({ id: "srv-4", guildId: OTHER_GUILD, ownerUserId: "13", mode: "linked", documentId: "doc-overlay-4" }); + + const result = await resolveProfile(env, OTHER_GUILD, "13"); + expect(result.profile).toMatchObject({ scope: "server", mode: "linked", orientation: "switch_domme", dmStatus: "open", bio: "global bio" }); + expect(result.profile?.selections.pronouns).toEqual(["They/Them"]); + }); + + it("uses the overlay's explicit-empty override instead of falling back to the global value", async () => { + await seedDocument({ id: "doc-global-5", ownerUserId: "14", orientation: "submissive", dmStatus: "open", bio: "global bio" }); + await seedGlobalProfile("14", "doc-global-5"); + + // Overlay explicitly overrides bio to empty (null) -- distinct from "not overridden". + await seedDocument({ id: "doc-overlay-5", ownerUserId: "14", bio: null }); + await seedOverride("doc-overlay-5", "bio"); + await seedServerProfile({ id: "srv-5", guildId: OTHER_GUILD, ownerUserId: "14", mode: "linked", documentId: "doc-overlay-5" }); + + const result = await resolveProfile(env, OTHER_GUILD, "14"); + expect(result.profile?.bio).toBeNull(); + }); + + it("overrides only the fields marked overridden, inheriting the rest live", async () => { + await seedDocument({ id: "doc-global-6", ownerUserId: "15", orientation: "switch_submissive", dmStatus: "open", bio: "global bio" }); + await seedGlobalProfile("15", "doc-global-6"); + + await seedDocument({ id: "doc-overlay-6", ownerUserId: "15", dmStatus: "closed" }); + await seedOverride("doc-overlay-6", "dm_status"); + await seedServerProfile({ id: "srv-6", guildId: OTHER_GUILD, ownerUserId: "15", mode: "linked", documentId: "doc-overlay-6" }); + + const result = await resolveProfile(env, OTHER_GUILD, "15"); + expect(result.profile?.dmStatus).toBe("closed"); + expect(result.profile?.bio).toBe("global bio"); + }); + + it("reflects a live global update immediately without any copy on the overlay", async () => { + await seedDocument({ id: "doc-global-7", ownerUserId: "16", orientation: "domme", dmStatus: "open", bio: "before" }); + await seedGlobalProfile("16", "doc-global-7"); + await seedDocument({ id: "doc-overlay-7", ownerUserId: "16" }); + await seedServerProfile({ id: "srv-7", guildId: OTHER_GUILD, ownerUserId: "16", mode: "linked", documentId: "doc-overlay-7" }); + + const before = await resolveProfile(env, OTHER_GUILD, "16"); + expect(before.profile?.bio).toBe("before"); + + // Simulate a fresh global publication: a *new* document becomes current. + await seedDocument({ id: "doc-global-7b", ownerUserId: "16", orientation: "domme", dmStatus: "open", bio: "after" }); + await env.DB.prepare("UPDATE global_profiles SET current_document_id = ?, version = version + 1 WHERE owner_user_id = ?") + .bind("doc-global-7b", "16") + .run(); + + const after = await resolveProfile(env, OTHER_GUILD, "16"); + expect(after.profile?.bio).toBe("after"); + }); + + it("hides an inherited link the overlay marks not visible, while keeping its own local links", async () => { + await seedDocument({ id: "doc-global-8", ownerUserId: "17", orientation: "domme", dmStatus: "open" }); + await seedGlobalProfile("17", "doc-global-8"); + await seedLink({ id: "link-global-8a", documentId: "doc-global-8", platform: "twitter", publicLabel: "Twitter", normalizedUrl: "https://twitter.com/a", linkType: "social", sortOrder: 0 }); + await seedLink({ id: "link-global-8b", documentId: "doc-global-8", platform: "cashapp", publicLabel: "CashApp", normalizedUrl: "https://cash.app/a", linkType: "payment", sortOrder: 1 }); + + await seedDocument({ id: "doc-overlay-8", ownerUserId: "17" }); + await seedHiddenLink("doc-overlay-8", "link-global-8a"); + await seedLink({ id: "link-local-8", documentId: "doc-overlay-8", platform: "onlyfans", publicLabel: "Local", normalizedUrl: "https://example.com/local", linkType: "social", sortOrder: 0 }); + await seedServerProfile({ id: "srv-8", guildId: OTHER_GUILD, ownerUserId: "17", mode: "linked", documentId: "doc-overlay-8" }); + + const result = await resolveProfile(env, OTHER_GUILD, "17"); + const linkIds = result.profile?.links.map((link) => link.id) ?? []; + expect(linkIds).toContain("link-global-8b"); + expect(linkIds).toContain("link-local-8"); + expect(linkIds).not.toContain("link-global-8a"); + }); + + it("falls back preferred payment: overlay choice, then global choice, then first visible payment link", async () => { + await seedDocument({ id: "doc-global-9", ownerUserId: "18", orientation: "domme", dmStatus: "open", preferredPaymentLinkId: "link-global-9b" }); + await seedGlobalProfile("18", "doc-global-9"); + await seedLink({ id: "link-global-9a", documentId: "doc-global-9", platform: "cashapp", publicLabel: "A", normalizedUrl: "https://cash.app/a", linkType: "payment", sortOrder: 0 }); + await seedLink({ id: "link-global-9b", documentId: "doc-global-9", platform: "venmo", publicLabel: "B", normalizedUrl: "https://venmo.com/b", linkType: "payment", sortOrder: 1 }); + + await seedDocument({ id: "doc-overlay-9", ownerUserId: "18" }); + await seedServerProfile({ id: "srv-9", guildId: OTHER_GUILD, ownerUserId: "18", mode: "linked", documentId: "doc-overlay-9" }); + + // No overlay preference set -> falls back to global's preferred link. + const first = await resolveProfile(env, OTHER_GUILD, "18"); + expect(first.profile?.preferredPaymentLinkId).toBe("link-global-9b"); + + // Overlay picks an explicit (still-visible) preference -> wins over global's. + await env.DB.prepare("UPDATE profile_documents SET preferred_payment_link_id = ? WHERE id = ?") + .bind("link-global-9a", "doc-overlay-9") + .run(); + const second = await resolveProfile(env, OTHER_GUILD, "18"); + expect(second.profile?.preferredPaymentLinkId).toBe("link-global-9a"); + + // Global's preferred link disappears (hidden by the overlay) and the overlay's own + // choice is also hidden -> falls back deterministically to the first visible payment link. + await seedHiddenLink("doc-overlay-9", "link-global-9a"); + await env.DB.prepare("UPDATE profile_documents SET preferred_payment_link_id = NULL WHERE id = ?").bind("doc-overlay-9").run(); + await env.DB.prepare("UPDATE profile_documents SET preferred_payment_link_id = 'link-missing' WHERE id = ?") + .bind("doc-global-9") + .run(); + const third = await resolveProfile(env, OTHER_GUILD, "18"); + expect(third.profile?.preferredPaymentLinkId).toBe("link-global-9b"); + }); + }); + + it("resolves nothing for a server guild with no server profile even when a global one exists", async () => { + await seedDocument({ id: "doc-global-10", ownerUserId: "19", orientation: "domme", dmStatus: "open" }); + await seedGlobalProfile("19", "doc-global-10"); + + const result = await resolveProfile(env, OTHER_GUILD, "19"); + expect(result.profile).toBeNull(); + expect(result.globalAvailable).toBe(true); + }); + + it("orders selections/aliases and never leaks alias data from a non-alias orientation", async () => { + await seedDocument({ id: "doc-global-11", ownerUserId: "20", orientation: "submissive", dmStatus: "open" }); + await seedAlias("doc-global-11", "PetName", "petname"); + await seedGlobalProfile("20", "doc-global-11"); + + const result = await resolveProfile(env, TEST_HOME_GUILD_ID, "20"); + expect(result.profile?.aliases).toEqual(["PetName"]); + }); +}); diff --git a/worker/vitest.config.ts b/worker/vitest.config.ts index 6d7ba59..071d914 100644 --- a/worker/vitest.config.ts +++ b/worker/vitest.config.ts @@ -15,6 +15,7 @@ export default defineWorkersConfig(async () => { BILL_BOT_API_TOKEN: "test-bot-token", THRONE_PUBLIC_KEY_PEM: "", PUBLIC_BASE_URL: "https://usebill.dev", + BILL_HOME_GUILD_ID: "100000000000000001", THRONE_TEST_GIFTER_USERNAMES: "test-gifter", }, }, diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 89da758..248e122 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -3,11 +3,11 @@ main = "src/index.ts" compatibility_date = "2024-11-01" compatibility_flags = ["nodejs_compat"] -# Replace database_id with the ID returned by `wrangler d1 create bill`. +# Live D1 database id for the `bill` database. [[d1_databases]] binding = "DB" database_name = "bill" -database_id = "00000000-0000-0000-0000-000000000000" +database_id = "6333cb0a-0c23-44b2-9022-a6fde1500f77" migrations_dir = "migrations" [vars] @@ -18,6 +18,7 @@ NOTIFICATION_BACKOFF_BASE_SECONDS = "30" NOTIFICATION_BACKOFF_MAX_SECONDS = "900" MAX_TIMESTAMP_SKEW_SECONDS = "300" -# Secrets (set with `wrangler secret put`, never committed): +# Required deployment values (set with `wrangler secret put`, never committed): # BILL_BOT_API_TOKEN +# BILL_HOME_GUILD_ID # THRONE_PUBLIC_KEY_PEM