@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 +6 -3
- package/bin/cli.mjs +49 -22
- package/package.json +1 -1
- package/rules/quickdraw-access.md +107 -30
- package/rules/quickdraw-client.md +9 -3
- package/rules/quickdraw-services.md +71 -15
- package/rules/quickdraw-testing.md +12 -8
- package/skills/quickdraw-migrate-v5/SKILL.md +8 -2
- package/skills/quickdraw-new-service/SKILL.md +14 -3
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
49
|
-
|
|
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
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 =
|
|
149
|
+
const repoRoot = findProjectRoot(process.cwd());
|
|
129
150
|
if (repoRoot === undefined) {
|
|
130
|
-
fail(
|
|
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
|
|
136
|
-
* path,
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
@@ -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`,
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
row
|
|
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
|
-
|
|
99
|
-
form is
|
|
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
|
|
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
|
|
166
|
-
at the collection's `access` level (default `Read`). Per-row
|
|
167
|
-
field tiers do not apply inside
|
|
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
|
|
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)
|
|
51
|
-
call
|
|
52
|
-
|
|
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
|
|
173
|
-
derive the item service's access from the anchor (`inherit`)
|
|
174
|
-
|
|
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`
|
|
207
|
-
|
|
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 }
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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"
|
|
288
|
-
|
|
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
|
|
161
|
-
test that caused it (an oversized reply fails an
|
|
162
|
-
call; over a socket or HTTP the reply was already
|
|
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)
|
|
166
|
-
`resolver-without-reads` (a `resolver` policy declares no `reads`)
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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"
|
|
144
|
-
below `Admin`
|
|
145
|
-
|
|
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.
|