Public share surface (tokenized read-only links)
This pattern lets an authenticated user mint a time-limited, read-only link. A visitor opens the link without logging in. The database stores only a hash of the token, so the plain token is returned once and cannot be recovered later.
Use the public-share-token recipe as the executable starting point. The guide below explains the decisions around that recipe and the client mount.
Prerequisites
Section titled “Prerequisites”- Read Anonymous access so the anonymous role and tenant resolution are clear.
- Have a feature that owns the records being shared.
- Decide what data may appear in the public DTO. Do not return the complete database row by default.
- Decide how long links live and how an owner revokes them.
- If you need Art. 15 or Art. 17 coverage, mount
user-data-rightsand register anEXT_USER_DATAhook for the token entity.
Store a one-time token
Section titled “Store a one-time token”The recipe uses an event-sourced share-link entity with a SHA-256 token hash,
label, payload, expiry, and revocation timestamp. The create handler returns the
plain token once, and the revoke handler updates revokedAt.
// Public share token — generic recipe (no domain payload).//// Pattern: authenticated create → plain token show-once → anonymous// read-by-token with SHA-256 lookup. Revoke sets revokedAt via executor.//// A full consumer adds domain snapshots, layouts and tier gates on top.
import { generateToken } from "@cosmicdrift/kumiko-framework/api";import { fetchOne } from "@cosmicdrift/kumiko-framework/db";import { createEntity, createEntityExecutor, createJsonbField, createTextField, createTimestampField, defineFeature, defineQueryHandler, defineWriteHandler,} from "@cosmicdrift/kumiko-framework/engine";import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";import { getTemporal } from "@cosmicdrift/kumiko-framework/time";import { z } from "zod";
const TOKEN_PREFIX = "sh_";
const SHARE_BY_TOKEN_REASON = "resolves the share link by token hash for callers whose dispatch tenant is not the link owner's";
export const shareLinkEntity = createEntity({ table: "read_public_share_links", idType: "uuid", fields: { tokenHash: createTextField({ personal: false, reason: "is_token_hash", required: true, maxLength: 64, }), label: createTextField({ personal: false, reason: "is_business_data", required: true, maxLength: 200, }), payload: createJsonbField(), expiresAt: createTimestampField({ required: true }), revokedAt: createTimestampField({}), }, indexes: [ { unique: true, columns: ["tokenHash"], name: "read_public_share_links_hash_unique", }, ],});
const { executor, table } = createEntityExecutor("share-link", shareLinkEntity);
async function hashToken(plain: string): Promise<string> { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(plain)); let hex = ""; for (const byte of new Uint8Array(digest)) { hex += byte.toString(16).padStart(2, "0"); } return hex;}
async function mintToken(): Promise<{ plain: string; hash: string }> { const plain = `${TOKEN_PREFIX}${generateToken()}`; return { plain, hash: await hashToken(plain) };}
const createSchema = z.object({ label: z.string().min(1).max(200), payload: z.record(z.string(), z.unknown()).optional(), expiresInDays: z.number().int().min(1).max(90).default(30),});
export const shareLinkCreateWrite = defineWriteHandler({ name: "share-link:create", schema: createSchema, access: { openToAll: { reason: "any signed-in user may create a share link for their own tenant; the link " + "itself is only reachable via its unguessable token", }, }, rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 }, handler: async (event, ctx) => { const { plain, hash } = await mintToken(); const now = getTemporal().Now.instant(); const expiresAt = now.add({ hours: 24 * event.payload.expiresInDays });
const result = await executor.create( { tokenHash: hash, label: event.payload.label, payload: event.payload.payload ?? {}, expiresAt, revokedAt: null, tenantId: event.user.tenantId, }, event.user, ctx.db, ); if (!result.isSuccess) return result;
const id = String((result.data as { id: unknown }).id); return { isSuccess: true as const, data: { id, plainToken: plain, expiresAt: expiresAt.toString() }, }; },});
export const shareLinkRevokeWrite = defineWriteHandler({ name: "share-link:revoke", schema: z.object({ id: z.uuid() }), access: { openToAll: { reason: "any signed-in user may attempt to revoke a share link, but only the link's " + "own creator succeeds; the handler checks ownerId against the caller and " + "returns not-found otherwise", }, }, handler: async (event, ctx) => { const rows = await ctx.db.selectMany( table, { id: event.payload.id, tenantId: event.user.tenantId }, { limit: 1 }, ); const row = rows[0]; const ownerId = String(row?.["insertedById"] ?? row?.["inserted_by_id"] ?? ""); if (!row || ownerId !== event.user.id) { throw new NotFoundError("share-link"); }
return executor.update( { id: event.payload.id, version: Number(row["version"]), changes: { revokedAt: getTemporal().Now.instant() }, }, event.user, ctx.db, ); },});
interface ShareLinkRow { readonly id: string; readonly label: string; readonly payload: unknown; readonly expiresAt: Temporal.Instant; readonly revokedAt: Temporal.Instant | null;}
export const shareByTokenQuery = defineQueryHandler({ name: "share-by-token", schema: z.object({ token: z.string().min(1) }), access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] }, rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 }, escapeHatch: { grants: ["unsafeRaw"], reason: SHARE_BY_TOKEN_REASON, }, handler: async (query, ctx) => { const hash = await hashToken(query.payload.token); const row = await fetchOne<ShareLinkRow>(ctx.db.unsafeRaw(), table, { tokenHash: hash, });
if (!row) { throw new NotFoundError("share-link"); }
const now = getTemporal().Now.instant(); if (row.revokedAt != null || row.expiresAt.epochMilliseconds <= now.epochMilliseconds) { throw new NotFoundError("share-link"); }
return { label: row.label, payload: row.payload, }; },});
export const publicShareTokenFeature = defineFeature("public-share", (r) => { r.entity({ name: "share-link", ...shareLinkEntity }); r.writeHandler(shareLinkCreateWrite); r.writeHandler(shareLinkRevokeWrite); r.queryHandler(shareByTokenQuery);});The token has the prefix sh_ in the recipe. The prefix is not the security
boundary; the generated token entropy is. Store the hash as a unique value and
never log or persist the plain token.
Protect creation and revocation
Section titled “Protect creation and revocation”The create and revoke handlers in the recipe are authenticated. A paid share
capability can be controlled with a toggleable feature and a tier map. A
toggleable declaration makes the dispatcher gate the feature per tenant; the
tier map belongs to the app’s tier-engine configuration.
Keep owner checks in the revoke path. Return the same not-found response when the record is missing or belongs to another owner so the handler does not reveal whether a token exists.
Resolve a token anonymously
Section titled “Resolve a token anonymously”The query accepts the plain token, hashes it, and looks up the hash. Misses,
expired tokens, and revoked tokens all return the same NotFoundError.
export const shareByTokenQuery = defineQueryHandler({ name: "share-by-token", schema: z.object({ token: z.string().min(1) }), access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] }, rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 }, escapeHatch: { grants: ["unsafeRaw"], reason: SHARE_BY_TOKEN_REASON, }, handler: async (query, ctx) => { const hash = await hashToken(query.payload.token); const row = await fetchOne<ShareLinkRow>(ctx.db.unsafeRaw(), table, { tokenHash: hash, });
if (!row) { throw new NotFoundError("share-link"); }
const now = getTemporal().Now.instant(); if (row.revokedAt != null || row.expiresAt.epochMilliseconds <= now.epochMilliseconds) { throw new NotFoundError("share-link"); }
return { label: row.label, payload: row.payload, }; },});ctx.db.unsafeRaw() takes no argument. The reason lives on the handler as
escapeHatch: { reason, grants: ["unsafeRaw"] }. Without that declaration the
call throws AccessDeniedError. Each use is reported as
security:escape-hatch-used with the declared reason, to the audit sink when the
app has one and as a warning log line otherwise. Use a reason that explains why an anonymous
token lookup crosses the normal caller tenant boundary. See
escape hatches for the three
grants.
Rate-limit every anonymous handler by IP or IP plus handler. A user-based bucket would treat every anonymous caller as the same user.
Enable anonymous access on the server and choose the tenant resolution that matches the host:
await runProdApp({ features: appFeatures, anonymousAccess: { defaultTenantId: APEX_TENANT_ID },});For multiple public hosts, configure a tenant-resolver provider instead of a single default tenant. The resolver must validate that the resolved tenant exists and is allowed for the host.
Mount the public page before authentication
Section titled “Mount the public page before authentication”A full createKumikoApp client usually includes a session provider and an auth
gate. A share route must render before that gate, otherwise a visitor sees the
login screen.
For a small public surface containing only explicit routes, use
createPublicSurface. It stacks client providers and translations but ignores
client gates, so it does not accidentally add an auth gate:
createPublicSurface({ clientFeatures: [emailPasswordClient()], routes: [{ path: "/share", component: <PublicSharePage /> }], fallback: <PublicSharePage />,});For a schema-driven app, add a client feature gate before the session gate:
const publicShareClient: ClientFeatureDefinition = { name: "public-share", gates: [({ children }) => (isPublicSharePath() ? <PublicSharePage /> : <>{children}</>)],};Return a stable public DTO
Section titled “Return a stable public DTO”Keep presentation choices out of the token handler. Return one versioned DTO with the data needed by the public page and a presentation discriminator if the app supports multiple layouts. The client maps that discriminator to a component.
Do not put the plain token, internal IDs that are not needed by the page, or private tenant metadata in the DTO.
Data rights
Section titled “Data rights”Register the token entity with the user-data extension when its metadata is personal data. Use the framework extension point:
r.useExtension(EXT_USER_DATA, "share-link", { export: shareLinkExport, delete: shareLinkDelete,});A token entity that is deliberately covered by another erase path may register a documented no-op delete hook. The boot checks require an explicit decision.
Common gotchas
Section titled “Common gotchas”- Return one not-found shape for invalid, expired, and revoked tokens. Different errors create an existence oracle.
- The plain token is show-once. To resend a link, mint a new token.
- Rate-limit anonymous access by IP or IP plus handler.
- Validate and bound the payload before storing it. A public link is not a reason to expose arbitrary database data.
- Revoke the token before deleting the public page’s backing record, or make the query handle a missing target safely.
The recipe tests create → anonymous query, revoke → 404, invalid token → 404, and expiry → 404. Add an end-to-end test for the actual browser route and a security test that a token from one tenant cannot resolve through another host.