@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 +6 -3
- package/bin/cli.mjs +49 -22
- package/package.json +1 -1
- package/rules/quickdraw-access.md +160 -30
- package/rules/quickdraw-client.md +62 -10
- package/rules/quickdraw-services.md +154 -19
- package/rules/quickdraw-testing.md +22 -8
- package/skills/quickdraw-migrate-v5/SKILL.md +11 -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,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`,
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
row
|
|
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
|
-
|
|
99
|
-
form is
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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)
|
|
51
|
-
call
|
|
52
|
-
|
|
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,
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
173
|
-
|
|
174
|
-
|
|
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`
|
|
207
|
-
|
|
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.
|
|
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 }
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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`)
|
|
272
|
-
|
|
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"
|
|
288
|
-
|
|
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
|
|
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
|
|
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)
|
|
166
|
-
`resolver-without-reads` (a `resolver` policy declares no `reads`)
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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"
|
|
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.
|