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(orcreatePublicSurfaceon apex) - GDPR export/forget hooks on token metadata
Prerequisites
Section titled “Prerequisites”- 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.
The code
Section titled “The code”Server, the token entity, the gate on minting, and the read path.
1. Entity + handlers
Section titled “1. Entity + handlers”Mirror user-data-rights/download-by-token:
tokenHash(SHA-256 hex, unique)expiresAt,revokedAttargetPayload(json snapshot, domain-specific)
Register create, revoke, list, and share-by-token on your app feature.
2. Tier gate on create
Section titled “2. Tier gate on create”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).
3. Anonymous query
Section titled “3. Anonymous query”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); },});4. anonymousAccess
Section titled “4. anonymousAccess”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.
5. Gate before auth
Section titled “5. Gate before auth”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 orderconst 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).
Layout templates
Section titled “Layout templates”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_DATAhook revokes tokens on user forget - Art. 20, export token metadata (without
tokenHash/ plain token)
Common gotchas
Section titled “Common gotchas”- One
NotFoundErrorfor 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.
| Layer | What to assert |
|---|---|
| Unit | Token entropy, hash stability, plain ≠ entity id |
| Integration | create → query 200; revoke/expired/invalid → 404 |
| E2E | Mint dialog → open /share anonymously, shared view visible |
Live example
Section titled “Live example”Runnable end-to-end, mint through anonymous read: recipe: public-share-token.
See also
Section titled “See also”- Anonymous access, the underlying anonymous-read mechanics this guide builds on
- user-data-rights, the download-by-token pattern the token entity mirrors
- managed-pages, branding in public DTO
- folders, snapshot a whole folder into one share