Skip to content

Technical documentationInternal

For the team and anyone taking the product over. Not linked from the site and not indexed.

Technical documentation pages

Server, accounts and billing

Backup status and Study-file download receipts

The browser's storage mode and the account upload queue are separate evidence. The Study backup panel waits for the scoped queue to answer; a read failure is shown rather than represented as an empty queue. Pending and failed uploads remain unconfirmed. A completed acknowledgement shows the actual snapshot date and explains that subsequent edits need another backup. Scope-epoch changes invalidate panel state and late feedback. A downloaded recovery file is reported as a download started, without claiming independent restore or browser-save confirmation. No provider, entitlement, host or billing behavior changes.

This page describes the suite's server code: the route handlers under src/app/api, the workspace access modes, and the Supabase and Stripe integrations behind future account mode. Account mode, backups and billing are present in the code but disabled unless a deployment opts in explicitly.

Overview

Most of the suite runs in the browser. The server does five things:

  • proxies a few public lookups (PubChem, ChEMBL and UniProt suggestions);
  • runs literature search and the optional AI features;
  • renders share images;
  • answers the workspace access check;
  • in account mode, handles sign-in, Study backups and the Stripe subscription.

The browser Workspace stores its records locally; see Storage and state. The headers and outbound-host rules are covered in Security and network. Operational setup is in docs/workspace-setup.md and Deployment and environment.

Server-only modules:

Module Role
src/lib/workspace/server-config.ts workspaceServerConfig(env), isLocalDevelopmentRequest, sameOriginRequest
src/lib/workspace/server.ts Supabase clients (accountClient, billingAdminClient), verifiedWorkspaceAccount, serverWorkspaceAccess, PRIVATE_HEADERS
src/lib/workspace/billing.ts stripeClient, reconcileCustomer
src/lib/workspace/billing-policy.ts Pure functions: chooseWorkspaceSubscription, workspaceSubscriptionState
src/lib/workspace/checkout-operation.ts prepareCheckout, the leased, idempotent checkout procedure
src/lib/workspace/backup-integrity.ts BACKUP_MAX_BYTES, BACKUP_ID, backupDigest (SHA-256)
src/lib/workspace/request-body.ts boundedRequestText, which limits both Content-Length and the streamed body
src/lib/workspace/capabilities.ts WorkspaceAccess, decideWorkspaceCapability, ensureWorkspaceCapability (shared with the client)

server.ts and billing.ts import server-only, so a client component that imports them fails to build.

Access modes

workspaceServerConfig reads the environment on each call.

Setting Effect
WORKSPACE_ACCESS_MODE unset or anything other than account Browser mode, the default. accountsEnabled is false, so accountClient() returns null and serverWorkspaceAccess() returns BROWSER_WORKSPACE_ACCESS (kind: "browser", scopeId: "browser"). The client may read, export and write local work. Backup and share are refused.
WORKSPACE_ACCESS_MODE=account Account mode. Access depends on a verified Supabase user and a row in workspace_subscriptions. Adding provider credentials does not enable this mode on its own.
WORKSPACE_DEVELOPMENT=1 Separate local development namespace. Honoured only when neither VERCEL nor WORKSPACE_SITE_URL is set, and only for requests to localhost, 127.0.0.1 or [::1] (isLocalDevelopmentRequest). /api/workspace/access then returns a development access with a 24-hour validity and no account. Backup is refused for this kind.

In account mode, serverWorkspaceAccess() works as follows:

  1. It verifies the user with client.auth.getUser(), which checks the user against Supabase rather than trusting the cookie alone.
  2. It reads status and paid_through from workspace_subscriptions using the user's own client, so row-level security applies.
  3. It returns entitlement: "active" only when the status is active or trialing and paid_through is in the future. Otherwise it returns "expired" when a row exists and "none" when it does not.
  4. It sets offlineUntil to the earlier of paid_through and seven days from now.

decideWorkspaceCapability lets an account read and export even after expiry. Writing and backup need an active entitlement. Backup also needs the client to be online. Sharing is always refused, because no permission model has been released.

On the client, src/lib/workspace/access-client.ts fetches /api/workspace/access with cache: "no-store". It keeps the result in sessionStorage under suite:workspace-access:v1. A cached value is loaded as offline until the server answers. A failed request never extends the recorded expiry.

Route handlers

All handlers below are in src/app/api. "Origin check" means the handler compares the Origin header with the expected origin (see the next section).

The single-provider literature handler reads its static response messages from content/apps/literature-route-copy.ts. Compound lookup reads route-only input/not-found messages from content/apps/compound-route-copy.ts and reuses compoundLookupCopy.unavailable; the client-consumed compound dictionary has no added keys. This copy extraction preserves wording, statuses and route behaviour, including dynamic validation and quota messages (MNT-03).

Public lookups

Path Method Purpose and upstream Validation Caching
/api/search/compound GET ?name= Calculator reagent lookup. Answers from the curated structures when the name maps to one. Otherwise POSTs the canonical name to PubChem PUG REST for SMILES, formula, InChIKey and title. Non-empty, at most 120 characters, no control characters. Only typed application-owned payload refusals return 422; unknown network, JSON and runtime errors return a fixed 503 without diagnostics (SEC-08). Input 400 and upstream not-found 404 remain. Upstream next: { revalidate: 86400 }; no response Cache-Control set
/api/search/compounds GET ?q= Retained PubChem autocomplete endpoint; the shared typing hook makes no automatic calls (SEC-02) 3 to 120 characters, no control characters Upstream cache: "no-store"; every response private, no-store
/api/search/molecule GET ?name= Exact ChEMBL record (www.ebi.ac.uk/chembl/api/data/molecule/<id>.json) for a name in the local catalogue. Checks that the returned id matches. Name must resolve through findCompound, at most 120 characters Upstream revalidate: 86400
/api/search/molecule-description GET ?source=&id= ChEMBL mechanism rows or a PubChem compound description for an exact identifier Exactly two parameters; chembl with CHEMBL<n> or pubchem with a numeric CID; query string at most 128 characters In-process map with a 1-hour expiry and at most 128 entries; errors are not cached; public, max-age=3600, s-maxage=3600
/api/search/proteins GET ?q= UniProt REST search, four queries in parallel, ranked 2 to 120 characters Upstream revalidate: 86400; public, max-age=3600, s-maxage=86400
/api/share-image GET ?path= Share-card image for a known path (cardForPath, ogCard); no external call Unknown path returns 404 public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800; nosniff

Literature and AI

All literature routes run on the Node.js runtime and set maxDuration. Their responses are private, no-store. The literature code is in src/lib/literature/.

Path Method Purpose and upstream Notable checks
/api/literature POST Single-provider search: Europe PMC, or OpenAlex for semantic queries and OpenAlex ids Requires a present exact Origin; body at most 12,000 characters; validateQuery; cursor at most 4,096 characters. The OpenAlex path has a 5-minute in-process cache (32 entries), a 1 s gap between semantic requests per process, and reserveSearch.
/api/literature/search POST Multi-lane search through executeSearch (src/lib/literature/search/) across Europe PMC and OpenAlex keyword and semantic lanes Requires a present exact Origin; body at most 40,000 characters; a session id pattern. In account mode, a verified user becomes the quota owner. When no provider succeeds, returns 429 if any provider was rate-limited or out of allowance (with Retry-After when known), otherwise 503.
/api/literature/discover POST References, citing works, related works or shared references from OpenAlex; the seed paper may first be resolved through Europe PMC requestBody (origin, 16,000 characters); one paper, or 2 to 5 papers for shared references; page 1 to 100
/api/literature/fulltext POST Europe PMC full-text XML for a paper with a PMCID requestBody; locator validates the id, DOI, PMID, PMCID and OpenAlex id. In-process cache of 12 entries for 5 minutes. Source documents are limited to 4,000,000 bytes.
/api/literature/extract GET, POST GET reports whether AI extraction is available. POST sends an abstract or full-text section to the Anthropic Messages API for evidence extraction. extractionOwner, model id pattern claude-…, reserveExtraction
/api/literature/plan GET, POST AI search-strategy suggestion (createSearchPlanHandlers) body.consent === true; only query and consent keys allowed; query at most 2,000 characters; reserveExtraction and reserveSearchAssist
/api/study/summary POST AI summary of selected Study records body.consent === true; body at most 400,000 characters; validateStudyPacket (at most 200 sources); focus is one of overview, evidence or handover

AI access is decided by extractionOwner in src/lib/literature/extraction-access.ts:

  • Every non-GET request must have an Origin equal to the WORKSPACE_SITE_URL origin, or to the request's own origin when that variable is unset.
  • Local development is allowed without an account. "Local" means NODE_ENV is not production, VERCEL and WORKSPACE_SITE_URL are unset, and the request is to a loopback host.
  • Anywhere else, LITERATURE_AI_ENABLED=1 and WORKSPACE_ACCESS_MODE=account are both required, and the caller must hold the workspace.write capability.
  • reserveExtraction enforces a per-process daily limit (LITERATURE_AI_DAILY_REQUESTS, default 25), at least 10 s between calls per owner, and one call in flight per owner.
  • anthropicMessage (src/lib/literature/anthropic.ts) sends the API key only to https://api.anthropic.com/v1/messages. It uses redirect: "error" and an 80 s timeout, and it never returns provider error text to the caller.

OpenAlex usage is metered by src/lib/literature/search/quota.ts. Europe PMC keyword lanes in /api/literature/search have only a per-process burst guard of 30 requests per owner and 120 in total per minute. OpenAlex and AI search assistance need the following:

  • Locally, in-process daily counters are used.
  • On a hosted deployment, the Supabase RPC reserve_literature_search_quota is used. It needs LITERATURE_SEARCH_SHARED_QUOTA=1, SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY. Without them, OpenAlex lanes are refused and Europe PMC remains available. Owners are stored only as SHA-256 hashes of account ids. All anonymous callers share one anonymous row.

Workspace and accounts

Path Method Purpose Notable checks
/api/workspace/access GET Returns the current WorkspaceAccess force-dynamic. Local development override as above. 503 if the subscription read fails.
/api/workspace/auth POST Sends a Supabase email sign-in link (signInWithOtp) with redirect <origin>/workspace/auth/callback Origin check; body at most 4,096 bytes; email pattern, at most 254 characters. 503 when accounts are not configured.
/api/workspace/sign-out POST auth.signOut({ scope: "local" }) Origin check; X-Workspace-Scope must match a signed-in user
/api/workspace/backups GET Lists the caller's backups, 100 per page, newest first Verified user; X-Workspace-Scope; before cursor at most 160 characters, with timestamp and UUID patterns checked before it is embedded in the PostgREST or filter
/api/workspace/backups POST Uploads one Study backup See "Backups" below
/api/workspace/backups/download POST Returns one backup's JSON after an integrity check Origin check; verified user; X-Workspace-Scope; body at most 1,024 bytes with an id matching BACKUP_ID
/api/workspace/billing/checkout POST Creates or recovers a Stripe Checkout session See "Stripe" below
/api/workspace/billing/portal POST Creates a Stripe customer-portal session with return URL <origin>/workspace Origin check; verified user; X-Workspace-Scope; customer row read with the user's client
/api/workspace/billing/reconcile POST Re-reads the caller's subscriptions from Stripe Origin check; verified user; X-Workspace-Scope
/api/workspace/billing/webhook POST Stripe webhook Account mode required before constructing Stripe or reconciling; private/no-store 503 in browser mode even when credentials exist (SEC-07). Stripe signature; body at most 1 MiB; Node.js runtime

Every workspace response uses PRIVATE_HEADERS: Cache-Control: private, no-store, Vary: Cookie and Referrer-Policy: no-referrer.

Outside src/app/api, the sign-in callback src/app/(workspace)/workspace/auth/callback/route.ts exchanges the code parameter (shorter than 4,096 characters) for a session. It then redirects with 303 to <origin>/workspace#signed-in or #auth-failed. The account page /workspace (src/app/(workspace)/workspace/page.tsx) shows account controls only when account mode and the Supabase settings are present. It shows billing controls only when the Stripe key, price, webhook secret and service key are also set.

Literature POST handlers require a present exact Origin before body processing or provider calls (SEC-05). The shared requestBody guard covers discover and fulltext too. This does not authenticate nonbrowser callers; deployment-level per-client/provider rate limiting remains open.

Origin and scope checks

sameOriginRequest(request, expectedOrigin) returns true only when all three of these hold:

  • WORKSPACE_SITE_URL yields a valid origin. It must be HTTPS, or HTTP on localhost or 127.0.0.1, with no credentials, query or fragment.
  • The Origin header equals that origin exactly.
  • The request URL's origin equals it too.

It is used by auth, sign-out, backups (POST), backups/download, and the checkout, portal and reconcile billing routes. Without WORKSPACE_SITE_URL, all of them return 403.

The webhook has no origin check. In account mode it relies on stripe.webhooks.constructEvent with STRIPE_WEBHOOK_SECRET. When accounts are disabled it returns private/no-store 503 before constructing Stripe, verifying a signature or writing subscription state.

Account routes also require the X-Workspace-Scope header to equal account:<user id>, and return 409 otherwise. This stops a tab that still believes it belongs to one account from acting on another account after a sign-in change. The browser client sets the header from its current access scope (src/lib/workspace/backup-queue.ts, src/components/suite/workspace/backups.tsx).

Public literature routes recover the browser's exact loopback origin through requestOrigin when running without VERCEL or WORKSPACE_SITE_URL. Next normalises local request URLs to localhost even under next start; the helper accepts only a canonically serialised Host from its fixed loopback list at the same port and URL scheme. The Origin must match that recovered host exactly. Nonlocal, malformed, different-port and forwarded-host substitutions remain refused. AI and account enablement checks are unchanged (REL-701).

Supabase

accountClient() creates a @supabase/ssr server client with the publishable key and Next's cookies(). Its cookie options are:

  • httpOnly: true;
  • secure, unless WORKSPACE_SITE_URL is an HTTP origin;
  • sameSite: "lax";
  • path: "/".

accountClient() returns null unless account mode is on and SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are set.

billingAdminClient() creates a @supabase/supabase-js client with SUPABASE_SERVICE_ROLE_KEY, without persisting or refreshing a session. Only the backup upload, the checkout, reconciliation and the literature quota RPC use it. User-facing reads go through the user's client, so row-level security applies to them.

The migrations in supabase/migrations/ are applied in filename order.

Migration Creates
202610070001_workspace.sql See below
202610070002_workspace_checkout.sql See below
202610090001_literature_search_quota.sql See below

202610070001_workspace.sql creates:

  • Tables workspace_customers (owner to Stripe customer), workspace_subscriptions (status, paid_through, observed_at) and workspace_billing_events (processed event ids). Row-level security is on. Authenticated users can select only their own customer and subscription rows. Writes are reserved for service_role.
  • The function apply_workspace_subscription, which is security definer and executable only by service_role. It skips an event id it has already processed. It writes a snapshot only if that snapshot is not older than the stored observed_at.
  • The table workspace_backups (byte_size between 1 and 4,194,304), readable by its owner.
  • The private storage bucket workspace-backups (4 MiB limit, application/json only), with a read policy limited to the owner's folder. There is no client upload, update or delete policy.

202610070002_workspace_checkout.sql creates:

  • The table workspace_checkout_operations, one row per owner, accessible only to service_role.
  • The functions claim_workspace_checkout (a 2-minute lease), rotate_workspace_checkout and finish_workspace_checkout, all executable only by service_role.

202610090001_literature_search_quota.sql creates:

  • The table literature_search_quota, keyed by provider and owner key (global, anonymous or a 64-hex hash). It is accessible only to service_role.
  • The function reserve_literature_search_quota. It validates its arguments, takes an advisory transaction lock per provider, rolls daily counters over in UTC, and enforces the global and per-owner daily limits and a one-per-second gate for semantic queries.

docs/workspace-setup.md lists the release checks that must pass before these migrations are relied on. These include testing row-level security with two real accounts.

Stripe

stripeClient() returns a client only when STRIPE_SECRET_KEY is set. The client is configured with maxNetworkRetries: 2 and timeout: 15000.

Checkout

/api/workspace/billing/checkout works as follows.

  1. It returns 503 unless the Stripe key, service key, STRIPE_WORKSPACE_PRICE_ID, WORKSPACE_SITE_URL and STRIPE_WEBHOOK_SECRET are all present.

  2. It retrieves the configured price and refuses it unless the price is active, in GBP, yearly with an interval count of 1, licensed and has a positive amount. The browser never supplies a price.

  3. It maps the owner to a Stripe customer. A new customer is created with the idempotency key workspace-customer-<owner> and stored with an upsert that ignores duplicates. The stored row is then re-read.

  4. It calls prepareCheckout (src/lib/workspace/checkout-operation.ts). That function:

    • claims the owner's lease through claim_workspace_checkout;
    • refuses if a Workspace subscription already exists in a live or recoverable state;
    • recovers an existing session by its stored id, or by the operation id in its metadata;
    • refuses an open session that predates the lease;
    • rotates to a new operation only when the old session has expired, or completed into a cancelled subscription;
    • creates a new session with the idempotency key workspace-checkout-<operation id>.

    An operation older than 23 hours (CHECKOUT_RETRY_WINDOW_MS) is refused rather than recreated, because Stripe may prune idempotency keys after 24 hours. Ownership is checked against the session's customer and metadata. The success URL is <origin>/workspace#subscription-pending.

Returning from checkout does not grant access. Only reconciliation does.

Reconciliation

reconcileCustomer(customerId, eventId) in src/lib/workspace/billing.ts is called by the webhook and by /api/workspace/billing/reconcile. It works as follows.

  1. It looks up the owner. An unknown Stripe customer grants nothing.
  2. It skips event ids it has already processed.
  3. It lists the customer's subscriptions from Stripe. It reads the current state rather than trusting the event body, and stops at 1,000 subscriptions.
  4. chooseWorkspaceSubscription picks, among subscriptions with an item on an eligible price, the one with a current active or trialing status first, then the latest paid-through date. Its status and paid_through (the earliest period end among its eligible items) are written. If no subscription has an eligible price, the owner's previous subscription id is written with the status unentitled. If no Workspace subscription was ever recorded, nothing is written.
  5. It writes the result through apply_workspace_subscription.

The reconcile route uses the event id reconcile:<user>:<minute>, so repeated presses within one minute record at most one reconciliation.

The webhook handles these event types:

  • customer.subscription.created, .updated and .deleted;
  • invoice.paid, .payment_failed and .payment_action_required;
  • checkout.session.completed and .async_payment_succeeded.

It returns 503 when reconciliation fails, so Stripe retries the delivery.

Legacy prices

stripeEntitlementPriceIds is the current STRIPE_WORKSPACE_PRICE_ID plus the exact ids in the comma-separated STRIPE_WORKSPACE_LEGACY_PRICE_IDS. Legacy ids keep existing subscribers entitled during reconciliation and in the checkout's existing-subscription check. New checkouts always use the current price. docs/workspace-setup.md describes the order in which to change these settings.

Backups

A backup is an explicit snapshot of one Study, not synchronisation. POST /api/workspace/backups works as follows.

  1. It runs the origin check, verifies the user and checks X-Workspace-Scope. It returns 503 if there is no service-role client.
  2. The Idempotency-Key header must be a UUID matching BACKUP_ID. This key becomes the backup id.
  3. It checks the workspace.backup capability with serverWorkspaceAccess(), which returns 402 when the caller is not entitled. It also checks that the account has not changed.
  4. The body is read with boundedRequestText up to BACKUP_MAX_BYTES (4 MiB, below Vercel's request limit), parsed, and validated with validateStudyBundle (src/lib/workspace/bundle.ts).
  5. It computes a SHA-256 digest and uploads to workspace-backups/<user id>/<id>.json with upsert: false. If a row with that id already exists, the route returns it when the owner and digest match, and 409 otherwise. If the upload fails because an earlier attempt already stored the object, the stored object's digest is compared before continuing.
  6. It inserts the metadata row, re-reads it, and confirms the owner and digest before acknowledging.

The client queue in src/lib/workspace/backup-queue.ts retries with the same operation id and body.

A failed upload attempt retains its body, operation id and checksum. The panel says the backup has "not been confirmed": a lost response cannot establish whether the server stored it (REL-700). Reload and retry retain the same operation, and only the matching acknowledgement marks it backed up.

POST /api/workspace/backups/download reads the metadata row and the storage object with the user's client. It checks the stored size and the SHA-256 digest before returning the body with an X-Content-SHA256 header. Download has no entitlement check, so expired accounts can still recover their work.

Local recovery files, which never reach the server, are limited by STUDY_BUNDLE_LIMITS: 50 MiB, 10,000 records and the per-file limit ORIGINAL_FILE_MAX_BYTES.

What is disabled by default

With no environment variables set:

Feature Default behaviour
Accounts Off. /api/workspace/access returns browser access. The auth, sign-out and other account mutation routes return 403 because WORKSPACE_SITE_URL is unset.
Backups, portal, reconcile 403 from the origin check on POST routes; the backup list (GET) returns 401 with no verified user
Checkout 403, or 503 until every Stripe and Supabase setting is present
Webhook Private/no-store 503 unless account mode is enabled and both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are set. Browser mode returns before Stripe construction or reconciliation (SEC-07).
AI extraction, search plan, Study summary 403 on hosted deployments unless LITERATURE_AI_ENABLED=1 and account mode are set; 503 without ANTHROPIC_API_KEY. Local development can use a key without an account.
OpenAlex search lanes Refused on hosted deployments without the shared quota settings. Europe PMC search works.
Public lookups and share images Always on

Server environment variables

Names only. Never put values in documentation or source control. docs/workspace-setup.md covers provider setup.

Variable Read in Required when
WORKSPACE_ACCESS_MODE server-config.ts, extraction-access.ts, search/quota.ts, api/literature/search/route.ts Set to account to enable accounts
WORKSPACE_SITE_URL server-config.ts, extraction-access.ts, literature/sources.ts, search/quota.ts Any account mutation, sign-in redirect or checkout. Its presence also marks a deployment as hosted.
WORKSPACE_DEVELOPMENT server-config.ts Optional local development namespace only
SUPABASE_URL server-config.ts Account mode; the shared literature quota
SUPABASE_PUBLISHABLE_KEY server-config.ts Account mode
SUPABASE_SERVICE_ROLE_KEY server-config.ts Backups, checkout, reconciliation, the shared literature quota. Server only.
STRIPE_SECRET_KEY server-config.ts Billing
STRIPE_WEBHOOK_SECRET server-config.ts Webhook; checkout also requires it
STRIPE_WORKSPACE_PRICE_ID server-config.ts Checkout and reconciliation
STRIPE_WORKSPACE_LEGACY_PRICE_IDS server-config.ts When previous prices still have subscribers
ANTHROPIC_API_KEY literature/anthropic.ts, the extract and summary routes, search/assistant.ts AI features
ANTHROPIC_LITERATURE_MODEL The extract and summary routes, search/assistant.ts Optional; defaults to EXTRACTION_MODEL in src/lib/literature/extraction.ts
ANTHROPIC_WORKSPACE_ID literature/anthropic.ts, search/assistant.ts When the API key requires a workspace
LITERATURE_AI_ENABLED extraction-access.ts, search/quota.ts Set to 1 for hosted AI features
LITERATURE_AI_DAILY_REQUESTS extraction-access.ts Optional; 1 to 1,000, default 25
OPENALEX_API_KEY api/literature/route.ts, literature/sources.ts, search/providers.ts Optional; a larger OpenAlex allowance
LITERATURE_SEARCH_SHARED_QUOTA search/quota.ts, search/assistant.ts Set to 1 for hosted OpenAlex lanes and AI search assistance
LITERATURE_OPENALEX_DAILY_REQUESTS, LITERATURE_SEARCH_OWNER_DAILY_REQUESTS search/quota.ts Optional; defaults 500 and 100
LITERATURE_SEARCH_ASSIST_DAILY_REQUESTS, LITERATURE_SEARCH_ASSIST_OWNER_DAILY_REQUESTS search/quota.ts Optional; default 25 each
VERCEL, NODE_ENV server-config.ts, extraction-access.ts, literature/sources.ts, search/quota.ts Set by the platform; used to tell local from hosted

The build-time and public variables (NEXT_PUBLIC_SUITE_ORIGIN, SUITE_INDEXABLE and others) are covered in Deployment and environment.