cloudflare-workers-space-membership-invite
Skill0 starsChanged 7 days agoMITView on GitHub
Add per-space membership authorization and single-use invite links to a Hono + D1 Cloudflare Worker so a family shares one space and outsiders get 404, never 403. Use when choosing the authorization model of a new multi-user Workers app, or on these symptoms — a logged-in user can read another family's data by guessing an id, a 403 leaks that a space exists, an invite link works twice, a removed member still has access. Covers why `space_id NOT NULL` belongs in the first migration (retrofitting cost nyalog 1257 child rows to D1 ignoring `PRAGMA foreign_keys=OFF`), the spaces / space_members / invites schema, one-responsibility middleware (memberSpaceIds resolved per request, non-member spaces hidden as 404, owner checks per handler, last-owner guard), sha256-hashed 7-day invite tokens, the race-safe D1 batch (`UPDATE … WHERE consumed_at IS NULL` last, `meta.changes === 0` → compensating deletes + 409), atomic owner bootstrap, and the cross-space e2e seed. Auth-agnostic (passkey or OAuth).
- Reads credentials
- Deletes or force-pushes
In this repository23 other files it gives its agents.
- skills/vercel-react-best-practices/AGENTS.mdAGENTS.md
- agent-status-hubSkill
- claude-code-docker-sandboxSkill
- cloudflare-api-token-permissionsSkill
- cloudflare-cron-to-discordSkill
- cloudflare-d1-drizzle-migrationSkill
- cloudflare-d1-keyless-host-backupSkill
- cloudflare-d1-weekly-backup-via-prSkill
- cloudflare-mcp-claude-toolingSkill
- cloudflare-r2-private-image-uploadSkill
- cloudflare-workers-bot-scan-defenseSkill
- cloudflare-workers-builds-keyless-deploySkill
- cloudflare-workers-deploy-skeletonSkill
- cloudflare-workers-e2e-playwrightSkill
- cloudflare-workers-host-synthetic-checkSkill
- cloudflare-workers-passkey-authSkill
- cloudflare-workers-pat-bearer-authSkill
- cloudflare-workflows-for-long-tasksSkill
- playwright-e2e-in-docker-sandboxSkill
- sandboxed-agent-github-token-via-1passwordSkill
- sandboxed-agent-git-relaySkill
- snr-writingSkill
- vercel-react-best-practicesSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 30
Do not use for: single-user apps (keep WHERE user_id = ?), public UGC services where content is world-readable by design (you still want …
- Very specific: line 59
NOT NULLin the first migration: Never "add nullable → backfill → flip" on a table that is a CASCADE parent — that is the rebuild that …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Per-space membership + invite links on Cloudflare Workers
Extracted from nyalog (ADR-004/005) and routine-tasks (ADR-0001, Phase 4a/4b), 2026-08-22.
Get a multi-user Workers app to the state where every domain row belongs to a space, every request carries the caller's memberSpaceIds, non-member spaces are indistinguishable from non-existent ones (404), and a new family member joins through a single-use invite link — with the schema shaped this way in the first migration, not retrofitted.
Why do this first: authorization is the one part of the data model you cannot change later without a table rebuild. nyalog started as "every authenticated user sees everything", added space_id as NULLABLE, backfilled by hand, and then lost all 1257 child rows when the NOT NULL flip rebuilt the parent table (D1 ignores PRAGMA foreign_keys=OFF, so the rebuild's DROP TABLE cascaded). routine-tasks, designed per-space from day 1, needed none of that: one migration, one atomic bootstrap, invites one phase later.
When to use this skill
- Starting a Workers + D1 app where more than one account shares data (family, household, small team) and you must decide the authorization model
- Adding "invite someone to my space" to an app that already has sessions
- Symptoms in an existing app:
- a logged-in user can read or modify another group's rows by guessing a UUID (IDOR)
- non-member resources return 403, which tells an attacker the id exists
- an invite token works more than once, or two people race on the same link
- a removed member keeps access until their JWT expires (memberships embedded in the token)
- Auth-method-agnostic: the session layer may be passkeys (
cloudflare-workers-passkey-auth) or OAuth — this skill only needsc.var.userId
Do not use for: single-user apps (keep WHERE user_id = ?), public UGC services where content is world-readable by design (you still want created_by, but not spaces), or RBAC with more than two roles (extend; don't adopt verbatim).
Deliverables (completion criteria)
- First migration creates
spaces,space_members,invitesand every domain parent table withspace_id TEXT NOT NULL REFERENCES spaces(id) sessionMiddlewaresetsc.var.userIdandc.var.memberSpaceIds(one query per request; nothing authorization-related inside the JWT)spaceMiddlewareon/api/spaces/:spaceId/*returns404 {"error":{"type":"not_found"}}for malformed ids and for ids outsidememberSpaceIds; setsc.var.spaceId- Owner-only handlers (
POST /invites,DELETE /members/:userId,PATCHrename) callrequireOwnerinside the handler;DELETE /members/:userIdrefuses the last owner with400 last_owner POST /api/spaces/:spaceId/invitesstores onlysha256(token), returns the plain token once, 7-day expiry- Invite consumption (
registerInvitedUser/POST /api/invites/accept) is oneenv.DB.batch([...])withUPDATE invites … WHERE consumed_at IS NULLas the last statement;meta.changes === 0→ compensating deletes +409 invite_race register/beginvalidates the token withvalidateInviteand stores{ kind: "invite", uid, displayName, inviteId, spaceId }as the signed challenge-cookie state (ChallengeState);register/verifyhands that state toregisterInvitedUser— the client only ever receives{ options }and never echoes invite state- First registration (
registerInitialUser) createsusers+spaces+space_members(owner)+credentialsin one batch — no manual bootstrap SQL - Dev bypass (if any) auto-joins a fixed
DEV_SPACE_IDsomemberSpaceIdsis never empty in dev - e2e authorization-boundary spec: authed user → seeded "other family" space → API 404 with the exact body + UI access-denied
created_bycolumns are audit-only (ON DELETE SET NULL), never part of aWHEREused for authorization
The decision: per-space membership from day 1
| Model | Schema | Fails when |
per-user ownership (WHERE user_id = ?) | rows carry user_id | a family shares one list → duplicates, "only the creator can edit" |
| implicit family-shared (nyalog ADR-004) | no scope column; any authenticated user sees all | a second family / test account / "who can see this" has no answer; the retrofit is a table rebuild |
| per-space membership (adopt) | spaces, space_members(space_id, user_id, role), <parent>.space_id NOT NULL | only at SaaS scale (dozens of spaces per user → cache memberSpaceIds) |
Rules that follow:
- Parents carry
space_id; children join through the parent.meals.space_id, butmeal_tags/meal_photosreach the space viameal_id— no duplicated scope column to drift. An entity is a parent when it has its own list endpoint (tagsis one,meal_tagsis not). created_byis an audit attribute, not an authorization key (ADR-004). Everyone in the space edits everyone's rows;created_bydrives a badge and isON DELETE SET NULLso deleting a user never blocks on FK.- Single-space UX is fine. Hide the switcher; keep the model. The URL still says
/spaces/:spaceId/…. NOT NULLin the first migration. Never "add nullable → backfill → flip" on a table that is a CASCADE parent — that is the rebuild that bit nyalog. Mechanism and runbook:cloudflare-d1-drizzle-migration.
Schema in one screen
spaces (id PK, name, created_at)
space_members (space_id FK→spaces CASCADE, user_id FK→users CASCADE,
role CHECK IN ('owner','member'), created_at,
PK (space_id, user_id), INDEX (user_id))
invites (id PK, space_id FK→spaces CASCADE, token_hash UNIQUE,
role CHECK = 'member', expires_at, consumed_at,
consumed_by_user_id FK→users SET NULL,
created_by_user_id FK→users CASCADE, created_at)
meals (id PK, space_id FK→spaces CASCADE NOT NULL, …, created_by FK→users SET NULL)
tags (id PK, space_id FK→spaces CASCADE NOT NULL, name, UNIQUE (space_id, name))
meal_tags (meal_id FK→meals CASCADE, tag_id FK→tags CASCADE, PK) -- scope via parentsDrizzle source, generated SQL, ON DELETE choices and the index rationale: references/schema.md.
Middleware layering — one responsibility each
| Layer | Sets / checks | Never does |
sessionMiddleware | userId, displayName, memberSpaceIds (SELECT space_id FROM space_members WHERE user_id = ?) | per-space decisions |
spaceMiddleware (/api/spaces/:spaceId/*) | spaceId parses as a UUID and is in memberSpaceIds, else 404; sets c.var.spaceId | role checks |
| handler | requireOwner where the action is owner-level; isLastOwner before removal; SQL scoped by c.var.spaceId | re-checking membership |
404, not 403. 403 says "exists, not yours"; an id-guessing attacker learns which UUIDs are real. Return the same {"error":{"type":"not_found"}} for malformed, missing and foreign ids. Keep the console.error("[spaceMiddleware] 404 …") lines — they are how you diagnose e2e failures — but never echo the reason in the body.
Why memberSpaceIds is one query per request and not a JWT claim: removal must take effect on the next request. At family scale the query is free; revisit only when a user belongs to dozens of spaces.
Why owner checks live in handlers, not middleware: GET /members is member-level, POST /invites is owner-level, same prefix. An owner middleware on the prefix turns every read into 403 for members.
URL design: prefer /api/spaces/:spaceId/<resource> (routine-tasks) for a new app — membership is checked once, handlers use WHERE space_id = ?. nyalog's flat /api/cats/:catId with inArray(cats.spaceId, memberSpaceIds) works for one fixed space but every handler must repeat the lookup and guard the empty list explicitly (memberSpaceIds.length === 0 → 404 before inArray). Both shapes with code: references/middleware.md.
Invite flow
owner POST /api/spaces/:spaceId/invites → { token, inviteId, expiresAt, url } (plain token: once)
DB: INSERT invites(token_hash = sha256(token), role = 'member', expires_at = now + 7d)
owner sends https://<app>/#/invite?token=<token> out of band (LINE, in person)
family opens the link → SPA reads the fragment → register (A) or accept (B)Token rules: 32 random bytes (crypto.getRandomValues) → hex; the DB holds only the sha256 hex (token_hash UNIQUE); lookup by hash; expires_at = 7 days; consumed_at / consumed_by_user_id enforce single use; role CHECK (role = 'member') — invites never mint owners (promote by SQL, see ops).
Three accept paths, one consumption rule:
- (A) new user via passkey registration — contract with
cloudflare-workers-passkey-auth, implemented in this skill'sworker/spaces/registration.ts:register/beginwithinviteTokencallsvalidateInvite(403 invite_invalid/410 invite_consumed/410 invite_expired; the display name fails with the passkey skill's400 validation_error), stores{ kind: "invite", uid: pendingUserId, displayName, inviteId, spaceId }as the signed challenge-cookie state viaissueChallenge(c, challenge, state), and returns only{ options }.register/verifyhands the consumed state toregisterInvitedUser, which runs one batch:users→space_members(member)→credentials→UPDATE invites(last); the session row comes afterwards fromissueSession. - (B) logged-in user joining another space —
POST /api/invites/accept { token }(session-protected, not space-scoped): already a member →409 already_memberwithout consuming; otherwise batchspace_members→UPDATE invites. Design extension over routine-tasks — see Unverified. - (C) OAuth — log in first, then (B).
The consumption rule (the part that is easy to get wrong):
const results = await c.env.DB.batch([
...inserts, // users, space_members, credentials — prepared statements (the session row comes later via issueSession)
c.env.DB.prepare(
"UPDATE invites SET consumed_at = ?, consumed_by_user_id = ? WHERE id = ? AND consumed_at IS NULL",
).bind(now, userId, inviteId), // LAST
]);
if ((results[results.length - 1]?.meta.changes ?? 0) === 0) {
await c.env.DB.batch([/* reverse-order DELETEs of the inserts */]);
return c.json({ error: { type: "invite_race" } }, 409);
}batch() is atomic (any SQL error rolls everything back) but a WHERE that matches zero rows is not an error — the batch "succeeds" with changes: 0. So the UPDATE goes last, its row count is asserted, and the already-committed inserts are compensated by hand. D1 has no SELECT … FOR UPDATE; this is the substitute. registration.ts (validateInvite / registerInvitedUser / registerInitialUser), the AuthError additions, the routes and client fragment parsing: references/invite-flow.md.
| Status | error.type | Meaning |
| 403 | invite_invalid | hash not found (brute-force signal) |
| 410 | invite_consumed | used already (checked before expiry) |
| 410 | invite_expired | TTL passed, never used |
| 400 | validation_error | display name rejected (passkey skill's input error) |
| 409 | invite_race | lost the consumption race; rows compensated |
| 409 | already_member | (B) only; token not consumed |
Member management endpoints
| Route | Who | Notes |
GET /api/spaces | member | my spaces with role, memberCount |
GET /api/spaces/:spaceId | member | spaceMiddleware already proved membership |
PATCH /api/spaces/:spaceId | owner | rename |
GET /api/spaces/:spaceId/members | member | list with role, joinedAt |
DELETE /api/spaces/:spaceId/members/:userId | owner | 400 last_owner guard; 404 if not a member |
POST /api/spaces/:spaceId/invites | owner | issue |
GET /api/spaces/:spaceId/invites | owner | pending (no tokens — only hashes exist) |
DELETE /api/spaces/:spaceId/invites/:inviteId | owner | revoke = delete the row |
POST /api/invites/accept | any session | path (B) |
Two roles only. Owner promotion is a one-line SQL in ops, not an endpoint — add one when a second owner is actually needed.
Bootstrap and dev
- First owner: the passkey skill's
INITIAL_REGISTRATION_TOKENpath calls this skill'sregisterInitialUser, which createsusers+spaces+space_members(owner)+credentialsin onebatch;issueSessionthen adds the session row. No hand-written bootstrap SQL, no NULLspace_idwindow. (nyalog's2026-04-22-space-bootstrap.sqlis what retrofitting costs.) - Dev bypass (
DEV_BYPASS_USER_ID, local origin only): alsoINSERT … ON CONFLICT DO NOTHINGa fixedDEV_SPACE_IDspace and an owner membership (ensureDevSpace), somemberSpaceIdsis non-empty and dev INSERTs have a space to bind to. - Drizzle vs raw D1: Drizzle for everything except the registration / invite batches (
registerInitialUser,registerInvitedUser,POST /api/invites/accept), which useenv.DB.prepare().bind()insideenv.DB.batch()because they need per-statementmeta.changes— pass the raw bindingc.env.DB.
e2e: the authorization-boundary spec
Seed an "other family" with fixed UUIDs in global-setup.ts (INSERT OR IGNORE, wrangler d1 execute <db> --local): user → space → membership → one domain row, in FK order. The spec: register or log in as a fresh user, GET /api/spaces/<OTHER_SPACE_ID>/meals → 404 with body exactly {"error":{"type":"not_found"}}, then page.goto("/#/spaces/<OTHER_SPACE_ID>") shows the access-denied view. With OAuth, switch the seeded-session token instead of registering. Playwright wiring, the virtual authenticator and the seeded-session seam live in cloudflare-workers-e2e-playwright; the seed + spec bodies are in references/e2e-cross-space.md.
The pitfalls that eat hours
- 403 instead of 404 — leaks existence. One body for every non-member outcome.
- Owner check in middleware — members get 403 on reads. Check role per handler.
- Memberships in the JWT — a removed member keeps access until expiry. Resolve per request.
changes: 0is success inD1Database.batch()— put the consumingUPDATE … WHERE consumed_at IS NULLlast, assertmeta.changes, compensate.- Client-echoed registration state — routine-tasks sends
registration: { inviteId, spaceId }to the client and trusts it back atverify; anyone who learns an unconsumedinviteId(a UUID shown inGET /invites) could join without the token. In this contract the invite lives only in the signedChallengeStatecookie;register/verifymust never readinviteId/spaceId/uidfrom the request body. - Retrofitting
space_id NOT NULLrebuilds a CASCADE parent on D1 and deletes children (nyalog: 1257 rows). Design it in; if you must retrofit, followcloudflare-d1-drizzle-migration(backup, drop the CASCADE first, count after). inArray(col, [])— guardmemberSpaceIds.length === 0explicitly before building the query; don't rely on the ORM's empty-list behaviour.created_bywith the default FK action blocksDELETE FROM usersonce that user has rows (D1 enforces foreign keys). UseON DELETE SET NULLfor audit columns.- Seeding with
INSERT OR IGNOREout of FK order — insertusers→spaces→space_members→ parent rows → children. Ignore-on-conflict does not hide FK errors. - Plain token in logs — with Workers Observability at
head_sampling_rate: 1, anyconsole.logof the token is persisted. LoginviteId, never the token. Put the token in the URL fragment so it never reaches server logs orReferer. spaceMiddlewareon the wrong prefix — mount it on the sub-router that owns/:spaceId;GET /api/spaces(list) andPOST /api/invites/acceptsit outside it.
Unverified claims — confirm while implementing, then write back
Record outcomes in this section (PR to okayus-skills) and in the app's ADR.
- UNVERIFIED: the fragment-based invite link survives LINE's in-app browser on iOS/Android. matatabetai uses a history router with
/invite#token=…and Chromium keeps the fragment through the SPA's initial load (verified 2026-08-30 in e2e) — verify on a real phone via LINE.
Verified 2026-08-30 in matatabetai (first production user of this skill)
- drizzle-orm 0.45.2
inArray(col, [])emitssql\false`(sql/expressions/conditions.js) — no throw. Keep the explicit empty guard anyway; matatabetai avoids the question by putting:spaceId` in the URL. - Raw
D1Database.batch()on workerd exposes per-statementmeta.changes; a 0-rowUPDATE … WHERE consumed_at IS NULLreturnssuccess: true, changes: 0, so the race check as written detects the lost race.db.$clientwas not needed — passc.env.DB. - D1
batch()rolls back the whole batch on a mid-batch constraint violation (a duplicateusers.idas the 2nd statement raisedD1_ERROR: UNIQUE constraint failedand the 1st INSERT was gone afterwards) — verified on workerd/miniflare; production D1 documents the same atomicity. - Path (B)
POST /api/invites/accept:already_member(409, token not consumed) and a revoked token (invite_invalid) behave as specified; the same consumption batch is reused. Double-submit of a consumed token returnsinvite_consumedfromvalidateInvitebefore the batch. - The invite state travels only in the signed challenge cookie (
{ kind: "invite", uid, displayName, inviteId, spaceId }); the cookie is ~400 bytes, andregister/verifyreads nothing butresponse/deviceNamefrom the body. The cookie payload is re-validated with a zod discriminated union after signature verification, so an unknownkindis rejected even with a valid signature. - Hono 4.13: a sub-app mounted with
protectedApi.route("/spaces/:spaceId", space)whosespace.use("*", spaceMiddleware)readsc.req.param("spaceId")gets the param populated (Hono merges sub-app routes into the parent with the prefixed path). - drizzle-kit 0.31.10 emits
CHECKconstraints for SQLite when the schema declares them withcheck(name, sql\…`)fromdrizzle-orm/sqlite-core(array-form extra config), and local D1 enforces them (CHECK constraint failed: space_members_role_check). No hand-editing of the migration needed —references/schema.mdnow usescheck()`. - Bootstrapping a drizzle-kit journal on a repo whose first migration was hand-written (
0000_init.sql=SELECT 1): rundrizzle-kit generate --custom --name initonce against an empty schema file (createsmeta/0000_snapshot.json+ the journal entry, overwriting0000_init.sql— restore it from git), thendrizzle-kit generate --name <real>against the real schema produces0001_<real>.sqlwith a consistent snapshot chain. - "One owned space per user" (
POST /api/spaces) is race-safe without a table lock:INSERT INTO spaces … SELECT ?,?,? WHERE NOT EXISTS (SELECT 1 FROM space_members WHERE user_id = ? AND role = 'owner')followed by the membershipINSERT … SELECT … WHERE EXISTS (SELECT 1 FROM spaces WHERE id = ?)in one batch;meta.changes === 0on the first statement →409 already_owner.
Scope boundary — what this skill does NOT cover
- Passkey ceremonies, session cookies,
ChallengeState/issueChallenge/consumeChallenge,INITIAL_REGISTRATION_TOKEN, credential management —cloudflare-workers-passkey-auth(this skill supplies theworker/spaces/registration.tsfunctions itsregister/begin/verifycall:validateInvite,registerInvitedUser,registerInitialUser) - Playwright wiring, virtual authenticator, seeded-session seam —
cloudflare-workers-e2e-playwright - The D1
PRAGMA foreign_keys=OFFrebuild trap and the backup / row-count runbook —cloudflare-d1-drizzle-migration - Worker / wrangler / D1 skeleton —
cloudflare-workers-deploy-skeleton - Per-space R2 key prefixes for uploaded images (
<spaceId>/…) —cloudflare-r2-private-image-upload - Rate limiting the unauthenticated auth routes (invite validation rides on
register/begin, which is rate-limited there) —cloudflare-workers-bot-scan-defense - Roles beyond owner / member, per-space webhooks, multi-space switcher UX
References
- schema.md — Drizzle schema + generated SQL for
spaces/space_members/invitesand the domain-table rule - middleware.md —
sessionMiddlewaremembership resolution (loadMemberSpaceIds), dev-space auto-join (ensureDevSpace),spaceMiddleware,requireOwner/isLastOwner, router mounting, flat-URL alternative - invite-flow.md — token helpers,
worker/spaces/registration.ts(validateInvite/registerInvitedUser/registerInitialUser),AuthErroradditions, issue / list / revoke routes, paths (A) and (B) with the race-safe batch, client fragment handling - e2e-cross-space.md — fixed-UUID "other family" seed and the authorization-boundary spec
- ops.md — family onboarding runbook, owner promotion, recovery, cleanup SQL
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

