@fitzzero/quickdraw-skills 5.1.1 → 5.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,9 +41,12 @@ working on matching files.
41
41
  ## The command
42
42
 
43
43
  - `quickdraw-skills link` writes each link as a relative symlink into
44
- `node_modules/@fitzzero/quickdraw-skills` (or into the package wherever it
45
- is installed, when it is not at the repo root) in the nearest directory
46
- above the working directory that holds `.git`. It replaces or prunes only
44
+ `node_modules/@fitzzero/quickdraw-skills`, in the project that installs
45
+ the package: the nearest directory above the working directory with a
46
+ `package.json` and `node_modules/@fitzzero/quickdraw-skills`, whatever
47
+ `.git` is around it. It fails when there is none, and refuses to link
48
+ into the home directory, whose `.claude` holds every project's settings.
49
+ It replaces or prunes only
47
50
  links that point into this package (through
48
51
  `@fitzzero/quickdraw-skills/`, or landing in its directory): a real file
49
52
  or directory, or another package's link, is left alone with a warning,
package/bin/cli.mjs CHANGED
@@ -25,6 +25,13 @@
25
25
  // or `.claude/<kind>` is a symlink, or resolves outside the repo, that kind is
26
26
  // left alone with a warning, since relative links written through it would
27
27
  // land elsewhere (a shared or global directory) and resolve against it.
28
+ //
29
+ // The repo is the project that installs this package: the nearest directory
30
+ // above the working directory with a `package.json` and
31
+ // `node_modules/@fitzzero/quickdraw-skills`, whatever `.git` is around it (the
32
+ // 5.1 audit's CLIENT-7: run by a consumer's `prepare` inside a dotfiles repo
33
+ // in the home directory, it linked into `~/.claude`). It never links into the
34
+ // home directory itself, whose `.claude` holds every project's settings.
28
35
 
29
36
  import {
30
37
  existsSync,
@@ -36,6 +43,7 @@ import {
36
43
  rmSync,
37
44
  symlinkSync,
38
45
  } from "node:fs";
46
+ import { homedir } from "node:os";
39
47
  import { dirname, join, relative, resolve, sep } from "node:path";
40
48
  import process from "node:process";
41
49
  import { fileURLToPath } from "node:url";
@@ -45,8 +53,10 @@ const PACKAGE = "@fitzzero/quickdraw-skills";
45
53
  const USAGE = `Usage: quickdraw-skills link [--check]
46
54
 
47
55
  link link this package's rules into .claude/rules and its skills
48
- into .claude/skills of the repo around the working directory,
49
- and prune links to rules or skills it no longer ships
56
+ into .claude/skills of the project that installs the package
57
+ (the nearest package.json above the working directory with
58
+ node_modules/@fitzzero/quickdraw-skills), and prune links to
59
+ rules or skills it no longer ships
50
60
  link --check change nothing; exit 1 when a link is missing, stale or dangling
51
61
  `;
52
62
 
@@ -65,22 +75,33 @@ function fail(message) {
65
75
  process.exit(1);
66
76
  }
67
77
 
68
- /** The nearest directory above `start` that holds `.git`, else the nearest that holds `package.json`. */
69
- function findRepoRoot(start) {
70
- for (const marker of [".git", "package.json"]) {
71
- let dir = start;
72
- for (;;) {
73
- if (existsSync(join(dir, marker))) {
74
- return dir;
75
- }
76
- const parent = dirname(dir);
77
- if (parent === dir) {
78
- break;
79
- }
80
- dir = parent;
78
+ /**
79
+ * The nearest directory from `start` up that has a `package.json` and
80
+ * installs this package (`node_modules/@fitzzero/quickdraw-skills/package.json`):
81
+ * the project whose install runs the link. A `.git` around it plays no part.
82
+ */
83
+ function findProjectRoot(start) {
84
+ const installed = join("node_modules", ...PACKAGE.split("/"), "package.json");
85
+ let dir = start;
86
+ for (;;) {
87
+ if (existsSync(join(dir, "package.json")) && existsSync(join(dir, installed))) {
88
+ return dir;
81
89
  }
90
+ const parent = dirname(dir);
91
+ if (parent === dir) {
92
+ return undefined;
93
+ }
94
+ dir = parent;
95
+ }
96
+ }
97
+
98
+ /** True when `dir` is the home directory, through any symlink. */
99
+ function isHome(dir) {
100
+ try {
101
+ return realpathSync(dir) === realpathSync(homedir());
102
+ } catch {
103
+ return false;
82
104
  }
83
- return undefined;
84
105
  }
85
106
 
86
107
  function isSymlink(path) {
@@ -125,17 +146,23 @@ const { check } = parseArgs(process.argv.slice(2));
125
146
  // This package's own directory, through any install symlink (bun and pnpm may
126
147
  // install it as a link into their store).
127
148
  const packageDir = realpathSync(dirname(dirname(fileURLToPath(import.meta.url))));
128
- const repoRoot = findRepoRoot(process.cwd());
149
+ const repoRoot = findProjectRoot(process.cwd());
129
150
  if (repoRoot === undefined) {
130
- fail("found no repo root (a .git or package.json) above the working directory");
151
+ fail(`found no package.json above the working directory that installs ${PACKAGE}`);
152
+ }
153
+ if (isHome(repoRoot)) {
154
+ fail(
155
+ "refusing to link into the home directory (~/.claude holds every project's settings): run quickdraw-skills link from the app's repo",
156
+ );
131
157
  }
132
158
 
133
159
  /**
134
160
  * What one kind links: its entries, where the links go, and the directory
135
- * they point into. Links prefer the stable `node_modules/@fitzzero/quickdraw-skills`
136
- * path, so committed links never embed a package store's hashed path; without
137
- * it (the package not installed at the repo root) they point at the package
138
- * where it is.
161
+ * they point into. Links name the project's stable
162
+ * `node_modules/@fitzzero/quickdraw-skills` path, which the project root
163
+ * always installs, so committed links never embed a package store's hashed
164
+ * path; only an installed copy that lacks this kind's directory (a broken
165
+ * install) makes them point at the package that runs.
139
166
  */
140
167
  function planKind(kind) {
141
168
  const sourceDir = join(packageDir, kind.name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitzzero/quickdraw-skills",
3
- "version": "5.1.1",
3
+ "version": "5.1.3",
4
4
  "description": "Agent rules and skills for quickdraw 5.0 apps, linked into .claude/ by the quickdraw-skills command",
5
5
  "keywords": [
6
6
  "agents",
@@ -18,15 +18,15 @@ unknown level or a malformed access list denies.
18
18
 
19
19
  Every method's `{ access, handler }` names one form:
20
20
 
21
- | Form | Passes when |
22
- | --------------------------------- | ------------------------------------------------------------------------------------------- |
23
- | `"public"` | always, with or without a principal |
24
- | `"authenticated"` | a principal exists |
25
- | `{ service: L }` | the principal's service-wide grant is at least `L` |
26
- | `{ entry: L, id? }` | the policy gives the principal at least `L` on the row (`id` defaults to `input.id`) |
27
- | `{ service: L1, entry: L2, id? }` | either of the two |
28
- | `{ scope: L, of: contract, id }` | at least `L` on a row of another service, whose id is `input[id]` (create and list methods) |
29
- | `custom((ctx, input) => boolean)` | the function resolves `true` (a principal is required first) |
21
+ | Form | Passes when |
22
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `"public"` | always, with or without a principal |
24
+ | `"authenticated"` | a principal exists |
25
+ | `{ service: L }` | the principal's service-wide grant is at least `L` |
26
+ | `{ entry: L, id? }` | the policy gives the principal at least `L` on the row (`id` defaults to `input.id`) |
27
+ | `{ service: L1, entry: L2, id? }` | either of the two |
28
+ | `{ scope: L, of: contract, id }` | at least `L` on a row of another service, whose id is `input[id]` (create and list methods) |
29
+ | `custom((ctx, input) => boolean)` | the function resolves `true` (a principal is required first); it replaces the row check, and a service-wide `Admin` grant skips it |
30
30
 
31
31
  - Levels are `"Read" < "Moderate" < "Admin"` (`AccessLevel` also has
32
32
  `"Public"`). `id` is an input key (type-checked) or a function of the input.
@@ -69,34 +69,127 @@ export const tokenService = qd.defineService(token, {
69
69
  list, which holds for signed-in callers.
70
70
  - A service's list also refuses other kinds on `qd:sub`, `qd:col:sub`,
71
71
  `qd:watch` and `qd:stream:sub`, and its channels drop their messages.
72
+ - Kinds separate only what the hooks label: label an MCP caller
73
+ `kind: "agent"` in the bridge's `principal` hook (`bearerAuth` with
74
+ `loadPrincipal: (userId) => ({ userId, kind: "agent" })`), or a method
75
+ with `kinds: ["user"]` lets it in as a user. An MCP custom tool obeys the
76
+ app's kinds too, and takes `kinds` of its own within them, never a kind
77
+ check in its handler.
72
78
  - Never check the kind in a handler (`if (ctx.principal.kind !== "user")
73
79
  throw ...`; `no-inline-auth-guard` reports it) or in a `custom` form:
74
80
  `kinds` combines with any form (`{ entry: "Moderate" }` and
75
81
  `kinds: ["user"]`), where `custom` would have to check the row by hand.
76
- A check on a claim, such as a token's `scope`, stays `custom`.
82
+ A check on a claim, such as a token's `scope`, is `claims`, never
83
+ `custom` (below).
84
+
85
+ ## A requirement on the caller's claims
86
+
87
+ A check on the caller's verified claims (`principal.claims`, a token's
88
+ `scope`) is `claims`, beside `access` on the method:
89
+
90
+ ```ts
91
+ triage: {
92
+ // Moderate on the task's project, from a token that may triage: claims add to the form
93
+ access: { entry: "Moderate" },
94
+ claims: (claims, input) => claims?.scope === "triage" && input.status !== "archived",
95
+ handler: ({ input, db }) =>
96
+ db.task.update({ where: { id: input.id }, data: { status: input.status } }),
97
+ },
98
+ ```
99
+
100
+ - It adds to the form: `triage` needs Moderate on the task's project and a
101
+ triage token. Never write it as `custom`: that form replaces the row
102
+ check, so any token holding the claim would write every tenant's rows,
103
+ and a service-wide `Admin` grant skips it.
104
+ - It runs right after `kinds`, outside the access engine, so no grant passes
105
+ it, a service-wide `Admin` grant included. Without a principal the call
106
+ is `UNAUTHENTICATED`; when it returns anything but `true`, `FORBIDDEN`.
107
+ - It is synchronous, and a `"public"` method takes none.
108
+ - A token narrowed by `claims` is narrowed only on the methods that declare
109
+ it; elsewhere it acts with its user's grants (`kinds` keeps a kind of
110
+ token out).
111
+
112
+ ## A row the form cannot name
113
+
114
+ A check of the caller's level on a row no declared form names (a parent
115
+ the input names, a key that is optional) is a `custom` form that asks
116
+ `ctx.access`: never a check in the handler, and never a module that holds
117
+ the dispatcher.
118
+
119
+ ```ts
120
+ assigned: {
121
+ // Read on the project the input names, asked through ctx.access; none named, the caller's
122
+ // own that they may still read
123
+ access: custom(
124
+ async (ctx, input) =>
125
+ input.projectId === undefined || (await ctx.access.can(project, input.projectId, "Read")),
126
+ ),
127
+ handler: async ({ input, ctx, db }) => {
128
+ const readable = await ctx.access.where(task, "Read");
129
+ if (readable === "none") return [];
130
+ return db.task.findMany({
131
+ where: { AND: [{ assigneeId: ctx.principal.userId, projectId: input.projectId }, readable] },
132
+ take: 100,
133
+ });
134
+ },
135
+ },
136
+ ```
137
+
138
+ - `ctx.access.can(service, id, level)`, `levelsFor(service, ids)` and
139
+ `where(service, level)` count levels as `{ entry }` does, for
140
+ `ctx.principal` with the grants the call runs with: never load the user's
141
+ grants again. `service` is a contract or a service name.
142
+ - A list the form lets through without its key still follows the row
143
+ policy: add `ctx.access.where(service, "Read")` to its `findMany` with
144
+ `AND` (`"none"` lists nothing). Never spread it into `where`: the filter
145
+ and the input share a column (`inherit`'s `projectId`), so whichever
146
+ comes last replaces the other. The filter's widens the keyed list to
147
+ every project the caller reads; the input's `undefined` drops the policy
148
+ from the list without a key.
149
+ - Prefer a declared form when one says it: `{ entry: L }` for the method's
150
+ own row, `{ scope: L, of, id }` for a parent whose key is always there.
151
+ A `ctx.access` form counts a service-wide `Admin` grant on the service it
152
+ asks (the parent's); `{ scope, of }` counts only one on the method's own
153
+ service. `custom` replaces the row check and a
154
+ service-wide `Admin` grant skips it, so a method whose input has `id`
155
+ still needs `rowless: true` beside it (below).
156
+ - An anonymous caller has no level anywhere (`false`, an empty map,
157
+ `"none"`). `qd` has no `access`: a job asks `server.dispatcher.access`.
77
158
 
78
159
  ## A method that takes a row id
79
160
 
80
161
  On a service with a policy, a method whose input has `id` reads or writes
81
- the row that id names. Under a form that checks no row (`"public"`,
82
- `"authenticated"`, `{ service: L }` below `Admin`), anyone the form admits
83
- reaches any row by its id, so `defineService` refuses it when the service is
84
- defined. Fix the form, not the error:
162
+ the row that id names. Under a form that asks the policy nothing about that
163
+ row, anyone the form admits reaches any row by its id, so `defineService`
164
+ refuses it when the service is defined. Those forms are the ones that check
165
+ no row (`"public"`, `"authenticated"`, `{ service: L }` below `Admin`),
166
+ `custom(fn)`, `{ scope, of, id }` (a row of another service) and
167
+ `{ entry, id }` naming another key. Fix the form, not the error:
85
168
 
86
169
  - Give it `{ entry: L }` (`"Read"` for a read, `"Moderate"` for a change),
87
170
  or `{ service: L, entry: L }` to keep a service grant: the policy decides.
88
171
  - Only when every caller the form admits may reach any row on purpose (a
89
- public profile, a lookup by an id that tells nothing) say so with
90
- `rowless: true` on the method, or `rowless: ["get"]` in a kit's options
91
- (`crud.handlers`, `admin.handlers`, `sharing.handlers`).
92
- - The read/write kit's `update`, `delete`, `reorder` and `create` check the
93
- row themselves and need neither.
172
+ public profile, a lookup by an id that tells nothing), or when `id` names
173
+ a row the method creates (an id the client made, under a `scope` form),
174
+ say so with `rowless: true` on the method, or `rowless: ["get"]` in a
175
+ kit's options (`crud.handlers`, `admin.handlers`, `sharing.handlers`). A
176
+ `rowless` query under a form that checks no row also answers "not
177
+ modified" for any `id`.
178
+ - The read/write kit's `update`, `delete` and `reorder` check the row
179
+ themselves and need neither. `create` makes a new row and checks no row of
180
+ its own: it checks the anchors it writes (each `inherit` parent and
181
+ anchored collection's scope column, from what `prepare` returns) at the
182
+ method's row level, and takes a column the policy reads only from
183
+ `prepare` (`ownerId: ctx.principal.userId`), unless the caller holds a
184
+ service-wide `Admin` grant. Give it
185
+ `{ scope: "Moderate", of: project, id: "projectId" }`.
94
186
  - The check reads the input's keys from its JSON Schema: an `id` in any
95
187
  branch of a union counts, as does one beside a `Date` or a `Set`. An input
96
188
  without JSON Schema (a Zod 3 schema) or one that names no keys (a
97
- `todoSchema` without `keys`), a bare string that is the id itself
98
- and a row named by another key (`taskId`, `ids`) are not checked, so the
99
- form is all yours there (`{ entry: L, id: "taskId" }` names another key).
189
+ `todoSchema` without `keys`), a bare string that is the id itself, a row
190
+ named by another key (`taskId`, `ids`) on an input without `id`, and an
191
+ `entry` form whose `id` is a function are not checked, so the form is all
192
+ yours there (`{ entry: L, id: "taskId" }` names another key).
100
193
 
101
194
  ## Service-wide grants
102
195
 
@@ -110,8 +203,9 @@ its first call. With
110
203
  tracked write to that column refreshes the user's open sockets, and an
111
204
  in-process caller loads the grants again at its next call.
112
205
 
113
- - A service-wide `Admin` grant passes every check on its service
114
- (`adminBypass: false` on the service turns that off).
206
+ - A service-wide `Admin` grant passes every access form on its service
207
+ (`adminBypass: false` on the service turns that off), though not `kinds`
208
+ or `claims`.
115
209
  - A grant below `Admin` counts only where the form names `service`: a `Read`
116
210
  grant does not read every row through an `entry` form.
117
211
 
@@ -153,7 +247,23 @@ compile time.
153
247
  job, alone or in `anyOf`.
154
248
  - Lookups are batched per call: checking 60 ids costs what one does.
155
249
  `createServer({ access: { cacheMs } })` keeps them across calls; tracked
156
- writes to the columns and tables a policy reads evict them.
250
+ writes to the columns and tables a policy reads evict them. A lookup
251
+ inside a transaction always reads the database.
252
+ - Membership and junction tables need an `id` column
253
+ (`id String @id @default(cuid())`, the pair unique by `@@unique`), never a
254
+ composite `@@id`: the write tracker keys rows by `id`, so such a table is
255
+ never tracked and a removed member is never revoked. `createServer` logs
256
+ `[quickdraw:untracked-access-model]` as an error for one, and a strict
257
+ test app fails to start. A foreign key set through a relation on the
258
+ written row (`task.update({ data: { project: { connect } } })`) revokes
259
+ like a scalar one; one set from the parent's side
260
+ (`project.update({ data: { tasks: { connect } } })`) is a nested write
261
+ whose rows are not tracked: write the foreign key on the row itself.
262
+ - Never pass Socket.IO's `connectionStateRecovery` in `createServer({ socket })`:
263
+ a recovered socket comes back with its principal and rooms without
264
+ authenticating again, so a revoked session or a lowered grant would
265
+ return. `createServer` refuses it; clients resume by revision on every
266
+ reconnect.
157
267
 
158
268
  ## One policy, every surface
159
269
 
@@ -162,10 +272,18 @@ collection scopes (through the collection's `anchor`), the rows the kits'
162
272
  `list`, `getMany`, `search` and bulk methods return or write, streams and
163
273
  channel `requires`.
164
274
 
165
- - Everyone who may open a collection scope sees every item in it, stripped
166
- at the collection's `access` level (default `Read`). Per-row policies and
167
- field tiers do not apply inside a collection: give the item service
168
- `inherit` from the anchor, or use separate scopes.
275
+ - Everyone who may open an anchored collection scope sees every item in it,
276
+ stripped at the collection's `access` level (default `Read`). Per-row
277
+ policies and field tiers do not apply inside an anchored scope: give the
278
+ item service `inherit` from the anchor, or use separate scopes. A self
279
+ scope (`scopeAccess: "self"`) is the exception: its items follow the item
280
+ service's policy, so its user gets only the rows they may read.
281
+ - A list whose methods need a service-wide grant (`{ service: "Read" }`)
282
+ needs it too: `requires: { service: "Read" }` beside its `anchor` or
283
+ `scopeAccess`, in `defineService`'s `collections`. Both checks apply (a
284
+ service-wide `Admin` passes either), a scope without the grant is
285
+ `FORBIDDEN`, and a reload of the user's grants below it revokes the scope
286
+ on their sockets. Without it a self list opens for any signed-in user.
169
287
  - A hand-written method that reads many rows authorizes the parent
170
288
  (`{ scope: "Read", of: project, id: "projectId" }`) and filters by it, or
171
289
  uses the read/write kit's `list` or a collection, which filter by policy.
@@ -194,7 +312,19 @@ channel `requires`.
194
312
 
195
313
  - Change access lists and memberships with the sharing kit
196
314
  (`sharing.contract({ mode: "acl" | "members" })`), which refuses granting
197
- above the caller's own level and removing the last `Admin`.
315
+ above the caller's own level, changing or removing a user above it (equal
316
+ may; roles that map to one level are peers, so protect an owner with an
317
+ owner column), and removing the last `Admin`. Its `resolveUser` returns
318
+ exactly one user or `null`: an email with `findUnique`, a name only when
319
+ `findMany({ where: { name }, take: 2 })` finds one; never `findFirst` by
320
+ name. Without a
321
+ service-wide `Admin` grant, the read/write and admin kits refuse a
322
+ membership table's entry, user and role columns, whichever policy reads
323
+ them. They also refuse a column the policy reads. That holds even for a
324
+ column an anchored collection scopes by.
325
+ - Give a bulk method (`bulkUpdate`, `bulkDelete`) a form with a row level,
326
+ or the same form as `update` or `delete`: under a form that names none,
327
+ it takes the counterpart's row level only from an `{ entry: L }` form.
198
328
  - Write policy columns and membership rows through the tracked `db`: the
199
329
  flush then revokes what a demoted user held (`qd:revoked`), moves them to
200
330
  their new field tier, and updates `via` collections. A write the tracked
@@ -34,22 +34,37 @@ export function Providers({ children }: { readonly children: React.ReactNode })
34
34
  (`qd.taskService.board`); the examples here key theirs short (`qd.task`).
35
35
  - `auth` is a token (sent as `auth.token`) or handshake fields; leave it out
36
36
  for cookie sessions. Changing it reconnects, and a hello naming another
37
- user empties everything quickdraw cached.
37
+ user empties everything quickdraw cached, the placeholder a member's
38
+ `useQuery` or `useSearch` would make from it (`keepPreviousData`, the
39
+ last results) included.
38
40
  - `useQuickdraw()` gives
39
41
  `{ connection, status, isConnected, isKnown, reconnecting, userId, serviceAccess, hello, refusal, isRateLimited }`.
40
42
  Gate on `isKnown` (the server's hello named the user; `userId` is then
41
43
  final, `null` meaning signed out), never on `isConnected` or `userId`
42
44
  alone: `userId` is `null` before the hello too, `isConnected` turns true
43
45
  before it and false while reconnecting, and `reconnecting` keeps the user.
46
+ While `isKnown` is false, `status` `"disconnected"` or `"refused"` means
47
+ signed out: the server ended the session (signed out elsewhere, revoked,
48
+ expired) or refused a connect for it, the hello is gone and quickdraw's
49
+ cache is empty; any other status is still connecting. Never keep showing
50
+ data the app holds itself once `isKnown` turns false. A server that could
51
+ not check the credentials (its session store down: the handshake is
52
+ refused `INTERNAL`) signs nobody out: the connection keeps the user and
53
+ the cache, stays `connecting` (`reconnecting` once it had connected) and
54
+ connects again by itself, so never treat that as a sign-out. On the
55
+ server, `authenticate` refuses credentials only by throwing
56
+ `QuickdrawError("UNAUTHENTICATED")` (or `"FORBIDDEN"`); anything else it
57
+ throws is an outage.
44
58
  `hello.serverId` changes when the server restarts (or the socket lands
45
59
  on another node): compare it to tell a new server from a network blip.
46
60
  - Sign-in and out in the browser go through the auth routes kit's routes:
47
61
  `authProviders()` lists the sign-ins the API serves (render a button for
48
62
  those only, never from build-time flags), `signInUrl(provider, {
49
63
  returnTo })` is the provider button's link, `signOut()` and
50
- `signOutEverywhere()` end the session (they reject when refused). Never
51
- call `/auth/...` by hand; 4.x's `getOAuthUrl`, `logout` and
52
- `logoutAllDevices` are gone.
64
+ `signOutEverywhere()` end the session (they reject when refused); then
65
+ call `connection.close()` and `open()` (or reload), since a socket keeps
66
+ its user until it connects again. Never call `/auth/...` by hand; 4.x's
67
+ `getOAuthUrl`, `logout` and `logoutAllDevices` are gone.
53
68
 
54
69
  ## Reading
55
70
 
@@ -151,6 +166,35 @@ await rename.mutateAsync({ id, title }); // resolves with the output, rejects wi
151
166
  the user's say). Never join from a plain `useQuery` or a
152
167
  mount effect: a reconnect leaves the socket out of the room while the
153
168
  cached answer still looks fine. Without React, `connection.onHello`.
169
+ - Peer links: on a server created with `peers: true`,
170
+ `createPeerLink(connection, { room, to, offerer })` (in React,
171
+ `useQuickdraw().connection`) links two sockets of an app room over
172
+ WebRTC data channels, or over the server's relay when those cannot form.
173
+ The peer's socket id comes from the app (an event carrying
174
+ `ctx.socketId`, a method's answer); `onPeerRequest(connection, listener)`
175
+ answers a peer that linked first. Send input at a tick rate on `fast`
176
+ (unordered, never resent) and state on `rel` (ordered, reliable). A link
177
+ dies with its socket: open it again on every connection, as `useJoin`
178
+ joins again. What a peer sends is the peer's word, never checked by the
179
+ server: parse each message with its schema as untrusted input. Never
180
+ send `qd:peer` or `qd:relay` by hand (`no-raw-socket`). To play over a
181
+ bad network in a browser test or a bench, `link.setLag({ ms, jitter,
182
+ loss })` lags the link's own messages both ways on its real path (`rel`
183
+ never lost, `stats.simulatedLoss` for the rest); never a proxy or a
184
+ hand-made delay around `send`.
185
+ - Host mode: a player's `useStream` and `useChannel` of a host-served
186
+ member (`serve: "host"`) reach the room's host over a link, with no
187
+ change in the app; a channel's messages go on `fast`, so send input
188
+ every frame, never once. The host's client serves the members with
189
+ `defineHost(contract, { channels, streams }).serve(connection, { room })`
190
+ while `hostOf(connection, room)` is its own socket's id
191
+ (`onHostChange(connection, listener)` hears it change), and calls
192
+ `close()` when it is not. A host checks what players send it against the
193
+ contract, but each message stays that player's word, and what the host
194
+ sends is the host's word. Each of `host.peers` (and a handler's
195
+ `ctx.peer`) has `link`: read a player's round trip from
196
+ `link.clock.rtt`, and give the host's own play its players' lag with
197
+ `setLag` on the host's own entry, whose link is in process.
154
198
  - Admin screens: `qd.task.admin.adminList.useQuery(input)` and the other
155
199
  admin kit members; `useAdminServices(qd)` lists the services whose
156
200
  `adminMeta` answers the user, asking only those the hello's grants allow
@@ -158,9 +202,14 @@ await rename.mutateAsync({ id, title }); // resolves with the output, rejects wi
158
202
  `adminMeta` wider). A screen served from metadata for every service takes
159
203
  `adminOf(qd, key)`, one shape typed by field name: never cast
160
204
  `qd[key].admin`. The kit's rows are not live: after the screen's own
161
- write, `onSuccess: () => qd.invalidate(qd.task.admin.adminList)`. A
162
- generic form shows the fields whose `showInForm` is not `false`; a user's
163
- grants field says `kind: "grants"`: find it by that, never by its name.
205
+ write, read the page again (`qd.invalidate(qd.task.admin.adminList)`)
206
+ and put the row the write returned in the cache
207
+ (`qd.task.admin.adminGet.setData({ id: row.id }, row)`; `adminOf`'s
208
+ query members have `setData` too), never in React state. Its reads of
209
+ rows default to `staleTime: 0`, read again on every mount (`adminMeta`
210
+ stays cached): pass a `staleTime` only to keep a page longer. A generic
211
+ form shows the fields whose `showInForm` is not `false`; a user's grants
212
+ field says `kind: "grants"`: find it by that, never by its name.
164
213
 
165
214
  ## Server components and other runtimes
166
215
 
@@ -171,10 +220,13 @@ await rename.mutateAsync({ id, title }); // resolves with the output, rejects wi
171
220
  `caller.task.get.prefetch(queryClient, input)` fills the keys the hooks
172
221
  read, for `dehydrate` and `HydrationBoundary`.
173
222
  - React Native uses the same client and provider (no DOM is needed).
174
- React-free code (a Node script, a worker) makes a connection with
223
+ React-free code (a Node script, a CLI, a worker) imports
224
+ `@fitzzero/quickdraw-core/client/core`, which needs neither React nor
225
+ TanStack Query installed: it makes a connection with
175
226
  `createQuickdrawConnection({ url, auth })`, calls `open()`, and calls
176
- `callData(connection, { service: "taskService", method: "get", input })`
177
- from `./client`; `liveDataOf(connection, queryClient)` holds live rows.
227
+ `callData(connection, { service: "taskService", method: "get", input })`.
228
+ `./client` re-exports the same functions. Live rows need a TanStack
229
+ `QueryClient`: `liveDataOf(connection, queryClient)` from `./client`.
178
230
  - A client in another language (a Godot game, a native app) speaks the wire
179
231
  itself: `docs/protocol-v5.md` in the quickdraw repository is the
180
232
  specification, `examples/godot` a GDScript client written from it, and
@@ -49,7 +49,8 @@ export const task = defineContract("taskService", {
49
49
  - Methods, collections, streams, channels and events share one namespace
50
50
  (`qd.<service>.<name>` on the client). `subscribe`, `unsubscribe`, `call`,
51
51
  `then`, `useEntity`, `useEntities`, `admin` and `$`-names are reserved.
52
- A contract without `entity` is an RPC-only service.
52
+ A contract without `entity` is an RPC-only service. A service name holds
53
+ no `:` or `@` (it is part of every room name of the service).
53
54
 
54
55
  ## The service implements it
55
56
 
@@ -82,6 +83,16 @@ export const taskService = qd.defineService(task, {
82
83
  quickdraw-access.md), `timeoutMs`, and for queries `share` (`"caller"` or
83
84
  `"all"`), `ttlMs` and `version`. A service takes `kinds` too: the kinds of
84
85
  principal its methods and subscriptions admit.
86
+ - `share: "all"` runs one handler for every caller, with the first caller's
87
+ `ctx`, and gives every caller its result: use it only for a result that
88
+ depends on the input alone. Its handler reads nothing of the caller
89
+ (`ctx.principal`, `ctx.services`, `ctx.mcp`, `ctx.access`, or a context
90
+ field made from them); in development a read of one of the first four warns
91
+ `[quickdraw:shared-principal]`. It is refused with `custom` access and on
92
+ the kit methods whose result depends on the caller (the read/write kit's
93
+ `list` and `getMany`, `search`, the sharing kit's `listShares` and
94
+ `listMembers`, the admin kit's `adminList` and `adminGet`): give those
95
+ `share: "caller"`.
85
96
  - A handler receives `{ input, ctx, db }`: the parsed input, the context
86
97
  (`principal`, `signal`, `log`, `requestId`, `transport`, `touch`, `rooms`,
87
98
  `presence`, `mcp`, `services`) and the tracked client.
@@ -100,6 +111,10 @@ export const taskService = qd.defineService(task, {
100
111
  and is cancelled with `ctx.signal`; it is typed by the app's `contracts`
101
112
  (`initQuickdraw<{ ...; contracts }>()`). Never write another service's
102
113
  model directly to skip its access checks.
114
+ - A call through `ctx.services` carries this call's `ctx.mcp`, nested calls
115
+ too, so in an MCP call the method it reaches sees the token's fields. Its
116
+ `ctx.transport` is `"internal"`: tell a token's call apart by `ctx.mcp`,
117
+ never by `ctx.transport === "mcp"`.
103
118
 
104
119
  ## REST routes call the services
105
120
 
@@ -135,6 +150,35 @@ principal that carries `serviceAccess` keeps exactly those grants. Never
135
150
  read `req.userId` through a cast, and never build a principal with grants
136
151
  by hand to get past a check.
137
152
 
153
+ Authentication goes through the auth routes kit's helpers, which check the
154
+ session store on every call, so a signed-out or revoked session stops at
155
+ once:
156
+
157
+ - `socketAuth(...)` is `createServer`'s `authenticate`: the session cookie
158
+ from an allowed page (the Origin rule), or a bearer token.
159
+ - `requireSession(keys)` guards the app's own REST routes.
160
+ - `bearerAuth(...)` is for a hook handed a bearer token alone: the MCP
161
+ bridge's `principal`, an `authenticate` that reads `auth.token`.
162
+ - An app's own MCP transport, or an in-process assistant, that signs the
163
+ caller in itself (with `requireSession`, say) passes that caller to the
164
+ registry: `registry.callMethod(service, method, input, { request, principal })`.
165
+ The registry's `principal` hook is not asked (`null` calls anonymously),
166
+ the `context` hook and bound arguments see that principal, and its grants
167
+ load as `qd.caller`'s do. Never take it from a request body, and never
168
+ smuggle the user to the hook some other way (a map keyed by the request).
169
+ - Never `verifyJWT` alone: it checks the signature and expiry, not the
170
+ session store.
171
+ - A custom `authenticate` takes the session cookie only from
172
+ `request.sessionCookie` (HTTP), after `cookieOriginAllowed(request,
173
+ allowedOrigins)`, and checks it with `liveSession`; `auth.token` holds
174
+ only a bearer token for it.
175
+ - `authenticate` refuses credentials only by throwing
176
+ `QuickdrawError("UNAUTHENTICATED")` (or `"FORBIDDEN"`). Anything else it
177
+ or `loadServiceAccess` throws is an outage (the store or the database
178
+ down): a socket's handshake is refused `INTERNAL`, and the client keeps
179
+ the user and connects again by itself. Never throw a plain `Error` for
180
+ bad credentials, which the client would retry for ever.
181
+
138
182
  ## Writes are tracked; frames are derived
139
183
 
140
184
  Entity frames, collection deltas and change topics are computed from the
@@ -145,6 +189,16 @@ writes made through `db`, after the response is sent:
145
189
  wrap the work in `qd.run(async (ctx) => ...)`, which flushes before it
146
190
  returns; a boot-time seed may run it before `createServer` too (its writes
147
191
  then reach no subscriber). Never write through the untracked client.
192
+ - A helper that writes takes the tracked client by its type:
193
+ `client: Tracked<PrismaClient>`, or `TrackedTransaction<PrismaClient>`
194
+ when it may run inside `db.$transaction(async (tx) => ...)` (`tx` is
195
+ one, and so is `db`). `trackPrisma` returns a `Tracked<PrismaClient>`;
196
+ the untracked client is none, so passing it is a type error. Both types
197
+ are on `@fitzzero/quickdraw-core/prisma` and the root export. Never type
198
+ such a parameter `PrismaClient`, which the untracked client is too;
199
+ `no-untracked-write` reads the type, whatever the parameter's name. A
200
+ `db` declared `PrismaClient` (a lazy `Proxy`, say) carries no mark:
201
+ declare it `Tracked<PrismaClient>` to pass it to such a helper.
148
202
  - Background work a handler starts and does not await (a push sent after
149
203
  the reply) runs in `qd.run(fn, { detached: true })`: a unit of its own,
150
204
  flushed when `fn` settles. Without `detached` it joins the handler's unit,
@@ -157,7 +211,10 @@ writes made through `db`, after the response is sent:
157
211
  `db.$transaction(async (tx) => ...)` when they must commit together.
158
212
  - Raw SQL writes and database cascades are not seen: record them with
159
213
  `ctx.touch("task", ids)` (`{ removed: true }` for deleted rows); `qd.run`
160
- passes `fn` the same `touch`.
214
+ passes `fn` the same `touch`. Before deleting a row whose dependents the
215
+ database deletes or clears (`onDelete: Cascade`, `SetNull`), delete or
216
+ clear them through `db` first, in the same transaction, so the lists
217
+ holding them are told; for the admin kit, in its `prepare`.
161
218
  - A write to one row that changes how another service's row looks (a
162
219
  parent's counts) declares `affects: [{ service: task, id: "parentTaskId" }]`.
163
220
 
@@ -169,9 +226,16 @@ in `id`), and optionally `where`, `limit`, `maxLimit`, `index` (small fields
169
226
  sent for the whole scope), `views` (`(row, who) => boolean` over index rows)
170
227
  and `access`. The service says who may open a scope: `{ anchor: project }`
171
228
  (the level on the row the scope value names) or `{ scopeAccess: "self" }`
172
- (the subscriber's own user id). Everyone in a scope sees every item, so
173
- derive the item service's access from the anchor (`inherit`). An item that
174
- reads its `via` junction (a member count) declares
229
+ (the subscriber's own user id), plus `requires: { service: "Read" }` when
230
+ the list must need the service-wide grant its methods ask for
231
+ (`{ scopeAccess: "self", requires: { service: "Read" } }`: the user's own
232
+ rows, for holders of the Read grant); both checks then apply, and a socket
233
+ whose grants reload below it leaves the scope. Everyone in an anchored scope sees every
234
+ item, so derive the item service's access from the anchor (`inherit`); a
235
+ self scope's items follow the item service's policy, so its user gets only
236
+ the rows they may read. A `via` scope over the item service's own `members`
237
+ table counts only links whose role gives at least the collection's
238
+ `access`. An item that reads its `via` junction (a member count) declares
175
239
  `via({ model, entry, scope, refreshEntry: true })`, or the count goes stale
176
240
  in every scope but the one a membership write links.
177
241
 
@@ -201,10 +265,17 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
201
265
  `admin.contract` too, so the input schemas and MCP tools name only those):
202
266
  every other field is read-only, and a write naming one is `VALIDATION`.
203
267
  Without it the kit writes every field it shows but `id` and the
204
- timestamps, owner and foreign-key columns included.
268
+ timestamps, owner and foreign-key columns included. No kit ever writes a
269
+ relation field or a nested write (`{ connect }`, `{ update }`): either is
270
+ `VALIDATION`, whoever the caller, and `adminMeta` shows a relation field
271
+ (one a projection selects with a nested select) as read-only.
205
272
  On a users service, `admin.handlers(user, { grants: true })` lets the admin
206
- screen edit `serviceAccess` through `adminUpdate` (service-wide Admins
207
- only); never hand-write a `setServiceAccess` method for it. `adminMeta`
273
+ screen edit `serviceAccess` (or the column `auth.serviceAccessSource`
274
+ names, whatever its name) through `adminUpdate` (service-wide Admins
275
+ only); never hand-write a `setServiceAccess` method for it. A dispatcher
276
+ made without `createServer` (HTTP only, an MCP process) names that column
277
+ as `createDispatcher({ serviceAccessSource })`. Without
278
+ `grants: true` the kit hides that column. `adminMeta`
208
279
  marks that field `kind: "grants"`; an app with a grants editor of its own
209
280
  keeps it out of the generic form with
210
281
  `fieldOverrides: { serviceAccess: { showInForm: false } }`. What an admin
@@ -213,19 +284,33 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
213
284
  `onCommitted`, which runs once it committed, in a unit of its own (a
214
285
  game applying an edited definition to its running simulation, so a
215
286
  rolled-back edit is never applied); never wrap the kit's handlers.
287
+ What must happen before an edit goes in `prepare(call, ctx, db)`, in
288
+ the same transaction: a column the server computes (a content hash) is
289
+ returned from it and written over the caller's data, even a hidden or
290
+ read-only one (never `id`, a timestamp or a relation; a parent it sets
291
+ needs the caller's row level on it, as in the read/write kit), so it
292
+ need not be an editable field of the entity; and before an `adminDelete`
293
+ it deletes or clears through `db` the rows the database would cascade,
294
+ so their subscribers and lists are told.
216
295
 
217
296
  ## Realtime
218
297
 
219
298
  - Streams: `streams: { logs: { item, scope: "taskId", seed: 50, access } }`;
220
299
  push with `qd.stream(task, "logs").push(taskId, item)`, and several items
221
- at once with `pushMany(taskId, items)`, never `push` in a loop. When the
300
+ at once with `pushMany(taskId, items)`, never `push` in a loop. A job that
301
+ skips feeds nobody watches asks
302
+ `await qd.stream(task, "logs").subscribers(taskId)` (the sockets on every
303
+ node), never Socket.IO's adapter rooms, which hold one node's. When the
222
304
  items are deltas (a game's ticks), give the service a computed seed,
223
305
  `streams: { world: { seed: (worldId, ctx) => [snapshotOf(worldId)] } }`,
224
306
  never a bootstrap call beside the stream; `validate: "development"` there
225
307
  skips the per-item schema check in production for a hot stream. A feed
226
- only a room's sockets may read takes `access: { room }` (a name,
227
- `{ prefix }`, or `(scope) => room`), never an entry policy that repeats
228
- the room's membership.
308
+ only a room's sockets may read takes `access: { room }`, never an entry
309
+ policy that repeats the room's membership: a scoped stream computes the
310
+ room from its scope, ``{ room: (worldId) => `world:${worldId}` }``, and
311
+ only a global stream names it or takes `{ prefix }` (on a scoped stream
312
+ those would let a socket in one world read every world, so
313
+ `defineContract` refuses them).
229
314
  - A query over a model the service only `writes` (no service owns it)
230
315
  declares `watch: { service: ["gameScore"] }`, naming the models it reads
231
316
  (the service's `model` and its `writes`), with `watchAccess` on the
@@ -241,19 +326,65 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
241
326
  computes it, `{ room: { prefix: "world:" } }` takes any room with that
242
327
  prefix. The handler reads the matched room as `ctx.room`; never repeat a
243
328
  world's id in every input frame. 4.x's `requireRoom` becomes `{ room }`.
329
+ Give every channel a `requires`: without one any signed-in user may send
330
+ it, about any room its payload names, and the server warns
331
+ `channel-without-requires`. A handler that relays a message emits to
332
+ `ctx.room`, never to a room built from the payload. Only when any
333
+ signed-in user may send it, say so on the service:
334
+ `channels: { ping: { access: "authenticated", handler } }`.
244
335
  - A game loop asks `qd.rooms.size(room)` (this node's sockets, spectators
245
336
  included, synchronous) at its tick rate, never Socket.IO's adapter;
246
337
  `ctx.socketId` names the calling socket in a method.
338
+ - Peer links: `createServer({ peers: true })` lets two clients of an app
339
+ room link directly (`createPeerLink` on the client). The server forwards
340
+ their signals and relays their messages only between signed-in sockets
341
+ of the room, and ends a player's links when they leave it or
342
+ `rooms.leave(room, { userId })` takes them out. Tell players each other's
343
+ socket ids with an event or a method's answer (`ctx.socketId`). The
344
+ server never sees what a link carries: a method that records what a
345
+ client reports from a link (a score, a winner) checks it as untrusted
346
+ input, and its contract's `describe` says so.
347
+ - Host mode: a channel or stream declared `serve: "host"` is served by the
348
+ room's host, one player's client (`defineHost` on the client), over peer
349
+ links; the server needs `peers: true` and never handles it. A
350
+ host-served channel declares `requires: { room }` and a host-served
351
+ stream a scope and `access: { room: (scope) => room }`. Give a
352
+ host-served channel no handler and a host-served stream no options or
353
+ seed: `defineService` refuses them. Pick the host in a method,
354
+ `ctx.rooms.setHost(room)` for the calling socket (after a check of the
355
+ app's own: a seat, a role), never by election; read it with
356
+ `ctx.rooms.hostOf(room)`, clear it with `clearHost(room)`, and decide
357
+ what follows in `onHostLeave({ room, socketId, principal, reason })`.
358
+ Behind a cluster adapter, a node that dies without closing its sockets
359
+ leaves the hosts it held recorded on the other nodes until each room's
360
+ host is set or cleared again, and no `onHostLeave` runs for them. So
361
+ when the app picks a host only while `hostOf(room)` is `null`, also let
362
+ a player take over a host that stops answering: a method that calls
363
+ `setHost(room)` or `clearHost(room)` after a timeout of the app's own.
364
+ The server never sees what a host and its players send each other: what
365
+ a host reports to a method (a result, a score) is one player's word,
366
+ checked as untrusted input, and the contract's `describe` says so. The
367
+ service's `kinds` does not reach host-served members: any signed-in
368
+ socket of the room can link to the host, so guard the method that joins
369
+ the room.
247
370
  - Events: `events: { moved: { payload } }`, sent with
248
371
  `ctx.rooms.emit(room, task, "moved", payload)` to an app room
249
- (`ctx.rooms.join(room)` in a method puts the caller's socket in one).
372
+ (`ctx.rooms.join(room)` in a method puts the caller's socket in one), and
373
+ to a user's sockets with `ctx.rooms.emitToUser(userId, ...)`, never
374
+ `emit("user:...")` (refused, as are `qd:` rooms). Name app rooms with a
375
+ prefix (`doc:${id}`), never a bare 20-character id: a name with the shape
376
+ of a Socket.IO socket id is refused.
250
377
  Code that is not a handler (a game loop, a timer, a job) sends with
251
378
  `qd.rooms.emit(room, task, "moved", payload)`; never keep the `io` server
252
379
  to emit by hand.
253
380
  - When a user loses the right to a room (removed from a chat, kicked from a
254
381
  game), `await ctx.rooms.leave(room, { userId })` (or `qd.rooms.leave`)
255
382
  takes all their sockets out on every node, before anything they must not
256
- hear is sent. 4.x's "emit to the user instead of the room" workaround is
383
+ hear is sent; a sharing kit's `onChange` does it for a user a change left
384
+ below the room's join check (`change.after === null`, or with custom
385
+ `members` `levels`, a role below that level too), inside the change's
386
+ transaction (a join racing the commit can put them back until they
387
+ disconnect). 4.x's "emit to the user instead of the room" workaround is
257
388
  not needed.
258
389
  - React to a socket leaving with the service's own `onRoomLeave`
259
390
  (`defineService(game, { onRoomLeave })`, beside `methods`), never with
@@ -268,8 +399,10 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
268
399
 
269
400
  Bound every `findMany` with `take`; batch per-item reads with `in:` filters;
270
401
  filter in `where`, not after loading. Write many rows in one statement when
271
- they all get the same data (`updateMany`, `createMany`). When each row's
272
- data differs, loop over the rows inside an interactive
402
+ they all get the same data (`updateMany`, `createMany`); a large insert may
403
+ go in fixed-size `createMany` chunks, which neither `no-db-call-in-loop` nor
404
+ the N+1 warning counts. When each row's data differs, loop over the rows
405
+ inside an interactive
273
406
  `db.$transaction(async (tx) => ...)` and await one `tx.task.update(...)` by
274
407
  id per row, not an array-form `$transaction([...])`, which cannot read a
275
408
  moved row inside its batch. A projection's relation count selects the
@@ -283,9 +416,11 @@ matches no row or has nothing to write (`data: {}`) signals nothing; every
283
416
  other write signals, one that sets a column to the value it already holds
284
417
  too, so re-ensure a row with that upsert rather than by writing its values
285
418
  again.
286
- `share: "caller"` for hot queries;
287
- `versionColumn: "updatedAt"` answers "not modified" cheaply. The quickdraw
288
- lint rules enforce most of this file (`no-untracked-write`,
419
+ `share: "caller"` for hot queries (`"all"` only for a result that depends
420
+ on the input alone); `versionColumn: "updatedAt"` answers "not modified"
421
+ cheaply, for a query whose form checked the row its `id` names (an `entry`
422
+ form on `id`), or that says `rowless: true` under a form that checks no
423
+ row. The quickdraw lint rules enforce most of this file (`no-untracked-write`,
289
424
  `no-foreign-write`, `no-nested-write`, `no-raw-sql-write`, `no-manual-emit`,
290
425
  `no-unbounded-read`, `no-db-call-in-loop`, `no-load-then-filter`,
291
426
  `prefer-kit`): fix the code, not the rule. In development the server also
@@ -51,6 +51,16 @@ await app.close();
51
51
  `{ call, socket, hello, close }` once the server said hello. Pass `null`
52
52
  for an anonymous caller. Both callers are keyed by service name
53
53
  (`taskService`), where the web client uses the contract map's keys.
54
+ - Test a guard that reads `ctx.mcp` (an agent token's scopes) or
55
+ `ctx.transport` in process too, never through `app.server.dispatcher.call`:
56
+ `app.as(agent, { transport: "mcp", mcp: { scopes: [] } })` calls as an
57
+ MCP tool call arrives, its `mcp` typed by the app's
58
+ `QuickdrawTypes["mcp"]`, and `{ mcp }` alone as a call an MCP call made
59
+ through `ctx.services`. `transport` takes `"internal"` (the default),
60
+ `"http"` or `"mcp"`; a socket's call is `app.connect`'s. Bound arguments
61
+ and the registry's `principal` and `context` hooks belong to the MCP
62
+ registry: test them through
63
+ `registry.callMethod(service, method, input, { request })`.
54
64
  - `app.frames(match?)` lists every frame the server sent, each with its
55
65
  `event`, `data`, `socketId`, `userId` and `at`;
56
66
  `frames.waitFor(match, timeoutMs?)` waits for one, `frames.clear()`
@@ -157,15 +167,19 @@ await expectBudget(() => app.as(ada).taskService.list({ limit: 20 }), { name: "l
157
167
  `createTestApp({ strictWarnings: true })` (under vitest or jest) throws each
158
168
  development warning raised in that app's method calls (`n-plus-one`,
159
169
  `unbounded-read`, `oversized-response`, `nested-write`, `batch-read`,
160
- `batch-create-many`) as a `DevWarningError` where it happens, failing the
161
- test that caused it (an oversized reply fails an in-process `app.as(...)`
162
- call; over a socket or HTTP the reply was already sent, so it is logged, not
163
- thrown). Warnings outside its calls (an `ambient-write` while seeding) are
170
+ `batch-create-many`, `shared-principal`) as a `DevWarningError` where it
171
+ happens, failing the test that caused it (an oversized reply fails an
172
+ in-process `app.as(...)` call; over a socket or HTTP the reply was already
173
+ sent, so it is logged, not thrown). Warnings outside its calls (an `ambient-write` while seeding) are
164
174
  logged, and `app.close()` ends it. `tiered-field-in-output` (a method's own
165
- output schema declares a tiered field, at any depth) and
166
- `resolver-without-reads` (a `resolver` policy declares no `reads`) are
167
- thrown by `createTestApp` itself: answer `"entity"` from that method, or
168
- declare the resolver's `reads`, rather than turning strictness off.
175
+ output schema declares a tiered field, at any depth),
176
+ `resolver-without-reads` (a `resolver` policy declares no `reads`),
177
+ `untracked-access-model` (a membership or junction table without an `id`)
178
+ and `channel-without-requires` (a channel with neither `requires` nor a
179
+ declared `access`) are thrown by `createTestApp` itself: answer `"entity"`
180
+ from that method, declare the resolver's `reads`, give the table an `id`,
181
+ or give the channel a `requires` (or `access: "authenticated"` when any
182
+ signed-in user may send it), rather than turning strictness off.
169
183
  Turn it on for service suites.
170
184
  `createRecordingSink()` passed as `flushSink` records what each flush wrote.
171
185
 
@@ -95,12 +95,21 @@ guide's sections as the report sends you to them.
95
95
  `[v4-api]`: instance state, the 4.x context, `throw new Error(message)`
96
96
  in handlers (5.0 answers it with a generic `INTERNAL`: throw
97
97
  `QuickdrawError(code, message)` where the caller should see the message),
98
- channels, the server set-up (`qd.createServer`, with `legacyWire: true`
98
+ channels (each `[channel]` marker needs a `requires`, often a room a
99
+ new joining method puts the socket in: 4.x channels took any signed-in
100
+ socket's message, and 5.1 warns `channel-without-requires`), the server
101
+ set-up (`qd.createServer`, with `legacyWire: true`
99
102
  while 4.x clients remain, or `legacyWire: { aliases: { old: "new" } }`
100
103
  when you renamed a service they still call).
101
104
  A hand-built sign-in (unmarked: the codemod leaves it) moves onto the
102
105
  auth routes kit last, with its `Session` table migration (MIGRATION.md,
103
- "Hand-built auth to the auth routes kit").
106
+ "Hand-built auth to the auth routes kit"). Until then the server's
107
+ interim `authenticate` checks the app's session store on every handshake
108
+ and call, and the session cookie's `Origin` (`cookieOriginAllowed`);
109
+ never `verifyJWT` alone, which a signed-out session still passes. It
110
+ refuses by throwing `QuickdrawError("UNAUTHENTICATED")`, never a plain
111
+ `Error`, which a socket's handshake answers `INTERNAL` (an outage, which
112
+ the client retries, keeping the user).
104
113
 
105
114
  4. **Delete each marker when its item is done**, and run the codemod again:
106
115
  it changes no code a second time and rewrites the report from the markers
@@ -45,6 +45,12 @@ model Label {
45
45
  }
46
46
  ```
47
47
 
48
+ A membership or junction table (`projectMember`, `taskLabel`) gets the same
49
+ `id` and keeps its pair unique with `@@unique([projectId, userId])`, never a
50
+ composite `@@id`: the write tracker keys rows by `id`, so a table without one
51
+ is never tracked, and removing a member would never revoke them (the server
52
+ names such a table at start).
53
+
48
54
  Then, in `packages/db`, create the migration and generate the client, in
49
55
  that order: `bun run db:migrate --name add_labels` (`prisma migrate dev`,
50
56
  which writes `prisma/migrations/<time>_add_labels/migration.sql` and applies
@@ -140,14 +146,19 @@ export const labelService = qd.defineService(labelContract, {
140
146
  other model the handlers write in `writes`; return rows for projection
141
147
  outputs. Throw `QuickdrawError` with a code for expected failures.
142
148
  - A method whose input has `id` names a row: give it `{ entry: L }`.
143
- `defineService` refuses `"public"`, `"authenticated"` or `{ service: L }`
144
- below `Admin` there unless the method says `rowless: true` (every caller
145
- the form admits may reach any row, on purpose).
149
+ `defineService` refuses `"public"`, `"authenticated"`, `{ service: L }`
150
+ below `Admin`, `custom(fn)`, `{ scope, of, id }` and `{ entry, id }`
151
+ naming another key there unless the method says `rowless: true` (every
152
+ caller the form admits may reach any row, on purpose).
146
153
  - A method or service that only some kinds of principal may call says so
147
154
  with `kinds` beside `access` (`kinds: ["user"]`), within the app's
148
155
  `initQuickdraw({ kinds })`: never with a check of `ctx.principal.kind` in
149
156
  the handler or in a `custom` form. Other kinds get `FORBIDDEN`, whatever
150
157
  their grants (see quickdraw-access.md).
158
+ - A check on the caller's claims (a token's `scope`) is `claims` beside
159
+ `access` (`claims: (claims) => claims?.scope === "triage"`): it adds to
160
+ the form, and no grant passes it. Never write it as `custom`, which
161
+ replaces the row check.
151
162
  - Reach for a kit before hand-writing CRUD, search, sharing or admin; lint's
152
163
  `prefer-kit` warns on a hand-written `get`, `list`, `create` or
153
164
  `getLabel`-style method in a service that uses no kit.