Skip to content

Shares — branded public pages

One mechanism for every "send this to the client" surface: a share is (workspace, kind, resource?) behind a revocable token, rendered at /s/:token in the owning agency's branding, with no account required to read it.

One kind ships today (signals_review retired with the client approval workflow — signal plan step 0, 2026-09-01; the spine stays as the foundation a later "share a signal" feature reuses). Adding a kind is still no new plumbing — a manifest entry, a renderer, and one registry line.

KindModuleSubjectReader's verb
content_draftcontent/collectionsONE content piece, by article_group_idapprove the piece → client_approved_at stamped

content_draft is the per-resource shape: the token already fixes which piece, so its action reads no itemId at all (ShareAct's is optional for exactly this). It works for a piece of any collection, because the payload is the stored document plus resolved copy — never anything collection-shaped. It returns versions[], one per locale (primary first, the rest alphabetical so tab order is stable) — each with its own body, cover, kicker and search preview, because locales are natively written and not translations. A locale row with no body yet yields no version at all. Approving does not publish: it stamps blog_articles.client_approved_at on every pending_review row of the group and the agency still presses Publish, because an anonymous link holder must never be able to reach the live site. The two guards in markClientApproved (status = 'pending_review', client_approved_at is null) make a stale link and a replayed request both harmless, and a stamp that moved nothing answers with an error rather than a tick — the same "a no-op that LOOKS like success is worse than an error" rule as the signals approve. A link outlives the moment it was sent, so load resolves a state (in_review / approved / live / withdrawn / gone) and only in_review returns an actions map.

The split (why it is three layers, not one)

LayerOwnsPath
Spinethe token, expiry, revocation, view count, and the brand that wraps the pagebackend/src/workspace/shares/
Modulewhat the page SAYS, per kindmanifest shares: [{ kind, label, load }]
APIthe only place the two meetbackend/src/api/routes/shares.js

The spine never imports a module (ring rule), so the transport is what resolves a token's kind through getShareKinds() (modules/registry.js) and calls the module's load.

js
// modules/<group>/<module>/index.js
shares: [{
  kind: 'article_draft',                  // unique across all modules (boot guard)
  label: 'Article draft',                 // shown in the app's share list
  load: async ({ workspaceId, resourceId }) => ({ /* the page's data */ }),
  actions: {                              // optional — verbs the READER may perform
    approve: async ({ workspaceId, resourceId, itemId }) => ({ ok: true }),
  },
}]

load returns the kind's own shape; the envelope (workspace, branding, generatedAt) is added by the transport. Copy is resolved server-side (severity words, dates, counts) so the public renderer stays a dumb, brandable layout — the hosted-audit-report rule.

Actions — the page is not read-only

POST /api/share/:token/:action lets the client act: on content_draft, approving the piece stamps client_approved_at, the same yes the agency would record for them after a call.

The authorisation model is the token plus what the page contains. The transport passes only an itemId — never a workspace, a status, or a target — and the action re-checks that the item is currently on that page: approve refuses a subject the token's scope does not hold, which also makes a replayed request a no-op instead of a reach into the rest of the workspace. A kind with no actions is read-only and answers 404 for every verb. Actions do not count as views. The frontend deliberately does not refetch after an action: an item vanishing mid-read looks like a bug.

The store

workspace_shares (backend/db/workspace-shares-migration.sql): token is 32 bytes of base64url, stored in plaintext because the agency must be able to re-copy the link from the app. A partial unique index on (workspace_id, kind, coalesce(resource_id,'')) where revoked_at is null enforces one live link per subject — pressing Share again returns the same URL instead of scattering live links. Revoked rows are kept, never deleted, so a withdrawn URL stays dead. RLS on with no policies: every access goes through the service-role client, since the reader is anonymous by definition.

Gates

  • Reading is the token, and nothing else. Unknown and revoked both answer 404 — a withdrawn link must not confirm it ever existed. Expired answers 410, because "ask for a new one" is actionable.
  • Minting/revoking requires requireWorkspaceAgencyStaff: publishing outward on the client's behalf is the agency's act. A workspace guest is a member and still cannot do it; the ShareLinkButton hides itself for the same reason (viewer.isAgencyStaff), which is convenience — the middleware is the gate.

Frontend

/s/:tokenpages/share/SharePage.tsx fetches with plain fetch (no session to attach), wraps the payload in PublicShell and dispatches on kind through pages/share/kinds/index.ts. An unknown kind renders a notice, not a white screen. The footer carries only the agency ("Prepared by …") — the "Powered by Luniq" line and its showPoweredBy toggle were removed 2026-08-10: a shared page never carries anything of ours.

The page renders light unless the reader has explicitly chosen dark in Orbit (storedTheme(), never the OS preference — a report arriving dark because the client's laptop is dark is a look the agency never saw). PublicShell pins the palette on its own wrapper rather than inheriting <html>. No theme toggle: it is a document, not a workspace.

The brand layer (WHITE_LABEL_PLAN §4–§6)

BrandProvider sets three custom properties as inline styles on one wrapper — no per-agency stylesheet, no build step, nothing global, which is why the app chassis is unaffected. The values come from lib/brand-tokens.ts, which parses the agency's one hex into OKLCH and clamps it: lightness to 0.45–0.72, chroma to ≤0.18, and --brand-foreground is computed (white only if it clears WCAG AA 4.5:1, near-black otherwise). OKLCH rather than HSL because HSL's lightness lies — yellow and blue at 50% differ ~10× in perceived brightness, so an HSL clamp would pass exactly the unreadable picks. Tailwind exposes bg-brand / text-brand / bg-brand-strong; the fallbacks in index.css are neutral, so an unwrapped page is unbranded.

The chassis stays unaffected because nothing in the app is wrapped in BrandProvider — that is the mechanism, not a property of the file. brand-tokens.ts gained one export that deliberately reaches inside the app: mapRampStyle, the Analytics world map's six shading steps at the agency's hue, set inline by CountryMap rather than through the provider. It shares only the hex→OKLCH parser here; the ramp, its ladder and why a choropleth is the one legitimate exception are in Frontend.

No tinted-surface token, on purpose. A wash of an arbitrary hue behind small grey text is the first place an unlucky colour stops looking clean, and no single clamp fixes that for every hue — so brand colour is limited to solid fills (which carry their computed foreground) and accent text. Panels use bg-muted. Status colours are never brand colours either: a green "opportunity" does not become the agency's red.

api/allowed-origins.jsappOriginFor(req): the link opens in the app that minted it. The request's Origin is used when it is on the CORS allowlist (so localhost stays on localhost and staging stays on staging), otherwise PUBLIC_APP_BASE, otherwise https://orbit.luniq.io. An unrecognised Origin is never echoed — that would let a caller choose the host our links advertise.

Env

PUBLIC_APP_BASE (default https://orbit.luniq.io) — fallback host for callers with no request in hand.