diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 9d52e4b1c..1eceeca64 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -51,6 +51,16 @@ Source of truth: `hosted/server/providers.js`; `authPolicy` / `providerBindings` Source of truth: `App` in `hosted/src/App.tsx`; `restoreTheme` in `hosted/src/main.tsx`. +## Terms acceptance + +Continuing past the sign-in notice is how an account agrees to the Hosted terms (`website/src/pages/Terms.tsx` -> "The service"). + +- **Must show the notice beside every sign-in method**, naming `TERMS_VERSION` and linking the terms and privacy policy without leaving the page. +- **`TERMS_VERSION` is the policy pages' revision date** and changes with every terms revision. +- **Must record each account's first acceptance of each version**, after a sign-in that continued past the notice,, current version only. + +Source of truth: `termsRoutes` in `hosted/server/terms.ts`; `TERMS_VERSION` in `hosted/server/policy-constants.ts`. + ## Managed voice An admin-only test slice: Dormouse desktop exchanges a pasted voice token for ElevenLabs speech. The account Worker serves the token routes; the voice Worker serves speak. @@ -81,7 +91,7 @@ Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 **Must delete ElevenLabs speech history, which keeps each generation's text, from the production voice Worker only**: one pass shortly after each successful speak, and a Cron Trigger every 5 minutes for what that missed. No retention bound is guaranteed (rationale). -- **Must use an ElevenLabs account dedicated to Dormouse voice.** A sweep deletes the whole account's history. +- **Must use an ElevenLabs service account dedicated to Dormouse voice, with history access isolated from other workspace usage.** A sweep deletes every history item visible to its key (rationale). - **Never touch the database or any binding but `ELEVENLABS_API_KEY` in a sweep**, so an idle deployment lets Postgres suspend. Without the key nothing runs; development and previews never sweep. - **Must fail the cron invocation when its pass cannot list or any delete fails; the after-speech pass only logs** (rationale). diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index e9b641dac..576606902 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -4,8 +4,8 @@ History sweep (sources checked 2026-09-22): -- ElevenLabs stores every text-to-speech generation, including its text, in the account's speech history. Turning that off per request (`enable_logging=false`, zero-retention mode) is available to enterprise accounts only, so deletion is the remaining control. The history API filters only by voice or model, not by "items this Worker created", so a sweep of a shared account would delete unrelated history. -- Deleting the item straight after the speech call fails: the operator observed (2026-09) that the history item does not exist yet. The after-speech pass therefore waits about 10 s, and the cron pass catches anything that was still not listed. Spoken text usually leaves ElevenLabs about 10 s after the call, otherwise within the 5-minute interval plus ElevenLabs' indexing delay. +- ElevenLabs stores every text-to-speech generation, including its text, in the account's speech history. Turning that off per request (`enable_logging=false`, zero-retention mode) is available to enterprise accounts only, so deletion is the remaining control. The sweep has no per-application filter, so every history item visible to its key is eligible for deletion. On 2026-10-07 the operator confirmed testing that the dedicated Dormouse service account isolates its history from other usage in the same workspace. This is operator verification, not a claim that every service-account sharing configuration has the same isolation. +- Deleting the item straight after the speech call fails: the operator observed (2026-09) that the history item does not exist yet. The after-speech pass therefore waits about 10 s, and the cron pass catches anything that was still not listed. Successful deletion removes visible speech history; ElevenLabs' retention documentation allows residual debugging or moderation records and backups, so these schedules do not establish when every provider copy disappears. - `ctx.waitUntil()` extends an HTTP invocation for at most 30 s after the response is sent (https://developers.cloudflare.com/workers/runtime-apis/context/), so a 10 s wait plus one short pass has margin. Every 5 minutes is the backstop because the after-speech pass handles the common case. - Workers limits (https://developers.cloudflare.com/workers/platform/limits/, checked 2026-09-22): 50 subrequests per invocation on Free, and six connections may await response headers at once. The per-pass caps fit the Free limit, so the sweep does not depend on the account's plan; a test pins the arithmetic. - Endpoints: https://elevenlabs.io/docs/api-reference/history/list and https://elevenlabs.io/docs/api-reference/history/delete. diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md index 8af6c39cd..6fda2f97c 100644 --- a/docs/specs/pricing.md +++ b/docs/specs/pricing.md @@ -11,7 +11,7 @@ **Settings is the front door.** The spoken-alarm row's managed-voice link and the playground tutorial land on `/hosted#voice`, and the plan cards sit within one screen of that anchor. `#remote-control` and `#voice` keep resolving as section ids. -**Content, in order:** the plan cards, directly under the title and anchored `#pricing`; what a member gets, as prose; "Self-hosting stays free"; and a short FAQ — refunds and cancellation, the founding lock, who appears in the founders row, what happens if Hosted shuts down, and that team pricing goes by email to `teams@dormouse.sh`. +**Content, in order:** the plan cards, directly under the title and anchored `#pricing`; what a member gets, as prose; "Self-hosting stays free"; and a short FAQ — refunds and cancellation, the founding lock, who appears in the founders row, what happens if Hosted shuts down, and that team pricing goes by email to `support@dormouse.sh`. **Prices, inclusions, and the FAQ are prerendered text**, and the page emits `Product` / `Offer` JSON-LD carrying one `Offer` per paid plan at its current price, so an assistant fetching the page can quote it. **Offers stay `PreOrder` while checkout is unbuilt.** @@ -46,7 +46,7 @@ Seats left in the open cohort and the founders row load after hydration from one ### Published prices -Prices in USD, and the merchant of record adds or includes tax by jurisdiction. +Prices in USD; DiffPlug adds or includes applicable tax by jurisdiction. | Plan | Price | Cadence | |---|---|---| @@ -78,8 +78,8 @@ Team and enterprise tiers are never sold through this page. A free hosted tier i What each plan grants once checkout can sell it; [Published prices](#published-prices) is the ladder as the page prints it today. - **Founding grants the Individual plan plus a founding badge**; monthly and yearly grant the plan alone. -- **A founding lock survives every later price change** and ends only when the subscription lapses; a lapsed founder re-subscribes at list. -- **Cohorts close by count, never by date.** The count is completed purchases at the billing provider; a refund returns the seat to its cohort. +- **Must lock the founding base yearly price in USD while the subscription remains active**, excluding applicable taxes; a lapsed founder re-subscribes at list. **Must preserve the lock through billing-provider migrations and failures caused by DiffPlug**, allowing payment restoration. +- **Cohorts close by count, never by date.** The count is completed purchases at the billing provider; a full refund or finally reversed payment returns the seat to its cohort. - **When a cohort closes the price rises one step and the counter resets to 100.** - **Founding closes only when the ladder reaches list.** Founding means bought at launch pricing; the hosted Relay shipping does not close it. - **Checkout honors the price it opened at.** Concurrent checkouts may oversell a cohort by a few seats; the overage is the customer's, and the next cohort still opens at a full 100. @@ -102,19 +102,19 @@ What each plan grants once checkout can sell it; [Published prices](#published-p ### Checkout and entitlement -- **Stripe Managed Payments runs checkout, subscriptions, and the customer portal as merchant of record, through `@pgstencil/stripe`**, so tax is Stripe's. Dormouse never stores card data. A founding lock is a per-cohort Price; cohort counts come from the billing provider's completed subscriptions. +- **Must use Stripe Billing, Stripe-hosted Checkout, and its customer portal through `@pgstencil/stripe`; DiffPlug is the seller and merchant of record**, responsible for refunds and applicable tax registration, collection, filing, and remittance. **Never receive or store full card numbers in Dormouse.** A founding lock is a per-cohort Price; cohort counts come from the billing provider's completed subscriptions. - **Checkout starts from a Hosted account**: a buy button lands on the account origin, which asks for sign-in first, so the subscription belongs to an account from its first event. - **Founding checkout offers the founders-row opt-in, unticked**; the account can withdraw it at any time. - **The success page asks the four Van Westendorp questions**, optional and unsent until answered: too expensive to consider, too cheap to trust, expensive but would consider, a bargain. Their answers inform later list changes. - **The entitlement is the account's subscription, read on the server on every voice and Relay request.** No licence, no offline verification, and no grace past what the subscription grants; a lapsed member's voices fall back to the system voice and its Burrows to `not-entitled`. - **A desktop signs in from Settings by device code**, the flow Burrow enrollment already runs (`docs/specs/hosted.md` -> "Burrow enrollment"). The approval mints a desktop credential the host keeps and never hands a webview. Sign-in is the only account surface in the free client. -- **One account covers every machine the member uses.** No device count, no seat count, no activation limit. -- **A refund or chargeback ends the subscription**, so the next request is refused, and the seat returns to its cohort. +- **Must license one individual, including work use, without a per-device charge.** **Must disclose material enrollment and usage limits before purchase**, including the managed Relay's enrollment cap (`docs/specs/hosted.md` -> "Burrow enrollment"). +- **A full refund or a finally reversed payment ends the subscription**, so the next request is refused. A partial refund or billing correction never ends it, and an open dispute only suspends it ("Paid-launch requirements"). ### Managed voice - **Dormouse operates the endpoint and holds the vendor key** (ElevenLabs). A request carries the desktop credential, a voice id, and the text; the response is audio. -- **What leaves the machine is exactly the sanitized spoken label and the voice id** — the `toSpokenText` output in `lib/src/lib/alert-speech.ts`, never terminal content, never a notification body, never a Session id. **Disclose this in the enable flow before the first request**, honoring the promise the Hosted page makes. +- **Must send only the shortened displayed label, voice id, and authentication credential in the voice request**, never the terminal screen or output stream, notification body, or Session id. **Must disclose before enabling managed voice that labels can come from program-supplied titles, command labels, or directory names and that secret filtering is heuristic**, not a guarantee of confidentiality. Connection metadata remains visible to the serving infrastructure. - **Cache clips by voice and text on the client** and regenerate only when the label changes; a cache hit makes no request. **Fair use is a daily request cap per member**; past it, the system voice speaks. - **The system voice is the fallback**, for offline, unentitled, endpoint error, or cap: same delivery rules, same cut-off on attend, never silence because the service failed. Delivery identity, queueing, and cut-off stay owned by `docs/specs/alert.md` -> "Spoken alarms". - **One voice per Pane.** The member default applies everywhere; a per-Pane override is persisted with the pane's settings and follows the Session through minimize and restore. Doors and headers show nothing new. @@ -123,11 +123,31 @@ What each plan grants once checkout can sell it; [Published prices](#published-p ### Renewal, cancellation, refund - **Every plan auto-renews; cancel any time; access runs to period end.** -- **30-day refund on every plan.** A refund revokes. +- **Must offer a full refund within 30 days of the first Hosted payment or any yearly renewal**, including collected tax; monthly renewals, plan changes, and resubscriptions do not restart this voluntary first-payment guarantee. **Must preserve mandatory legal remedies.** A full refund revokes. - **A failed founding renewal gets 30 days of grace before the lock is lost.** - **A subscription is personal and non-transferable.** - **No trial**: the 30-day refund is the trial. +### Paid-launch requirements + +Part of **hosted-sales**; these remain unimplemented launch gates, not claims about the current account service. + +EEA/UK representative appointment is excluded from the internal release gate by operator decision; applicable legal obligations remain unchanged (rationale). + +- **Must obtain affirmative agreement to versioned terms and express consent to automatic renewal before charging**, disclosing price, taxes, interval, refund conditions, cancellation, and material limits beside the purchase action; retain the accepted version, offered limits, and consent evidence and send a durable confirmation. +- **Must provide direct online cancellation and a support cancellation path when account access is lost.** **Must cancel future renewals as part of account closure**, explaining remaining access and refund eligibility before completing closure; verification cannot require account recovery. +- **Must send jurisdiction-required renewal, annual, and price-change notices with cancellation instructions.** For California consumers, annual-term renewal notices are 15–45 days before renewal and fee-change notices 7–30 days before effectiveness; annual reminders also apply to monthly plans (rationale). +- **Must route subscription notices to the maintained billing email**, including provider-only accounts without a sign-in email; use the account contact email for other notices, or show them at sign-in when none exists. **Must record notice delivery or presentation before starting a notice period**, and provide any additional legally required notice or consent process. +- **Never end a subscription solely for a partial refund or billing correction.** **Must distinguish payment-dispute suspension from termination**: notify the customer, restore remaining access and the prior founding price if suspension was mistaken or payment is restored, and preserve refund and dispute rights; final reversal may end the affected access and renewals. +- **Must refund unused prepaid service, including corresponding collected tax, on permanent discontinuation or termination unrelated to customer breach**, and offer the same remedy for a material service reduction or rejected material terms change during a prepaid term. **Must give at least 30 days' advance notice of discontinuation, material reductions, or material terms changes**, except urgent legal or security requirements; no retroactive terms changes for disputes. +- **Must verify the permitted sales territories and tax setup before accepting payment**, and confirm refund, cancellation, failed-payment, and founder-lock behavior against the published offer. Stripe Billing does not transfer the seller's tax obligations. +- **Must support worldwide sales only where lawful, including EEA and UK consumer rights**: disclose the statutory withdrawal right and model form before purchase and in the confirmation, accept an unambiguous notice without account recovery, and refund withdrawal payments within 14 days without a use deduction or waiver for immediate access. The voluntary guarantee is additional (rationale). +- **Must provide a prominent online withdrawal function on the account billing page throughout the statutory withdrawal period**, distinct from cancelling renewal; allow the consumer to identify the contract, confirm submission, and receive a durable acknowledgement with the statement and its date and time. **Must disclose its location before purchase and in the confirmation** (rationale). +- **Must complete the EEA and UK provider privacy arrangements before launch**: execute applicable processor agreements, document provider transfer safeguards and assessments, and publish the applicable contacts and means to obtain the safeguards. **Never claim a transfer certification, contract, or representative that has not been verified** (rationale). +- **Must recheck ElevenLabs' training opt-out or contractual no-training protection and the applicable processing agreement before enabling paid voices**, and keep the public disclosure consistent with the verified practice. History deletion alone is not evidence of either protection (rationale). +- **Must obtain acceptance of the managed-voice customer provisions before granting paid voice access**, archive the incorporated provider requirements with the accepted terms, preserve mandatory consumer rights, and relay relevant provider notices. **Must apply the terms' age and government-use restrictions to managed voices** and verify the permitted voice selection. **Must obtain ElevenLabs' written approval before marketing that names it**; the Terms and Privacy policy name it because the provider requirements and disclosure law need them to. Provider requirement changes follow the published notice, renewed-agreement, service-reduction, and refund process (rationale). +- **Must complete the privacy notice from verified practices before paid launch**: purposes and applicable legal bases, retention periods or criteria, survey linkage, provider roles, and applicable international-transfer safeguards; describe unshipped practices conditionally. **Must archive previous policies and record actual publication and applicability dates.** **Must apply the replacement terms to new accounts on acceptance and to existing accounts on the notified date at least 30 days after notice**, unless expressly accepted sooner; obtain renewed agreement where required, preserve previous terms until then, and never backdate replacement to the revision date (rationale). + ### Open questions - A free hosted tier, no card. It is the only way a stock binary can try Pocket, since the shipped bundle reaches only `*.dormouse.sh` (`docs/specs/relay.md` -> "Relay origin"). diff --git a/docs/specs/pricing.rationale.md b/docs/specs/pricing.rationale.md new file mode 100644 index 000000000..9b41f6854 --- /dev/null +++ b/docs/specs/pricing.rationale.md @@ -0,0 +1,15 @@ +# Pricing rationale + +## Paid-launch requirements + +- The September 29 account terms promised reasonable advance notice of material changes. The October 8 revision adds a liability cap, business indemnity, and forum clause, so publication is distinguished from acceptance and the notified transition for existing accounts. Its 30-day transition is the chosen contractual process, not a claim that every jurisdiction requires that exact period. Subscription notices use the checkout/billing email because provider-only accounts can lack a sign-in email; an undisplayed account notice is not treated as delivered. +- California notice windows were checked against the [Attorney General's September 2025 guidance](https://oag.ca.gov/node/608083) on 2026-10-07. The applicable sales jurisdictions need review before launch; a generic promise of enough time to cancel does not configure the required notices. +- ElevenLabs' [retention documentation](https://elevenlabs.io/docs/eleven-api/resources/zero-retention-mode) and [model-training guidance](https://elevenlabs.io/docs/help-center/legal/is-my-data-used-to-improve-eleven-labs-ai-models), checked 2026-10-07, distinguish visible history deletion from residual records, backups, and training settings. On 2026-10-07 the workspace training opt-out was saved in the system browser and verified off after a full reload. The Dormouse Hosted service-account key was restricted to Text to Speech and History write, with a 10,000-credit refresh-period limit and leak auto-disable enabled. The operator separately confirmed history isolation. These facts do not establish zero retention or retroactively undo training; section 4(i) of the [provider terms](https://elevenlabs.io/terms-of-use) makes the opt-out prospective after processing. +- The worldwide launch decision includes EEA and UK consumers. The [EU distance-selling guidance](https://europa.eu/youreurope/business/selling-in-eu/selling-goods-services/ecommerce-distance-selling/index_en.htm) distinguishes statutory withdrawal disclosures and reimbursement timing from a voluntary refund promise. No use deduction or waiver is needed to support the offered full-refund policy (reviewed 2026-10-07). +- [Directive 2023/2673](https://eur-lex.europa.eu/eli/dir/2023/2673/oj/eng) added the Consumer Rights Directive's Article 11a online withdrawal function, with national measures applying from 2026-06-19. Despite the amending directive's financial-services title, this function also covers other distance contracts concluded through an online interface (reviewed 2026-10-07). +- The [ICO's representative guidance](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/international-transfers/receiving-personal-information-from-the-eea/) describes separate EEA and UK representation duties and the narrow occasional-processing exception; [its transfer guidance](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/international-transfers/a-brief-guide-to-international-transfers/) distinguishes adequacy from other safeguards. Provider locations alone do not establish either arrangement (reviewed 2026-10-07). +- On 2026-10-08 the operator declined to appoint EEA or UK representatives at this stage and accepted the resulting compliance risk. Appointment is therefore excluded from the internal launch gate; this records a business decision, not a legal exemption. The published privacy contact is DiffPlug's support address and does not purport to be a local representative. +- The [OEM terms](https://elevenlabs.io/oem-terms), checked 2026-10-07, define eligible business customers as Scale, Business, Enterprise, or equivalent subscribers (1.D), but define end users by internal business operations (1.I). The [Grant program](https://elevenlabs.io/startup-grants) advertises Scale access for startups building and launching products, supporting plan eligibility. The operator elected to proceed without vendor negotiation and treat the personal/hobby-use wording as a known supplier-contract ambiguity; grant-expiry planning is outside this review. This decision does not amend ElevenLabs' agreement. Section 2.B also requires approval for government use and public statements about the supplier relationship; adding customer clauses does not supply such approval. +- The [ElevenLabs DPA](https://elevenlabs.io/dpa), checked 2026-10-07, covers entity customers through the incorporated terms and includes EU SCCs and the UK Addendum. The workspace product-terms page showed no pending terms; that does not establish DiffPlug as the contracting customer or supply a consumer OEM exception. +- The managed-voice section added on 2026-10-08 addresses OEM 3.A through scoped incorporation of provider use requirements, a processing permission, no-agency/partnership language, and third-party-beneficiary rights. The [Prohibited Use Policy](https://elevenlabs.io/use-policy) (17 August 2026 version, checked 2026-10-08) also restricts government use and model training with output. The section limits incorporation to voice use; DiffPlug's payment, refund, and dispute provisions remain its own. Provider updates follow the published consumer-protective changes process; a provider requirement incompatible with continued service can trigger the service-reduction remedy. Affirmative acceptance and archiving the incorporated versions remain implementation work. +- The [Grants announcement](https://elevenlabs.io/blog/elevenlabs-grants) FAQ, checked 2026-10-08, asks a recipient to display the "ElevenLabs Grants" logo at the bottom of its website; the [program page](https://elevenlabs.io/startup-grants) states no other publicity term. That request is not the prior written approval OEM 2.B(d) requires for other public statements, and Terms of Use 5(c) reserves the ElevenLabs name and logos. diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index 7e6c56ee0..78244022d 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -136,7 +136,7 @@ Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; `HostingRequirement ## Hosted policies -**Must prerender `/privacy` and `/terms` outside Docs navigation with standalone marketing chrome and an effective date.** +**Must prerender `/privacy` and `/terms` outside Docs navigation with standalone marketing chrome and a last-updated date.** **Must distinguish the revision date from the policy's applicability conditions; draft policies remain marked as not yet effective.** Source of truth: `HostedPolicyLayout` in `website/src/components/HostedPolicyLayout.tsx`. diff --git a/hosted/README.md b/hosted/README.md index d5e2935bf..175dad5ce 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -179,7 +179,7 @@ pnpm exec wrangler secret put ELEVENLABS_API_KEY --config wrangler.voice.jsonc The voice Worker's first secret creates its stub, so set it before the first release that deploys `dormouse-voice`: preflight reads it there. Once that release is live, delete the copy the account Worker held before the split (`pnpm exec wrangler secret delete ELEVENLABS_API_KEY`); its mapper no longer reads it, but a secret should live only where it is used. -Create `ELEVENLABS_API_KEY` in an ElevenLabs account dedicated to Dormouse voice — the Worker deletes that account's entire speech history on a schedule, so never point it at a shared account. Restrict the key to text-to-speech plus speech-history access, and set a spending limit in the ElevenLabs console. How the Worker uses the key is `docs/specs/hosted.md` -> "Managed voice". +Create `ELEVENLABS_API_KEY` for an ElevenLabs service account dedicated to Dormouse voice, with history access isolated from other workspace usage — the Worker deletes every speech-history item its key can see on a schedule. Restrict the key to text-to-speech plus speech-history access, and set a spending limit in the ElevenLabs console. How the Worker uses the key is `docs/specs/hosted.md` -> "Managed voice". Generate a fresh cryptographically random `AUTH_SECRET`, and separately `RELAY_ENROLL_SECRET`, each with at least 32 bytes of entropy in your secret manager. Rotating `RELAY_ENROLL_SECRET` only voids enrollments in progress. Client IDs are public but may be stored through the same prompts as `GITHUB_CLIENT_ID`, `GOOGLE_CLIENT_ID`, `MICROSOFT_CLIENT_ID`, and `APPLE_CLIENT_ID`. The first secret can create the initial Worker stub; it does not activate account service. Set all required secrets before release; deployment preserves the ones already there. diff --git a/hosted/server/account-app.ts b/hosted/server/account-app.ts index c5a02bee8..876b4b467 100644 --- a/hosted/server/account-app.ts +++ b/hosted/server/account-app.ts @@ -3,14 +3,15 @@ import type { AccountEnv } from "./bindings"; import { accountRules } from "./headers"; import { relayAccountRoutes, type RelayAccountHost } from "./relay-account"; import { relayRoom } from "./relay-room-contract"; +import { termsRoutes } from "./terms"; import { voiceTokenRoutes } from "./voice"; import { workerApp } from "./worker-app"; /** * The account Worker (`hosted.dormouse.sh`): auth, providers, readiness, - * voice-token minting, the Relay's account routes, and the frontend. The - * production and preview entries differ only in `fetchAuth`'s mail and in - * `bindings`. + * terms acceptance, voice-token minting, the Relay's account routes, and the + * frontend. The production and preview entries differ only in `fetchAuth`'s + * mail and in `bindings`. */ export function accountApp( fetchAuth: ( @@ -40,6 +41,7 @@ export function accountApp( approveLimit: c.env.RELAY_APPROVE_LIMIT, closeBurrow: (userId, burrowId) => relayRoom(c.env.RELAY_ROOM, userId).closeBurrow(burrowId), }); + termsRoutes(app, host); voiceTokenRoutes(app, host); relayAccountRoutes(app, host); }, diff --git a/hosted/server/account-gate.ts b/hosted/server/account-gate.ts index d1ec0308f..e3392fdfa 100644 --- a/hosted/server/account-gate.ts +++ b/hosted/server/account-gate.ts @@ -39,6 +39,20 @@ export interface AccountLogin { export function cookieAdmin( host: (c: Context) => AccountHost, refuse: (c: Context) => Response, +): MiddlewareHandler<{ Variables: { login: AccountLogin } }> { + return cookieGate(host, refuse); +} + +/** {@link cookieAdmin}'s gate for a route any signed-in account may use. */ +export function cookieLogin( + host: (c: Context) => AccountHost, +): MiddlewareHandler<{ Variables: { login: AccountLogin } }> { + return cookieGate(host); +} + +function cookieGate( + host: (c: Context) => AccountHost, + refuse?: (c: Context) => Response, ): MiddlewareHandler<{ Variables: { login: AccountLogin } }> { return async (c, next) => { const origin = new URL(c.req.url).origin; @@ -61,7 +75,7 @@ export function cookieAdmin( session?: { createdAt?: unknown }; } | null; if (!session?.user) return c.json({ message: "Sign in first." }, 401); - if (!isAdmin(session.user)) return refuse(c); + if (refuse && !isAdmin(session.user)) return refuse(c); c.set("login", { userId: session.user.id, createdAt: session.session?.createdAt, diff --git a/hosted/server/dormouse-migrations/005_terms_acceptances.sql b/hosted/server/dormouse-migrations/005_terms_acceptances.sql new file mode 100644 index 000000000..3d39e690a --- /dev/null +++ b/hosted/server/dormouse-migrations/005_terms_acceptances.sql @@ -0,0 +1,12 @@ +-- Up Migration +-- Each version of the Hosted terms an account agreed to by continuing past the +-- sign-in notice (docs/specs/hosted.md -> "Terms acceptance"), first time only. +CREATE TABLE dormouse_terms_acceptances ( + "userId" text NOT NULL REFERENCES "user" (id) ON DELETE CASCADE, + version text NOT NULL, + "acceptedAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("userId", version) +); + +-- Down Migration +DROP TABLE dormouse_terms_acceptances; diff --git a/hosted/server/policy-constants.ts b/hosted/server/policy-constants.ts index 54cdb70af..75de72e66 100644 --- a/hosted/server/policy-constants.ts +++ b/hosted/server/policy-constants.ts @@ -20,3 +20,10 @@ export const RECENT_LOGIN_WINDOW = `${LOGIN_FRESH_AGE_MS / 60_000} minutes`; // How long an enrollment's device code lives (the relay mints it) and how long // an approval waits for its poll (the account writes it). export const ENROLLMENT_TTL_MS = 10 * 60 * 1000; + +// The Hosted terms version the sign-in notice names and an account accepts by +// continuing: the revision date both policy pages print +// (`website/src/components/HostedPolicyLayout.tsx`), which +// `hosted/server/tests/policy.test.ts` pins this to. A new version is a new +// acceptance row, never an edit of an old one. +export const TERMS_VERSION = "2026-10-08"; diff --git a/hosted/server/terms.ts b/hosted/server/terms.ts new file mode 100644 index 000000000..0a2d7e0c6 --- /dev/null +++ b/hosted/server/terms.ts @@ -0,0 +1,30 @@ +// Rules: docs/specs/hosted.md -> "Terms acceptance". +import type { Context, Hono } from "hono"; +import { accountQuery, cookieLogin, type AccountHost } from "./account-gate"; +import { TERMS_VERSION } from "./policy-constants"; + +/** + * Registers the account's POST /api/terms/acceptance, which records that the + * signed-in account continued past the sign-in notice naming `version`; call + * before any /api/* catch-all. Only the current version is recorded, so a + * notice a stale page showed never stands in for the one in force. + */ +export function termsRoutes( + app: Hono, + host: (c: Context) => AccountHost, +) { + app.post("/api/terms/acceptance", cookieLogin(host), async (c) => { + const body = await c.req + .json<{ version?: unknown }>() + .catch(() => null); + if (body?.version !== TERMS_VERSION) + return c.json({ message: "That terms version is not current." }, 409); + await accountQuery( + host(c), + `INSERT INTO dormouse_terms_acceptances ("userId", version) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [c.get("login").userId, TERMS_VERSION], + ); + return c.body(null, 204); + }); +} diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index 85bd52ff9..d6447676d 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -14,6 +14,8 @@ const PINNED: Record = { "003_relay_push.sql": "ee8cca19bb70eb89fcba708b54d8a188347caf0f32ec23551c56d27676ab094f", "004_relay_enrollment_redeemed.sql": "dd36852ac3efbdc7f0dc2b9ee449f8f213c3c63982c4ab176a27e4a19e08a74d", + "005_terms_acceptances.sql": + "b02891ae98157bf09927e51739057a7882d35e51e2e4d5cf3e25f8a9a3f29447", }; const directory = new URL("../dormouse-migrations/", import.meta.url); diff --git a/hosted/server/tests/policy.test.ts b/hosted/server/tests/policy.test.ts index 359547e4e..f7ff15451 100644 --- a/hosted/server/tests/policy.test.ts +++ b/hosted/server/tests/policy.test.ts @@ -1,4 +1,6 @@ import { test, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { TERMS_VERSION } from "../policy-constants"; import { authPolicy, providerBindings, LOGIN_FRESH_AGE_MS } from "../policy"; import { allowedDevRequest } from "../dev-host-guard"; import type { IncomingMessage } from "node:http"; @@ -40,3 +42,10 @@ test("the recent-login window matches the adapter's own freshAge", async () => { }); expect(built.session?.freshAge).toBe(LOGIN_FRESH_AGE_MS / 1000); }); +test("the sign-in notice names the terms revision the policy pages print", () => { + const layout = readFileSync( + new URL("../../../website/src/components/HostedPolicyLayout.tsx", import.meta.url), + "utf8", + ); + expect(layout).toContain(`Last updated