Skip to content

Public share surface (tokenized read-only links)

Authenticated users share a read-only view of their records via time-limited tokens. Visitors open /share?token=sh_… without an account. The pattern combines:

  • Event-sourced token entity (hash in DB, plain show-once)
  • Anonymous query + IP rate-limit
  • Client gate before AuthGate (or createPublicSurface on apex)
  • GDPR export/forget hooks on token metadata
  • You’ve read Anonymous access so roles: ["anonymous"] and the tenant resolution around it are clear.
  • You have an app feature that owns the records being shared.

Server, the token entity, the gate on minting, and the read path.

Mirror user-data-rights/download-by-token:

  • tokenHash (SHA-256 hex, unique)
  • expiresAt, revokedAt
  • targetPayload (json snapshot, domain-specific)

Register create, revoke, list, and share-by-token on your app feature.

If sharing is a paid capability, declare the share feature r.toggleable() and list it in the tierMap for the tiers that may mint links. The dispatcher then gates it per tenant based on the assigned tier, instead of a check inside the create handler (see tier-engine).

export const shareByTokenQuery = defineQueryHandler({
name: "share-by-token",
schema: z.object({ token: z.string().min(1) }),
access: { roles: ["anonymous", "Member", "User", "TenantAdmin"] },
rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
handler: async (query, ctx) => {
const row = await fetchOne(ctx.db.raw, table, {
tokenHash: await hashShareToken(query.payload.token),
});
if (!row || row.revokedAt != null || row.expiresAt <= now) {
throw new NotFoundError("share-token");
}
return buildPublicDto(row);
},
});
await runProdApp({
features: appFeatures,
anonymousAccess: { defaultTenantId: APEX_TENANT_ID },
});

Multi-tenant public hosts: use tenantResolver from subdomain/header (see Anonymous access).

Client, the visitor never has a session, so the share route has to win before the session gate runs.

Mount the share client before the session gate, otherwise an anonymous visitor lands on the login mask instead of the shared view:

// client.tsx, gate order
const gates = [
publicShareClient, // /share → share page
sessionAuthClient,
];

The gate matches pathname === "/share" and renders a page that calls public-share:query:share-by-token.

Alternative: createPublicSurface for a standalone apex bundle (see apex-surface-auth).

Share pages usually need more than one look, a plain default, something headline-heavy, a dense overview. Keep that out of the server: the query returns one DTO for every look, carrying a presentation discriminator (a template name the minting user picked, plus an optional theme name). A small router on the client maps that name to a React component.

The seam pays off twice: adding a template touches the router and one component but never the handler, and a link minted under an old template keeps rendering because the DTO shape did not change. Resolve the theme name into CSS variables at the page root so templates and themes stay independent.

Template and theme names are your app’s vocabulary, the framework only carries the fields through the DTO.

  • Public footer, read-only notice on the shared page, no tracking
  • Art. 17, EXT_USER_DATA hook revokes tokens on user forget
  • Art. 20, export token metadata (without tokenHash / plain token)
  • One NotFoundError for miss, expired and revoked. Distinguishable errors turn the endpoint into an existence oracle, a guesser learns which tokens once existed.
  • The plain token exists exactly once. The database holds the SHA-256 hash, so “resend the link” means minting a new token; there is nothing to read back.
  • Rate-limit by IP. The caller is anonymous, so per: "ip+handler" is the only attribution available, a per-user limit has no user to count.
LayerWhat to assert
UnitToken entropy, hash stability, plain ≠ entity id
Integrationcreate → query 200; revoke/expired/invalid → 404
E2EMint dialog → open /share anonymously, shared view visible

Runnable end-to-end, mint through anonymous read: recipe: public-share-token.