@fitzzero/quickdraw-skills 5.1.1 → 5.1.2

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.2",
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,80 @@ 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).
77
111
 
78
112
  ## A method that takes a row id
79
113
 
80
114
  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:
115
+ the row that id names. Under a form that asks the policy nothing about that
116
+ row, anyone the form admits reaches any row by its id, so `defineService`
117
+ refuses it when the service is defined. Those forms are the ones that check
118
+ no row (`"public"`, `"authenticated"`, `{ service: L }` below `Admin`),
119
+ `custom(fn)`, `{ scope, of, id }` (a row of another service) and
120
+ `{ entry, id }` naming another key. Fix the form, not the error:
85
121
 
86
122
  - Give it `{ entry: L }` (`"Read"` for a read, `"Moderate"` for a change),
87
123
  or `{ service: L, entry: L }` to keep a service grant: the policy decides.
88
124
  - 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.
125
+ public profile, a lookup by an id that tells nothing), or when `id` names
126
+ a row the method creates (an id the client made, under a `scope` form),
127
+ say so with `rowless: true` on the method, or `rowless: ["get"]` in a
128
+ kit's options (`crud.handlers`, `admin.handlers`, `sharing.handlers`). A
129
+ `rowless` query under a form that checks no row also answers "not
130
+ modified" for any `id`.
131
+ - The read/write kit's `update`, `delete` and `reorder` check the row
132
+ themselves and need neither. `create` makes a new row and checks no row of
133
+ its own: it checks the anchors it writes (each `inherit` parent and
134
+ anchored collection's scope column, from what `prepare` returns) at the
135
+ method's row level, and takes a column the policy reads only from
136
+ `prepare` (`ownerId: ctx.principal.userId`), unless the caller holds a
137
+ service-wide `Admin` grant. Give it
138
+ `{ scope: "Moderate", of: project, id: "projectId" }`.
94
139
  - The check reads the input's keys from its JSON Schema: an `id` in any
95
140
  branch of a union counts, as does one beside a `Date` or a `Set`. An input
96
141
  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).
142
+ `todoSchema` without `keys`), a bare string that is the id itself, a row
143
+ named by another key (`taskId`, `ids`) on an input without `id`, and an
144
+ `entry` form whose `id` is a function are not checked, so the form is all
145
+ yours there (`{ entry: L, id: "taskId" }` names another key).
100
146
 
101
147
  ## Service-wide grants
102
148
 
@@ -110,8 +156,9 @@ its first call. With
110
156
  tracked write to that column refreshes the user's open sockets, and an
111
157
  in-process caller loads the grants again at its next call.
112
158
 
113
- - A service-wide `Admin` grant passes every check on its service
114
- (`adminBypass: false` on the service turns that off).
159
+ - A service-wide `Admin` grant passes every access form on its service
160
+ (`adminBypass: false` on the service turns that off), though not `kinds`
161
+ or `claims`.
115
162
  - A grant below `Admin` counts only where the form names `service`: a `Read`
116
163
  grant does not read every row through an `entry` form.
117
164
 
@@ -153,7 +200,23 @@ compile time.
153
200
  job, alone or in `anyOf`.
154
201
  - Lookups are batched per call: checking 60 ids costs what one does.
155
202
  `createServer({ access: { cacheMs } })` keeps them across calls; tracked
156
- writes to the columns and tables a policy reads evict them.
203
+ writes to the columns and tables a policy reads evict them. A lookup
204
+ inside a transaction always reads the database.
205
+ - Membership and junction tables need an `id` column
206
+ (`id String @id @default(cuid())`, the pair unique by `@@unique`), never a
207
+ composite `@@id`: the write tracker keys rows by `id`, so such a table is
208
+ never tracked and a removed member is never revoked. `createServer` logs
209
+ `[quickdraw:untracked-access-model]` as an error for one, and a strict
210
+ test app fails to start. A foreign key set through a relation on the
211
+ written row (`task.update({ data: { project: { connect } } })`) revokes
212
+ like a scalar one; one set from the parent's side
213
+ (`project.update({ data: { tasks: { connect } } })`) is a nested write
214
+ whose rows are not tracked: write the foreign key on the row itself.
215
+ - Never pass Socket.IO's `connectionStateRecovery` in `createServer({ socket })`:
216
+ a recovered socket comes back with its principal and rooms without
217
+ authenticating again, so a revoked session or a lowered grant would
218
+ return. `createServer` refuses it; clients resume by revision on every
219
+ reconnect.
157
220
 
158
221
  ## One policy, every surface
159
222
 
@@ -162,10 +225,12 @@ collection scopes (through the collection's `anchor`), the rows the kits'
162
225
  `list`, `getMany`, `search` and bulk methods return or write, streams and
163
226
  channel `requires`.
164
227
 
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.
228
+ - Everyone who may open an anchored collection scope sees every item in it,
229
+ stripped at the collection's `access` level (default `Read`). Per-row
230
+ policies and field tiers do not apply inside an anchored scope: give the
231
+ item service `inherit` from the anchor, or use separate scopes. A self
232
+ scope (`scopeAccess: "self"`) is the exception: its items follow the item
233
+ service's policy, so its user gets only the rows they may read.
169
234
  - A hand-written method that reads many rows authorizes the parent
170
235
  (`{ scope: "Read", of: project, id: "projectId" }`) and filters by it, or
171
236
  uses the read/write kit's `list` or a collection, which filter by policy.
@@ -194,7 +259,19 @@ channel `requires`.
194
259
 
195
260
  - Change access lists and memberships with the sharing kit
196
261
  (`sharing.contract({ mode: "acl" | "members" })`), which refuses granting
197
- above the caller's own level and removing the last `Admin`.
262
+ above the caller's own level, changing or removing a user above it (equal
263
+ may; roles that map to one level are peers, so protect an owner with an
264
+ owner column), and removing the last `Admin`. Its `resolveUser` returns
265
+ exactly one user or `null`: an email with `findUnique`, a name only when
266
+ `findMany({ where: { name }, take: 2 })` finds one; never `findFirst` by
267
+ name. Without a
268
+ service-wide `Admin` grant, the read/write and admin kits refuse a
269
+ membership table's entry, user and role columns, whichever policy reads
270
+ them. They also refuse a column the policy reads. That holds even for a
271
+ column an anchored collection scopes by.
272
+ - Give a bulk method (`bulkUpdate`, `bulkDelete`) a form with a row level,
273
+ or the same form as `update` or `delete`: under a form that names none,
274
+ it takes the counterpart's row level only from an `{ entry: L }` form.
198
275
  - Write policy columns and membership rows through the tracked `db`: the
199
276
  flush then revokes what a demoted user held (`qd:revoked`), moves them to
200
277
  their new field tier, and updates `via` collections. A write the tracked
@@ -41,15 +41,21 @@ export function Providers({ children }: { readonly children: React.ReactNode })
41
41
  final, `null` meaning signed out), never on `isConnected` or `userId`
42
42
  alone: `userId` is `null` before the hello too, `isConnected` turns true
43
43
  before it and false while reconnecting, and `reconnecting` keeps the user.
44
+ While `isKnown` is false, `status` `"disconnected"` or `"refused"` means
45
+ signed out: the server ended the session (signed out elsewhere, revoked,
46
+ expired) or refused a connect for it, the hello is gone and quickdraw's
47
+ cache is empty; any other status is still connecting. Never keep showing
48
+ data the app holds itself once `isKnown` turns false.
44
49
  `hello.serverId` changes when the server restarts (or the socket lands
45
50
  on another node): compare it to tell a new server from a network blip.
46
51
  - Sign-in and out in the browser go through the auth routes kit's routes:
47
52
  `authProviders()` lists the sign-ins the API serves (render a button for
48
53
  those only, never from build-time flags), `signInUrl(provider, {
49
54
  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.
55
+ `signOutEverywhere()` end the session (they reject when refused); then
56
+ call `connection.close()` and `open()` (or reload), since a socket keeps
57
+ its user until it connects again. Never call `/auth/...` by hand; 4.x's
58
+ `getOAuthUrl`, `logout` and `logoutAllDevices` are gone.
53
59
 
54
60
  ## Reading
55
61
 
@@ -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`, or a context field made from
90
+ them); in development a read of one of the first three 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.
@@ -135,6 +146,22 @@ principal that carries `serviceAccess` keeps exactly those grants. Never
135
146
  read `req.userId` through a cast, and never build a principal with grants
136
147
  by hand to get past a check.
137
148
 
149
+ Authentication goes through the auth routes kit's helpers, which check the
150
+ session store on every call, so a signed-out or revoked session stops at
151
+ once:
152
+
153
+ - `socketAuth(...)` is `createServer`'s `authenticate`: the session cookie
154
+ from an allowed page (the Origin rule), or a bearer token.
155
+ - `requireSession(keys)` guards the app's own REST routes.
156
+ - `bearerAuth(...)` is for a hook handed a bearer token alone: the MCP
157
+ bridge's `principal`, an `authenticate` that reads `auth.token`.
158
+ - Never `verifyJWT` alone: it checks the signature and expiry, not the
159
+ session store.
160
+ - A custom `authenticate` takes the session cookie only from
161
+ `request.sessionCookie` (HTTP), after `cookieOriginAllowed(request,
162
+ allowedOrigins)`, and checks it with `liveSession`; `auth.token` holds
163
+ only a bearer token for it.
164
+
138
165
  ## Writes are tracked; frames are derived
139
166
 
140
167
  Entity frames, collection deltas and change topics are computed from the
@@ -169,9 +196,12 @@ in `id`), and optionally `where`, `limit`, `maxLimit`, `index` (small fields
169
196
  sent for the whole scope), `views` (`(row, who) => boolean` over index rows)
170
197
  and `access`. The service says who may open a scope: `{ anchor: project }`
171
198
  (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
199
+ (the subscriber's own user id). Everyone in an anchored scope sees every
200
+ item, so derive the item service's access from the anchor (`inherit`); a
201
+ self scope's items follow the item service's policy, so its user gets only
202
+ the rows they may read. A `via` scope over the item service's own `members`
203
+ table counts only links whose role gives at least the collection's
204
+ `access`. An item that reads its `via` junction (a member count) declares
175
205
  `via({ model, entry, scope, refreshEntry: true })`, or the count goes stale
176
206
  in every scope but the one a membership write links.
177
207
 
@@ -201,10 +231,17 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
201
231
  `admin.contract` too, so the input schemas and MCP tools name only those):
202
232
  every other field is read-only, and a write naming one is `VALIDATION`.
203
233
  Without it the kit writes every field it shows but `id` and the
204
- timestamps, owner and foreign-key columns included.
234
+ timestamps, owner and foreign-key columns included. No kit ever writes a
235
+ relation field or a nested write (`{ connect }`, `{ update }`): either is
236
+ `VALIDATION`, whoever the caller, and `adminMeta` shows a relation field
237
+ (one a projection selects with a nested select) as read-only.
205
238
  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`
239
+ screen edit `serviceAccess` (or the column `auth.serviceAccessSource`
240
+ names, whatever its name) through `adminUpdate` (service-wide Admins
241
+ only); never hand-write a `setServiceAccess` method for it. A dispatcher
242
+ made without `createServer` (HTTP only, an MCP process) names that column
243
+ as `createDispatcher({ serviceAccessSource })`. Without
244
+ `grants: true` the kit hides that column. `adminMeta`
208
245
  marks that field `kind: "grants"`; an app with a grants editor of its own
209
246
  keeps it out of the generic form with
210
247
  `fieldOverrides: { serviceAccess: { showInForm: false } }`. What an admin
@@ -223,9 +260,12 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
223
260
  `streams: { world: { seed: (worldId, ctx) => [snapshotOf(worldId)] } }`,
224
261
  never a bootstrap call beside the stream; `validate: "development"` there
225
262
  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.
263
+ only a room's sockets may read takes `access: { room }`, never an entry
264
+ policy that repeats the room's membership: a scoped stream computes the
265
+ room from its scope, ``{ room: (worldId) => `world:${worldId}` }``, and
266
+ only a global stream names it or takes `{ prefix }` (on a scoped stream
267
+ those would let a socket in one world read every world, so
268
+ `defineContract` refuses them).
229
269
  - A query over a model the service only `writes` (no service owns it)
230
270
  declares `watch: { service: ["gameScore"] }`, naming the models it reads
231
271
  (the service's `model` and its `writes`), with `watchAccess` on the
@@ -241,19 +281,33 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
241
281
  computes it, `{ room: { prefix: "world:" } }` takes any room with that
242
282
  prefix. The handler reads the matched room as `ctx.room`; never repeat a
243
283
  world's id in every input frame. 4.x's `requireRoom` becomes `{ room }`.
284
+ Give every channel a `requires`: without one any signed-in user may send
285
+ it, about any room its payload names, and the server warns
286
+ `channel-without-requires`. A handler that relays a message emits to
287
+ `ctx.room`, never to a room built from the payload. Only when any
288
+ signed-in user may send it, say so on the service:
289
+ `channels: { ping: { access: "authenticated", handler } }`.
244
290
  - A game loop asks `qd.rooms.size(room)` (this node's sockets, spectators
245
291
  included, synchronous) at its tick rate, never Socket.IO's adapter;
246
292
  `ctx.socketId` names the calling socket in a method.
247
293
  - Events: `events: { moved: { payload } }`, sent with
248
294
  `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).
295
+ (`ctx.rooms.join(room)` in a method puts the caller's socket in one), and
296
+ to a user's sockets with `ctx.rooms.emitToUser(userId, ...)`, never
297
+ `emit("user:...")` (refused, as are `qd:` rooms). Name app rooms with a
298
+ prefix (`doc:${id}`), never a bare 20-character id: a name with the shape
299
+ of a Socket.IO socket id is refused.
250
300
  Code that is not a handler (a game loop, a timer, a job) sends with
251
301
  `qd.rooms.emit(room, task, "moved", payload)`; never keep the `io` server
252
302
  to emit by hand.
253
303
  - When a user loses the right to a room (removed from a chat, kicked from a
254
304
  game), `await ctx.rooms.leave(room, { userId })` (or `qd.rooms.leave`)
255
305
  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
306
+ hear is sent; a sharing kit's `onChange` does it for a user a change left
307
+ below the room's join check (`change.after === null`, or with custom
308
+ `members` `levels`, a role below that level too), inside the change's
309
+ transaction (a join racing the commit can put them back until they
310
+ disconnect). 4.x's "emit to the user instead of the room" workaround is
257
311
  not needed.
258
312
  - React to a socket leaving with the service's own `onRoomLeave`
259
313
  (`defineService(game, { onRoomLeave })`, beside `methods`), never with
@@ -283,9 +337,11 @@ matches no row or has nothing to write (`data: {}`) signals nothing; every
283
337
  other write signals, one that sets a column to the value it already holds
284
338
  too, so re-ensure a row with that upsert rather than by writing its values
285
339
  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`,
340
+ `share: "caller"` for hot queries (`"all"` only for a result that depends
341
+ on the input alone); `versionColumn: "updatedAt"` answers "not modified"
342
+ cheaply, for a query whose form checked the row its `id` names (an `entry`
343
+ form on `id`), or that says `rowless: true` under a form that checks no
344
+ row. The quickdraw lint rules enforce most of this file (`no-untracked-write`,
289
345
  `no-foreign-write`, `no-nested-write`, `no-raw-sql-write`, `no-manual-emit`,
290
346
  `no-unbounded-read`, `no-db-call-in-loop`, `no-load-then-filter`,
291
347
  `prefer-kit`): fix the code, not the rule. In development the server also
@@ -157,15 +157,19 @@ await expectBudget(() => app.as(ada).taskService.list({ limit: 20 }), { name: "l
157
157
  `createTestApp({ strictWarnings: true })` (under vitest or jest) throws each
158
158
  development warning raised in that app's method calls (`n-plus-one`,
159
159
  `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
160
+ `batch-create-many`, `shared-principal`) as a `DevWarningError` where it
161
+ happens, failing the test that caused it (an oversized reply fails an
162
+ in-process `app.as(...)` call; over a socket or HTTP the reply was already
163
+ sent, so it is logged, not thrown). Warnings outside its calls (an `ambient-write` while seeding) are
164
164
  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.
165
+ output schema declares a tiered field, at any depth),
166
+ `resolver-without-reads` (a `resolver` policy declares no `reads`),
167
+ `untracked-access-model` (a membership or junction table without an `id`)
168
+ and `channel-without-requires` (a channel with neither `requires` nor a
169
+ declared `access`) are thrown by `createTestApp` itself: answer `"entity"`
170
+ from that method, declare the resolver's `reads`, give the table an `id`,
171
+ or give the channel a `requires` (or `access: "authenticated"` when any
172
+ signed-in user may send it), rather than turning strictness off.
169
173
  Turn it on for service suites.
170
174
  `createRecordingSink()` passed as `flushSink` records what each flush wrote.
171
175
 
@@ -95,12 +95,18 @@ 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.
104
110
 
105
111
  4. **Delete each marker when its item is done**, and run the codemod again:
106
112
  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.