klipstack/docs/security.md at main · klipstack/klipstack · GitHub
Skip to content

Latest commit

 

History

History
82 lines (71 loc) · 50.8 KB

security.md

File metadata and controls

82 lines (71 loc) · 50.8 KB

Security boundaries

What the code protects, how, and which tests hold it in place. Report problems privately: see SECURITY.md.

Running it safely

  • Configure working email delivery or a sign-in provider that verifies email ownership. Password login and library use work for unverified users; authorizing OAuth apps requires a verified email.
  • Set TRUST_PROXY to the exact address or subnet of your reverse proxy, and keep the web port reachable only through it. A wrong value can reject valid HTTPS form posts; never trust a range that includes untrusted clients.
  • Keep S3-compatible buckets private. Images always pass through the app's owner-checked asset route.
  • The container runs as UID 1000; volumes it writes must belong to UID and GID 1000.

See docs/self-hosting for the full setup.

Controls and regression tests

Boundary Protection Tests
Email ownership Verified users only can create durable credentials; first verification revokes legacy access in the same database transaction. An app's sign-in by an unverified account (a sign-up from the app's sign-in included) stops at /oauth/verify-email, the provider's postLogin page, which keeps the app's signed request and offers to send the link again; its Continue hands the request back to the provider, which checks the session and the verification again. The recent sign-in is checked when the consent is granted. app/lib/auth.server.test.ts, app/services/credential-triggers.test.ts, app/routes/app-sign-up.test.ts, e2e/oauth.spec.ts
Recent sign-in Connecting a new app and deleting the account need a sign-in from the last 30 minutes (session.created_at, which refreshes keep), like admin writes. An older session with a password confirms it in Settings (/settings/confirm-password, the same in-process sign-in with the session's own email as admin's, under the login's limits), so it never passes through the login page; one without a password, or on the consent page, is asked to log in again. The app check sits in consentReference, which better-auth calls from every endpoint that grants consent; an app connected before may be allowed again from any session. e2e/oauth.spec.ts
Verification link The Settings email links to /verify-email, which requires a session: signed out, the visitor logs in and returns. beforeEmailVerification refuses a token whose account is not the signed-in one, so a squatter (password, no mailbox) and the mailbox owner (link, no password) each fail alone. The link signs nobody in, lasts 60 minutes and removes nothing. better-auth's own /verify-email and /send-verification-email endpoints are disabled; sending shares the auth limits. The login page reached from the link hides the magic-link option, which would remove the password. app/lib/auth.server.test.ts, app/routes/settings.verify-email.test.ts, e2e/auth.spec.ts
Password reset The emailed link lasts 60 minutes and works once. Unknown addresses get the same answer and no mail; requests share the per-account and per-IP limits. Saving a password ends every session. For a verified account it also revokes every connected app, in one transaction: a reset is a recovery, and whoever held a session may have left one behind. For an unverified account the link counts as mailbox proof alone, like a magic link: provider links from before it are removed and the account becomes verified, which also fires the credential trigger. The token never leaves the origin (Referrer-Policy: same-origin). app/lib/auth.server.test.ts, app/lib/auth-limits.server.test.ts, e2e/auth.spec.ts
Verification discovery Unverified accounts see a reminder after signup and login, plus Account status in Settings. The same form sends and resends links, reports delivery failures and rate limits, and handles expired links. It always uses the signed-in account's email. English and Spanish are supported. app/routes/settings.verify-email.test.ts, e2e/auth.spec.ts
OAuth revocation A random, persisted grant reference travels from authorization code through refresh tokens and JWT claims. API and MCP compare it with the current grant. Consent deletion removes it, including through the provider's endpoint. Old references never become valid again. app/services/oauth-apps.server.test.ts, app/api/auth.server.test.ts, e2e/oauth.spec.ts
Retired API keys Programs reach the API and MCP only with OAuth tokens. Personal API keys (klip_…) are retired: nothing creates them, and the API and MCP refuse one with 401 like any unknown token, even one still in the api_keys table and never revoked. The table is kept, unread, until a later migration drops it. /settings/api-keys redirects to Connected apps. A request with a bearer token is signed out of the app's pages and browser endpoints, even with the session's cookies. app/routes/api.v1.test.ts, e2e/bearer-tokens.spec.ts
Mobile app sign-in klipstack-mobile is a public native client (PKCE, no secret), linked to /api only. The app (iOS 17.4 or later) gets its sign-in back through the universal link <APP_URL>/app/oauth/callback (mobileRedirectUri), which only the app signed by our team opens (see "iPhone app links"). An https server (klipstack.com) accepts no other redirect for it (mobileRedirectUris). Only an http server (the dev server, or one self-hosted without TLS) also accepts the custom scheme com.klipstack.app:/oauth/callback, which any iOS app can claim; the app sends it there, as a universal link can't work without https. Builds from before the universal link can't sign in to klipstack.com again. The client skips consent (skipConsent): the app signs in on its own screen (see "The app's sign-in screen"), and a signed-in browser that opens its authorization request sends the code only to that redirect, which on an https server only our app opens. On an http server, an app that claims the scheme can get the code of a reader signed in in Safari; a saved consent allowed that silently before too. Run a server the app signs in to on https. Builds from before that screen send prompt=consent, which still shows the consent screen, saying to allow it only right after tapping Sign in. Apps below the platform's minimum version get 426 client_outdated before sign-in; the X-Klipstack-Client header only ever turns a request away. app/routes/app-sign-up.test.ts, app/api/router.test.ts, app/lib/auth-constants.test.ts, e2e/api.spec.ts
The app's sign-in screen The iPhone app signs in without a web page: /app/sign-in takes a password, an emailed code or Apple's sheet's tokens, signs in through better-auth in process (its checks, limits and the invite gate), then runs the app's own authorization request with that new session and answers only its code, bound to the app's PKCE challenge. Its session is set on no response: only the token exchange, with the app's verifier, turns it into tokens. An account with two-factor authentication gets 403 two_factor_required instead, with its pending sign-in as an opaque twoFactorToken that /app/sign-in/two-factor takes with the code (see "Two-factor authentication"). Nothing there creates an account, in any sign-up mode: the user-create hook refuses a user from Apple's ID token (/sign-in/social, only reached in process) or from a provider sign-in the app's sheet started (a flag in the OAuth state's server context), unless it runs as the app's sign-up (see "The app's sign-up"). A wrong password or code is 401 unauthorized; an unverified account's password is 403 verification_required and its session is ended (the emailed code verifies it, as a magic link does: earlier ways in are removed); an unknown Apple ID is 404 not_found. The code (/app/sign-in/code) is 6 digits, stored hashed, valid 10 minutes and for 3 tries; it is sent only to existing accounts, with the same 204 for any address, at most 10 a day per address typed and 100 per client address (so 30 guesses a day; ten requests for someone's address also stop their codes for that day, not their other ways in), and sending and checking are not open over HTTP (better-auth's email-OTP endpoints are closed). Apple's sign-in is limited per address. Google and GitHub run in the app's sign-in sheet, an ephemeral browser session: /app/sign-in/<provider> takes only klipstack-mobile's request with one of its redirect URIs (anything else is 404), goes straight to the provider, and the provider's callback continues to that request; a failure goes back to the same redirect URI with an error, read from a signed cookie that lasts 10 minutes. app/routes/app.sign-in.test.ts, app/lib/apple-sign-in.server.test.ts, app/routes/settings.sign-in.test.ts
The app's sign-up The iPhone app creates accounts only through /app/sign-up, /app/sign-up/code and its provider sheet with sign_up=1, and only with a usable invite unless SIGNUP_MODE=open. The invite is checked before anything else (a code, a trip to the provider) and locked and consumed in the transaction that creates the user, and only if a user was created: an Apple ID that has an account just signs in, leaving the invite unused. Email: /app/sign-up/code emails a 6-digit code (stored hashed, valid 10 minutes and for 3 tries, taken out of the table before it is checked, so two requests can't both use it) to an address with no account; an address with an account gets a mail saying to sign in, and the app the same 204. Sending is limited to 10 a day per address typed and 100 per client address, the sign-up to 10 per address and 20 per client address an hour, as the web's. The account is created, with its password and verified, only once the code checks out. Apple: Apple's code is exchanged and its subject checked as for the sign-in; without a usable invite an Apple ID with an account still signs in, and one with none is refused and the authorization Apple's sheet just gave is revoked. Google and GitHub: the invite travels in the OAuth state's server context, which only the server writes, with a flag that lets the callback create a user; a client can set neither. A new account takes the phone's language from Accept-Language. app/routes/app.sign-in.test.ts, app/lib/apple-sign-in.server.test.ts
Account deletion through the API POST /api/v1/account/delete runs the web's command (deleteAccount: stored images, then rows, nothing kept) under the web's two checks. The typed confirmEmail must be the account's email, whatever its case (invalid_request). The sign-in must be from the last 30 minutes (reauthentication_required): the time is the created_at of the browser session the token's OAuth sign-in went through (its sid claim), as the web reads its own session's; a refresh keeps the sid, so a refresh token, however new, never counts as a sign-in. A token whose session is someone else's, ended or missing counts as no sign-in. The app then asks for a new sign-in on its own sign-in screen (any of its ways in: each makes a new session), checks the account is still the same one, and repeats. Only OAuth tokens issued to a client in ACCOUNT_DELETION_CLIENTS (the iPhone app, klipstack-mobile) may call it: the CLI and other apps get 403 forbidden (clients on the endpoint, checked by the API router); MCP has no such tool. Another iOS app claiming the app's scheme could get such a token if the reader signs in and consents in its flow (see "Mobile app sign-in"), and with it delete the account within 30 minutes: more than its library write access destroys (feeds, connected apps, the account itself), and why an https server accepts no custom scheme for it. Deleting the user removes its sessions, tokens and consents, so the token stops working. Like the web's, it writes no audit event; the operator's deletion does. app/routes/api.v1.account.test.ts, app/api/openapi.test.ts, e2e/oauth.spec.ts
Mobile app session The app, its share extension and its home screen widget share one session (API URL, tokens) in a keychain item of their app group. Signing out deletes it and the article the widget keeps for offline use; the widget never deletes the session itself. The bearer token goes only to the session's own API origin: a lead image stored by Klipstack is downloaded with it, a site's own image only over https and without it. The widget refuses images over 10 MB. apps/mobile/src/lib/lead-image.test.ts; the widget's Swift copy of the rule (targets/widget/Klipstack.swift) has no test
Safari extension sign-in Safari sends no SameSite=Lax cookie with an extension's request, so the Safari build signs in on its own: klipstack-extension is a public client (PKCE, no secret), linked to /api only, with the scopes openid offline_access library:read library:write (write is not optional for it). Its one redirect URI is a page of the server, <APP_URL>/extension/connected (extensionRedirectUri), which shows neither the code nor anything from another site. The server sends this client's codes to that exact address only (better-auth matches it in full). The extension reads the code from that tab's address only when its state matches the sign-in it started, so no page can tie it to another account, and exchanges it with the verifier it alone holds. PKCE protects only the extension's own sign-ins: the consent screen shows on the first connection, and once it was allowed, a flow anyone starts with a verifier of their own gets a code at that page without asking. What keeps such a code from them is that only the server's origin, and extensions with access to it, can read that tab: the page loads nothing from another site, the referrer stays on the origin (Referrer-Policy: same-origin), framing is denied, and the server logs no query strings. On an http loopback server (development), better-auth ignores the redirect's port, so another local port could receive a code. The consent screen says to allow it only right after clicking the button in Safari. Tokens live in the extension's storage.local; a refresh token the server refuses (the reader disconnected it in Settings → Connected apps) is forgotten, and the next save asks again. Chrome's and Firefox's builds keep saving with the browser's session (/api/documents). e2e/extension.spec.ts, apps/extension/lib/connect.test.ts
iPhone app links /.well-known/apple-app-site-association names one app, <APPLE_TEAM_ID>.<APPLE_BUNDLE_ID>, from the server's environment, and lets it open one path, the sign-in callback /app/oauth/callback (MOBILE_CALLBACK_PATH); every other link stays on the web. Apple signs each app with its team, so only that app can claim the path, which is why the app signs in through it (see "Mobile app sign-in"). It also lists the app under webcredentials, which the sign-in sheet needs to hand an https callback to the app, and which lets the app offer the reader's saved klipstack.com passwords from iCloud Keychain, with the reader's confirmation each time. Without both values it answers 404, the default for self-hosters; klipstack.com sets both. An invalid team or bundle ID stops the server at startup. app/routes/well-known.apple-app-site-association.test.ts, app/lib/env.server.test.ts
Authentication abuse PostgreSQL counters apply to server API calls and HTTP endpoints. Limits cover normalized accounts and a server-signed client IP, and return 429 with Retry-After. Forwarding headers alone cannot choose an identity. An address with 60 refused bearer tokens in a minute gets 429 from the API and MCP before any token lookup. app/lib/auth-limits.server.test.ts, app/api/auth.server.test.ts
Signup The invite row is locked before account creation. Auth writes and invite consumption share one transaction. Failure rolls back both; deleting an account does not reuse its consumed invite. app/lib/invites.server.test.ts
Sign-up by emailed link A sign-up without a password creates nothing until its emailed link is confirmed. The invite is checked before any mail is sent, behind the sign-up limit, and consumed with the account in one transaction; the code travels in the link. The mailer writes to an address with no account only in a server-side context set by /signup: better-auth's own magic-link endpoint cannot. An address that has an account gets its login link and the same answer. The link opens a page whose button POSTs, so a mail scanner spends nothing. The token is checked before the invite. better-auth's GET endpoint refuses a sign-up token in invite mode. The account starts verified, with no password. e2e/auth.spec.ts
Sign-up and login with Google, GitHub or Apple In invite mode a provider sign-up needs an invite like any other. /signup checks it behind a per-address limit and puts it in the OAuth state's server context, which only the server writes: an invite a client adds to the state is ignored. The callback runs in one transaction that locks the invite when the user is created and consumes it after: a failure rolls back user, link and session. The login button carries no invite and creates nothing in invite mode. A provider is never linked by email to an unverified local account, so a squatter's password account cannot capture the owner's provider login. Provider tokens are not stored, but for Apple's refresh token: Apple requires revoking the person's Sign in with Apple when the account is deleted, which needs it. It is kept in account.refresh_token, encrypted with BETTER_AUTH_SECRET with the client it was issued to (the Services ID or the app's bundle ID), and used for nothing but appleid.apple.com/auth/revoke, when the account is deleted (from Settings, the API or the operator CLI) or Apple is disconnected in Settings. A failure there is logged and the deletion goes on; a link from before tokens were kept, or one sealed under an older secret, is not revoked. Apple answers with a cross-site form post that carries none of our cookies (all are SameSite=Lax); the server only turns it into a GET of the same URL, where the state is checked as for the others. Apple's client secret is a JWT the server signs with the Sign in with Apple key from its environment, for at most six months, renewed daily. better-auth's /sign-in/social is closed over HTTP: our pages start provider sign-ins in process, and it would also take an ID token by itself. Apple ID tokens are accepted only for the Services ID and the iPhone app's bundle ID (APPLE_BUNDLE_ID), signed by Apple and at most an hour old, and only through /app/sign-in, which first exchanges Apple's authorization code (one use, five minutes) and requires its subject to be the token's: a copied ID token alone signs nobody in. They sign in or link existing accounts, and create one only through the app's sign-up (see "The app's sign-up"). When Apple's sheet authorized someone with no account here, or with an account not connected to Apple, or a sign-up was refused, that authorization is revoked at once. Apple's form post becomes a redirect whose query carries the code and, at a first sign-in, the name and email Apple sends; the app's request log drops queries. app/lib/provider-sign-in.server.test.ts, app/lib/apple-sign-in.server.test.ts, e2e/sign-in-methods.spec.ts
Two-factor authentication An account with it on gets no session from any first step alone: a password (better-auth's plugin), an emailed link, a provider callback, the app's emailed code or Apple's ID token (twoFactorGate, the auth config's after-hook, which runs before every plugin's, the OAuth provider's included, so no app gets an authorization code first). Each leaves a pending sign-in instead: a signed cookie (the app gets it as an opaque twoFactorToken) valid 10 minutes for 5 codes, finished on /login/code or /app/sign-in/two-factor. The plugin locks the account for 15 minutes after 10 wrong codes; codes also count per address (/two-factor/verify-*), and those typed in Settings per account. The lock ends on its own, so someone who has the password can keep guessing at about 960 codes a day (roughly 0.3% a day of hitting one), and nobody is told: the password is the boundary that matters, the code a second one. Only the pending sign-in's cookie reaches the plugin's verify step: with a session cookie it would check that session's account instead. Confirming a password (Settings, admin) asks for the code too. Turning it on needs the two-factor flag, a verified email and the password (or a sign-in from the last 30 minutes without one), with password guesses counted per account as a password change's; the first code turns it on and ends every other session. Turning it off needs a current code (or a backup code) and, when the account has one, the password, checked first so a wrong one uses up no backup code. New backup codes, which replace the old, need the same proof. Connected apps (OAuth tokens) stay signed in when it is turned on or reset, as after a password change. The secret and the 10 backup codes are encrypted with BETTER_AUTH_SECRET; each backup code works once. No remembered devices, no codes by email. A reader with neither phone nor backup codes is reset only by the operator CLI (admin reset-two-factor), with an audit event; a password reset leaves it on. The plugin's HTTP endpoints are closed. app/lib/two-factor.server.test.ts, app/routes/settings.two-factor.test.ts, app/routes/app.sign-in.test.ts, app/lib/provider-sign-in.server.test.ts, app/lib/apple-sign-in.server.test.ts, app/services/admin-accounts.server.test.ts, e2e/two-factor.spec.ts
Ways to log in (Settings) Only a verified account adds a password or connects a provider; the provider's email must equal the account's. Changing a password needs the current one, behind a per-account and per-address limit, and signs out other sessions. The last way in cannot be disconnected. better-auth's /change-password, /link-social and /unlink-account are closed: only the Settings action reaches them. app/routes/settings.sign-in.test.ts, e2e/sign-in-methods.spec.ts
Incoming bodies Byte caps apply before parsing, with upload concurrency limits and idle/total deadlines. Large uploads take slots from one pool shared by the web and the HTTP API (2 per user, 6 in all). Forms use 64 KiB for auth and 1 MiB elsewhere; captures use 12 MiB; MCP uses 1 MiB, and a tool call's arguments hold at most 1,000 array elements and object members, refused before they are validated. Rejected bodies are discarded only within a small byte/time allowance before closing the connection. app/lib/request-body.server.test.ts, app/lib/body-reader.server.test.ts, app/lib/forms.test.ts, app/routes/mcp.test.ts, e2e/auth.spec.ts, e2e/api.spec.ts
Saving and exporting Shared links (/add) open the Save link dialog with the link filled in, and save nothing by themselves. Saving requires a CSRF-protected POST and uses the shared save bucket, one save per link when several are sent at once. Both export routes use one admission function, with per-user quota and bounded concurrent exports. app/routes/add.test.ts, app/routes/library.save.test.ts, app/commands/saving.server.test.ts, app/services/export.server.test.ts, e2e/documents.spec.ts
Browser privacy Private Cache Storage partitions use server-confirmed session identifiers. Logout, account deletion and account switching remove private caches, invalidate in-flight warming, and reload other tabs. Images use network authorization before offline fallback. The separate unscoped memory view cache was removed. Public static assets remain cacheable. app/lib/offline.test.ts, e2e/offline.spec.ts
Article HTML The reader, and the edition editor's article pane, render stored article HTML inside the app's page. Extraction removes forms, scripts, styles and embeds (clean.ts), then sanitizes with an explicit list of tags and attributes (sanitize.ts): no event handlers, styles or background; links and images keep only http(s), mailto and in-page addresses. Each list forbids forms, styles and embeds on its own, so a caller that skips cleaning is still safe. Every rendered page sends script-src 'self' with a new nonce per response: a script injected into the page does not run. The public guide page (/guide/<language>) renders the guide bundled with the app without extraction; a test holds every bundled starter page to plain text markup without attributes. The news pages (/news/<slug>, and the feed) render the entries in app/news/ the same way; a test holds them to text markup whose only attribute is a link's https or same-site href. packages/extract/test/sanitize.test.ts, e2e/auth.spec.ts, app/lib/starter-texts.server.test.ts, app/lib/news.server.test.ts
Administration /admin needs a browser session whose account has the admin role, a verified email, a password and two-factor authentication on, read from the database on every request (the CLI refuses to grant the role to an account without a password; an admin without two-factor gets 403 two_factor_required and a page that links to its setup, which Settings offers every admin); every loader and action checks for itself. OAuth tokens and MCP never carry admin authority. Only the operator CLI changes roles, in one transaction with ending that account's sessions and the audit event. Writes need a sign-in from the last 30 minutes (session.created_at, which refreshes keep); an older session renews it with the signed-in account's own password and two-factor code (/admin/reauthenticate: a sign-in in process with the session's email, under the login's limits, whose new session replaces the old one), never through the login page, where anyone could sign in. Writes also need a same-origin POST, a body under 16 KiB, and pass per-admin hourly limits. Each change locks the actor's row and checks the role again, so a revoke and a change are ordered. No change commits without its audit event. app/lib/admin.server.test.ts, app/routes/admin.routes.test.ts, app/services/admin-*.server.test.ts, e2e/admin.spec.ts
Operator account deletion Only the operator CLI deletes another person's account (admin delete-account): database access in the web container is the credential, as for roles, and there is no web endpoint. --confirm must repeat the account's email, an admin's account is refused, and the deletion is the one Settings runs (stored images, then rows) with an account.delete audit event, its reason and no content, in the same transaction. app/services/admin-accounts.server.test.ts
Admin data exposure Admin serializers list their fields: no credentials, article content, URLs, queue payloads or exception text. The one exception is what a reported or removed public page already showed anyone: a shared highlight is named by the start of its quote and its article's title. Audit metadata is an allowlist of IDs and categories; invite codes, notes and emails have no key. The service worker never reads, stores or warms /admin paths and shows a plain notice offline. Its version bump drops every cache an older worker made; if an older worker cannot be replaced, the admin layout deletes the admin responses it stored and shows no admin data. app/services/admin-*.server.test.ts, app/lib/offline.test.ts, app/lib/admin-freshness.client.test.ts, e2e/offline.spec.ts
Feature flags Turning a feature in testing on or off (/admin/users/:id/features) passes the same checks as every admin action: role and verified email from the database, POST, same origin, a recent sign-in, and the role checked again inside the transaction; the change commits with its audit event. A flagged feature's routes answer 404 to accounts without the flag (requireFeature), not only hide their links. One exception: Settings' two-factor page sends such an account back to Account, since an account that turned it on before its flag was removed lands there right after turning it off; its changes (/settings/two-factor) still answer 404. Admins are the other exception: Settings offers them two-factor authentication without the flag, since /admin needs it (twoFactorOffered, app/routes/settings.two-factor.test.ts). A new account starts with newAccountFeatures (ai, topics) on, written in its sign-up's transaction with the user and the invite; admins can turn them off. app/services/admin-features.server.test.ts, app/routes/admin.routes.test.ts, app/lib/auth.server.test.ts, app/lib/provider-sign-in.server.test.ts
Account switches Settings → Features turns AI answers, editions and feeds off for the account, in its prefs. The commands check the switch, not only the pages: with AI answers off, every question, follow-up, retry and new brief is refused forbidden on the web and in the API, so no article text goes to the AI provider; the answers already kept can still be read and deleted. With editions off (the default), marking for the next edition, edition notes and every editor action are refused forbidden, the editor's routes answer 404, and published editions stay public. With feeds off (the default), every feed command, API endpoint and MCP tool is refused forbidden, the feed's routes answer 404, Home shows no posts, and the worker fetches no feed that only such accounts follow; the subscriptions stay. app/routes/api.v1.ai.test.ts, app/routes/api.v1.home.test.ts, app/routes/api.v1.test.ts, app/routes/mcp.test.ts, apps/worker/src/jobs/feeds.test.ts, e2e/ai-reader.spec.ts, e2e/editions.spec.ts, e2e/feeds.spec.ts
Wait list /request-invite exists only in SIGNUP_MODE=waitlist. It sends no mail: the admins learn of new requests from a daily digest with only counts, so the form cannot send our mail to an address someone types. Every stored request, repeat, existing account, bot trap or too-fast post gets the same answer. A signed form time (3 seconds to 24 hours), a hidden trap field, per-address and per-IP limits in PostgreSQL and a cap of 500 pending requests bound abuse. Approving creates the invite, then emails it after the commit; answering a request deletes it. app/lib/invite-requests.server.test.ts, app/services/admin-invite-requests.server.test.ts, apps/worker/src/jobs/invite-digest.test.ts, e2e/wait-list.spec.ts
Per-account caps Saves, first publishes, new highlight links and AI questions a month, storage and followed feeds are capped per account (admins have a higher AI cap, by role, read on each check), counted in PostgreSQL so deploys and machines do not reset them. Monthly counts are bumped in the transaction that does the work, under a row lock, so two requests cannot both take the last unit; deleting an article does not give its save back. A refusal is 403 limit_reached and counts as a cap hit for /admin/usage, outside the refused transaction. With billing on (BILLING_STARTS_AT), the caps follow the account's plan, worked out in SQL from its sign-up date, its role and an admin's free_until (packages/db/src/plan.ts), read on each check, the worker's included. A lapsed account is refused anything new with 402 subscription_required by the same checks, and by checkMayAdd where nothing is counted: highlights, article and highlight notes, following a feed (before its address is fetched), and updating a published edition or shared highlight. It still reads, exports, organizes and deletes, and its public pages stay up as they were. The worker filters by plan on each run: it stops fetching feeds only lapsed accounts follow, and runs no topics for a lapsed account. Only an admin sets free_until, through the usual admin checks and audit. app/lib/limits.server.test.ts, app/commands/annotations.server.test.ts, app/routes/api.v1.test.ts, app/services/admin-billing.server.test.ts, app/routes/admin.routes.test.ts, app/commands/highlight-shares.server.test.ts, apps/worker/src/jobs/feeds.test.ts, apps/worker/src/jobs/topics.test.ts, app/commands/saving.server.test.ts, app/commands/feeds.server.test.ts, app/services/feeds-opml.server.test.ts, app/services/editions.server.test.ts, app/services/admin-usage.server.test.ts
Edition reports /e/:publicId/report and /h/:publicId/report (shared highlights) take reports from anyone. It sends no mail: an optional, unverified address gets one email, after an admin decides. It has the wait list's guards (a signed form time made for this form only, a trap field, one answer for everything), limits of 10 an hour per address and 3 an hour per edition from one address, and a cap of 1,000 open reports. A report hides nothing; only an admin's removal does, through the usual admin checks. A removed edition answers 410 and its owner cannot publish it again. The owner's email names the ground and the admin's note, never the reporter. Decided reports are deleted; the audit keeps the ground and the count. Admins get hourly counts, never report text. app/lib/edition-reports.server.test.ts, app/services/admin-reports.server.test.ts, app/routes/admin.routes.test.ts, apps/worker/src/jobs/report-digest.test.ts, e2e/edition-reports.spec.ts
Shared highlights /h/<publicId> shows a snapshot taken when the owner shared or last updated it, never the library: a later edit of the highlight or its note reaches the page only through Update link. It shows at most 100 words or 600 characters of the quote, and of the note (measured as it reads, a link by its title, never cut inside one), cut where the page, its picture and its report form read it (getPublicShare): the snapshot keeps the whole highlight, for its owner only, so links shared before the cap are cut too. The address is 12 random base62 characters; stopping takes the page down (410), and sharing again makes a new address, so an old link never comes back. A deleted highlight's page is down while it is deleted. Only the owner shares, updates or stops (highlights/:id/share, session and CSRF like other web actions; /api/v1/highlights/{id}/share with a token's write scope, reading it with read); another reader's or a deleted highlight is not_found. The note goes only when the owner leaves it ticked; the page never names the owner, and its note links point to the linked articles' original addresses. The page's data carries the note as text and original addresses, none of the owner's article ids. Pages send noindex, link out with nofollow ugc noopener, and the service worker never caches them. New addresses count against a monthly cap. A highlight's picture to share by itself (/highlights/:id/image.png) is drawn for its owner only (session, or /api/v1/highlights/{id}/image.png with a token's read scope; another reader's, a deleted or a trashed one is 404), at most 120 an hour per reader and server process, the web and the API together (drawHighlightImage); nothing is stored, and the dialog's copy goes with it (no-store). Reports, removal (410, and share_removed for the owner) and restore work as for editions, under the same admin checks and audit. app/commands/highlight-shares.server.test.ts, app/routes/highlight.public.test.ts, app/components/shared-highlight-page.test.tsx, app/routes/highlights.image.test.ts, app/routes/api.v1.shares.test.ts, app/lib/offline.test.ts, app/services/admin-reports-shares.server.test.ts, e2e/share-links.spec.ts
Public editions /e/<publicId> shows the snapshot taken at the last Publish or Update, never the library. Each item's note and each quote's note show at most 100 words or 600 characters, measured as they read (a link by its text, never cut inside one), its paragraphs kept: editionView cuts them with the shared highlights' code (lib/public-cut.ts), so the preview shows the same. The snapshot keeps the whole note, for its owner only, so editions published before the cap are cut too. Quotes are capped when they are chosen and again at publish (quoteProblem). app/routes/edition.public.test.ts, app/routes/highlight.public.test.ts
Invite revocation Signup refuses a revoked invite when it looks the invite up and again when it consumes it, and a signup whose invite was not consumed rolls back. Revoking also shortens expires_at to five minutes before the revocation (never extends it), so signup code from before the admin release refuses the invite too, even when the application's clock runs behind the database's. Signup and revoke take the same row lock: one wins. app/services/admin-invites.server.test.ts
Queue admission Saving, the owner's retry, the admin retry, the re-extract script and the worker share one admission (packages/queue/src/admission.ts): a document row lock plus a look at waiting jobs, with the job sent in the caller's transaction. pg-boss's singletonKey alone does not deduplicate under the standard policy. A capture saved while a job runs gets its own job; the running job publishes nothing once a newer capture exists, and versions are numbered under the document's row lock. packages/queue/src/admission.test.ts, apps/worker/src/jobs/extract.test.ts, app/services/admin-jobs.server.test.ts
Image publication An image job checks its version at start and again under the document's row lock when it publishes: HTML, images_done and the thumbnail commit together, or nothing does. A job whose article was re-extracted, trashed or deleted meanwhile publishes nothing and reports skipped. apps/worker/src/jobs/images.test.ts
AI in the reader Article text and the reader's question go to one AI provider (Anthropic) only when the server has its key, the account has the ai flag, and the reader asks; the key stays in the server's environment. Every thread, answer and brief query is scoped by account and a live article; a chat or a brief is the account's own. Questions count against a monthly cap and a rate limit (30 per 5 minutes; per web process), the web reader's and the API's (/api/v1/ai, /articles/{id}/ai/…, which only call the same commands; asking needs a write token) together; the reader dims its buttons at the cap and says so without asking, but only the server's count refuses. Spending the account's AI is for Klipstack's own apps only; reading what it answered already is not spending. Through the API, every AI endpoint that asks, briefs, retries, deletes or restores, or records consent, answers only an OAuth token issued to a client in IN_APP_AI_CLIENTS (the iPhone app, klipstack-mobile), and the CLI or any other app gets 403 forbidden (clients on the endpoint, checked by the API router). GET /ai and GET /articles/{id}/ai (the threads, answers and brief already kept) answer every client with a read token, as MCP's one AI tool, get_ai_history, does: they never ask the AI, never write a brief and never count against the cap, and still need the account's ai flag. Reading moves threads asked of an older version to the current text, as the reader does. Never give API clients or MCP a tool or endpoint that asks the AI. The iPhone app asks before the account's first question and keeps the answer on the account (PUT /ai/consent, also limited to IN_APP_AI_CLIENTS); the app enforces it, not the server, and the web reader does not ask. This is a product rule, not a boundary: a person can sign in as the iPhone app from a script, but only to their own account and within its cap; a website cannot, since the app's redirect is its custom scheme (another iOS app claiming the scheme could, after the reader consents: see "Mobile app sign-in"). An article over 400,000 characters is refused before it counts. Citations name sentences of the article text by number; a number it lacks is dropped, a citation covers at most 10 sentences and an answer at most 50 citations, and markers and sentences are read in linear time (the model's output and the page are untrusted). Logs keep ids, model and token counts, never the article, passage or question. Anthropic's console spend limit is the backstop. app/commands/ai.server.test.ts, app/lib/limits.server.test.ts, app/routes/api.v1.ai.test.ts, app/routes/mcp.test.ts, packages/ai/src/*.test.ts, e2e/ai.spec.ts, e2e/ai-reader.spec.ts
Automatic topics Article text goes to TypeSafe (sorting) and Anthropic (proposing, growing and reviewing topics) only when the server has both keys, the account has the topics flag, and the reader turned topics on; turning them off deletes the reader's topics, sorts and refusals in one transaction, and every write of a run in progress first checks the reader still has topics on, inside its own transaction, so a run that outlives the switch keeps nothing. Every topic read and command is scoped by account, the API's shelves (GET /topics?latest=, the trash left out), GET /articles?view=all and the filters' counts (GET /articles/counts) too: another reader's topic, or a trashed or foreign article, is not_found (the web's lists ignore such a ?topic= and show every topic); a search's topic: matches only the reader's active topics, and saved searches follow their topics only within the account; an article taken out of a topic is never put back. Models see short refs, never ids, and a growth or review naming refs outside its request is dropped. Haiku's work counts against a monthly cap (LIMIT_TOPICS_PER_MONTH) once answered; sorting with Jev counts against a monthly cap of its own (LIMIT_TOPIC_SORTS_PER_MONTH, first sorts and re-sorts alike, each article once sent; at the cap, growing and reviewing stop too, and a topics-month job on the 1st queues a run for each reader with topics on) and runs 200 articles at a time, with a 30 s timeout per request; both providers' costs count in the account's topic spend, apart from its AI spend. Model-written names are cleaned as a reader's are, and descriptions cut to 600 characters, before they are stored. One worker run per reader at a time (a stately queue). Counts of how a reader uses topics are numbers per account and month, never an article, title or topic: POST /topics/use counts only for the signed-in account, with the topics flag, one of five named clicks (anything else is invalid_request), at most 60 a minute per web process (rate_limited); without the flag it answers forbidden, as the other topic actions do; renames, removals and articles taken out are counted by their commands, only when they succeed. The API's topic changes (POST /api/v1/topics/enable, /disable, /confirm, /seen, /card/dismiss, /invitation/end, /use; PATCH and DELETE /api/v1/topics/{id}; DELETE /api/v1/topics/{id}/articles/{articleId}; all write scope) only call the same commands, so the flag, the providers' check, the caps and the rate limit hold for every client. app/commands/topics.server.test.ts, app/routes/topics.use.test.ts, app/services/admin-usage.server.test.ts, app/services/home-topics.server.test.ts, app/routes/reading-home.topics.test.ts, app/routes/library.topics.test.ts, app/services/search-topics.server.test.ts, app/routes/api.topics.test.ts, app/routes/api.v1.topics.test.ts, apps/worker/src/topics.test.ts, packages/ai/src/topic-models.test.ts, e2e/topics.spec.ts
Searching highlights and notes The search page's Highlights and Notes groups, the command palette, the Notebook (/notebook and GET /api/v1/notebook: its views, search, filters, tag and topic), GET /api/v1/search/annotations, GET /api/v1/highlights?q= and MCP's list_highlights look only at the signed-in reader's rows, by user id on the highlight and on its article. A deleted highlight never shows, nor a highlight or note of an article in the trash (in:trash finds none). Snippets travel as plain text runs that React renders as text, never as HTML. The search vectors (highlights.search, documents.note_search) stay on the server: reads leave them out. app/services/annotation-search.server.test.ts, app/routes/search.annotations.test.ts, app/services/notebook.server.test.ts, app/routes/notebook.test.ts, app/routes/api.v1.notebook.test.ts, e2e/search-annotations.spec.ts, e2e/notebook.spec.ts
Highlights brought back on Home dailyHighlights picks only the signed-in reader's highlights, by user id on the highlight and on its article: never a deleted or orphaned one, nor one of an article in the trash. The API's GET /api/v1/daily-highlights takes the device's local date, refused when it is more than a day from the date in UTC, so a client cannot rewrite when highlights were last shown. "Not useful anymore" and its undo (POST /home/highlights; POST and DELETE /api/v1/daily-highlights/{id}/dismiss) are not_found for a highlight that is someone else's, deleted, or in a trashed article, so no row is ever written for another reader's highlight. app/services/resurfacing.server.test.ts, app/commands/resurfacing.server.test.ts, app/routes/api.v1.notebook.test.ts, e2e/home-highlights.spec.ts
Undoing a highlight's delete restoreHighlight (the web reader's and Notebook's Undo; POST /api/v1/highlights/{id}/restore, write scope; MCP's restore_highlight, library:write) clears the delete in one statement only for the reader's own deleted highlight of an article still in the library: someone else's, a live one, or one whose article is in the trash is not_found, and stays as it was. app/commands/annotations.server.test.ts, app/routes/api.v1.test.ts, app/routes/mcp.test.ts
Retrying and deleting for good A failed save's retry (the web's Retry; POST /api/v1/articles/{id}/retry) is not_found for an article that is someone else's or in the trash, queues only one that failed, and counts against the save limit like a save, since it fetches the page again; one that queues nothing costs nothing. Deleting for good (the trash's "Delete forever", one article or a selection through POST /documents/bulk with intent=purge; POST /api/v1/articles/purge, up to BATCH_MAX ids, write scope) deletes in one statement only the reader's own articles already in the trash: any other id comes back in notFound, untouched. MCP offers neither. app/routes/api.v1.test.ts, app/services/owner-scoping.server.test.ts, app/routes/documents.bulk.test.ts
Notes asked for on Home listNoteReminders lists only the signed-in reader's completions of their own live articles, by user id on the completion and on the article. "Nothing to add" and its undo (POST /home/notes; POST and DELETE /api/v1/articles/{id}/note/skip) write only to the reader's own article, and are not_found for one that is someone else's or in the trash. app/commands/note-reminders.server.test.ts, app/routes/api.v1.notebook.test.ts
Home's line, older saves and next edition in the API GET /api/v1/home/finished counts only the signed-in reader's completions of their own live articles, by user id on the completion and on the article; its timeZone must be a time zone (invalid_request otherwise) and only decides where the days start. GET /api/v1/home/rediscover lists only the reader's own ready, unread articles in the library, never one in the trash; a topic that is not theirs (or with topics off) is not_found. GET /api/v1/editions/next counts only the reader's own marks and draft, marks in the trash left out, and POST /api/v1/editions/next/snooze (write scope) writes only the reader's own preference. app/routes/api.v1.home.test.ts, app/services/editions.server.test.ts
The review of old saves in the API GET /api/v1/review lists only the signed-in reader's own ready articles in the library, never one in the trash or archived. Keeping (PATCH /api/v1/articles with kept, write scope) goes through the same command as every article change: another reader's article, or one in the trash, comes back in notFound, untouched. GET /api/v1/review/suggested reads, and POST /api/v1/review/snooze (write scope) writes, only the reader's own preference. app/routes/api.v1.review.test.ts, app/services/review.server.test.ts
Sharing a highlight's text Sharing copies text to the reader's own clipboard and publishes nothing. A note's links into the library (/read/<id>) become the linked articles' original addresses, which GET /api/note-links (browser session) and POST /api/v1/note-links/lookup (a token's read scope) look up: they answer only for the signed-in reader's articles (those in the trash included), so another reader's id gets nothing. Only http(s) addresses become links; the copied HTML is escaped. app/routes/api.note-links.test.ts, app/routes/api.v1.shares.test.ts, packages/api-contract/src/highlight-share.test.ts, e2e/note-links.spec.ts
Starter texts in the API GET /api/v1/starter-texts and GET /api/v1/starter-texts/offer mark a text as the reader's (articleId) only from their own articles, found by user id and not in the trash: another reader's copy is never shown. POST /api/v1/starter-texts/{slug}/save (write scope) runs the web's command (saveStarterText): it takes a name from the server's own list, never an address, so a client cannot make the server fetch anything else. Bundled texts are saved from the pages that ship with the server, fetching nothing; a linked text is fetched from the fixed address in app/starter/links.ts, as any saved link. Each new save counts against the save rate limit and the monthly cap, as any other; adding a text the reader already has writes nothing. app/routes/api.v1.starter.test.ts, app/commands/saving.server.test.ts
Demo account tools The demo commands (pnpm demo:*, which pnpm orb:app also runs) refuse NODE_ENV=production, a non-loopback database, S3 storage, and an account that only shares the demo email with their ownership record. demo:login-link writes a standard magic-link token for the owned demo account, whose password is published; the app gains no sign-in route. scripts/demo/local.test.ts
Support form On a server with SUPPYHQ_TENANT, the app sidebar's Contact support opens SuppyHQ's contact form in a frame (components/support-panel.tsx), with the account's name and email in its address. Nothing loads from SuppyHQ before that click, and none of SuppyHQ's scripts run in the app's pages: the script policy stays 'self' plus the nonce. The frame is sandboxed without top navigation, so it cannot send the app's tab elsewhere, and gets no referrer. Accounts with the admin role get no link, so their pages never frame it. The frame's messages count only from SuppyHQ's origin and that frame, and only size (at most the window's height) or close it. app/routes/app-layout.test.ts, e2e/support.spec.ts

Test paths above are relative to apps/web/ unless they name another package. The PostgreSQL triggers in the migrations are part of the credential boundary; preserve them when changing the auth schema.

Save/export quotas and upload/export concurrency currently apply per web process. Authentication limits are shared in PostgreSQL. If increasing the web replica count, move work quotas and concurrency admission to shared storage as well.

Dependencies

The latest stable parent packages still pulled vulnerable versions of esbuild and phin. Narrow overrides select esbuild 0.25.12 for @esbuild-kit/core-utils and phin 3.7.1 for @jimp/core. Migration generation/application, production builds and a local image-tracing smoke test check those integrations. Reassess the overrides when upgrading the parent packages.

CI runs pnpm audit --audit-level moderate in addition to lint, type checks, tests and builds. A clean dependency audit covers known advisories only.