@fitzzero/quickdraw-core 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 +1308 -219
- package/dist/{admin-DKwkIh5i.d.ts → admin-CS7JB_UH.d.ts} +2 -2
- package/dist/{adminSchemas-2DUA_hZ8.d.ts → adminSchemas-29cGgrg5.d.ts} +6 -4
- package/dist/{auth-BhWqdyb-.d.ts → auth-DtdYjrZ9.d.ts} +51 -21
- package/dist/{caller-CXTFswBO.d.ts → caller-BTwGl7vm.d.ts} +296 -33
- package/dist/chunk-2FDSKLBO.js +82 -0
- package/dist/chunk-2FDSKLBO.js.map +1 -0
- package/dist/{chunk-RT6OILOI.js → chunk-4D2DOGAW.js} +2 -14
- package/dist/chunk-4D2DOGAW.js.map +1 -0
- package/dist/{chunk-OECZPRPW.js → chunk-64I6MYBC.js} +162 -775
- package/dist/chunk-64I6MYBC.js.map +1 -0
- package/dist/chunk-7473HK24.js +17 -0
- package/dist/chunk-7473HK24.js.map +1 -0
- package/dist/chunk-7Y5ROPXL.js +33 -0
- package/dist/chunk-7Y5ROPXL.js.map +1 -0
- package/dist/chunk-A2RBQD66.js +7 -0
- package/dist/chunk-A2RBQD66.js.map +1 -0
- package/dist/chunk-AK23XSWD.js +16 -0
- package/dist/chunk-AK23XSWD.js.map +1 -0
- package/dist/chunk-ASAMUGHG.js +651 -0
- package/dist/chunk-ASAMUGHG.js.map +1 -0
- package/dist/{chunk-B2P7AZ25.js → chunk-CYE4UBOB.js} +6 -4
- package/dist/chunk-CYE4UBOB.js.map +1 -0
- package/dist/chunk-DKE6QO26.js +136 -0
- package/dist/chunk-DKE6QO26.js.map +1 -0
- package/dist/chunk-DNP65TD5.js +832 -0
- package/dist/chunk-DNP65TD5.js.map +1 -0
- package/dist/{chunk-H23KEFBA.js → chunk-DUBU3BB3.js} +30 -1
- package/dist/chunk-DUBU3BB3.js.map +1 -0
- package/dist/chunk-GYLWEJHW.js +19 -0
- package/dist/chunk-GYLWEJHW.js.map +1 -0
- package/dist/{chunk-SHEGLZEP.js → chunk-I3JCFS7H.js} +34 -13
- package/dist/chunk-I3JCFS7H.js.map +1 -0
- package/dist/{chunk-X5CWPX7H.js → chunk-IIQ3WMJK.js} +13 -2
- package/dist/chunk-IIQ3WMJK.js.map +1 -0
- package/dist/{chunk-RZTP4IEW.js → chunk-JVTONQFI.js} +6339 -4226
- package/dist/chunk-JVTONQFI.js.map +1 -0
- package/dist/{chunk-QEKVYG5F.js → chunk-KKQANRLG.js} +2 -2
- package/dist/chunk-KKQANRLG.js.map +1 -0
- package/dist/{chunk-CDKG4TFA.js → chunk-MPQAT55F.js} +38 -205
- package/dist/chunk-MPQAT55F.js.map +1 -0
- package/dist/chunk-NO4M6PO5.js +101 -0
- package/dist/chunk-NO4M6PO5.js.map +1 -0
- package/dist/{chunk-TBMKJ26R.js → chunk-NW6U45HX.js} +3 -1
- package/dist/chunk-NW6U45HX.js.map +1 -0
- package/dist/chunk-NX2V6SIL.js +202 -0
- package/dist/chunk-NX2V6SIL.js.map +1 -0
- package/dist/{chunk-ZIVPHYRG.js → chunk-NZHA4TZD.js} +72 -175
- package/dist/chunk-NZHA4TZD.js.map +1 -0
- package/dist/{chunk-2PWMUVC7.js → chunk-OCZJ4EYV.js} +518 -27
- package/dist/chunk-OCZJ4EYV.js.map +1 -0
- package/dist/{chunk-REX5YPHX.js → chunk-PHCURHC7.js} +76 -21
- package/dist/chunk-PHCURHC7.js.map +1 -0
- package/dist/{chunk-4YO5H5ON.js → chunk-Q4OGQM5I.js} +4 -10
- package/dist/chunk-Q4OGQM5I.js.map +1 -0
- package/dist/chunk-QSU47XEX.js +13 -0
- package/dist/chunk-QSU47XEX.js.map +1 -0
- package/dist/{chunk-DD4SKCNO.js → chunk-RZXJTDH5.js} +13 -2
- package/dist/chunk-RZXJTDH5.js.map +1 -0
- package/dist/{chunk-Z2LZ7IZ7.js → chunk-SN3OZVEQ.js} +5 -31
- package/dist/chunk-SN3OZVEQ.js.map +1 -0
- package/dist/{chunk-4DIQHASK.js → chunk-SQ5VUG77.js} +28 -8
- package/dist/chunk-SQ5VUG77.js.map +1 -0
- package/dist/chunk-STH5DLQP.js +1192 -0
- package/dist/chunk-STH5DLQP.js.map +1 -0
- package/dist/{chunk-QCHKWIRS.js → chunk-VZFJTKIW.js} +13 -13
- package/dist/{chunk-QCHKWIRS.js.map → chunk-VZFJTKIW.js.map} +1 -1
- package/dist/{chunk-VSQBHPWH.js → chunk-X4ZU4QH5.js} +54 -22
- package/dist/chunk-X4ZU4QH5.js.map +1 -0
- package/dist/chunk-XD7SRDUY.js +188 -0
- package/dist/chunk-XD7SRDUY.js.map +1 -0
- package/dist/chunk-XH2IZVLT.js +62 -0
- package/dist/chunk-XH2IZVLT.js.map +1 -0
- package/dist/chunk-Z7I6SONO.js +825 -0
- package/dist/chunk-Z7I6SONO.js.map +1 -0
- package/dist/cli/quickdraw-docs.js +132 -112
- package/dist/cli/quickdraw-docs.js.map +1 -1
- package/dist/client/core.d.ts +387 -0
- package/dist/client/core.js +109 -0
- package/dist/client/index.d.ts +52 -203
- package/dist/client/index.js +59 -218
- package/dist/client/index.js.map +1 -1
- package/dist/{clientTypes-Bc-G8n-U.d.ts → clientTypes-CUUNJwWs.d.ts} +3 -3
- package/dist/{connection-Cr1BU07W.d.ts → connection-2EeQ_Et0.d.ts} +22 -11
- package/dist/{createServer-CP9eDHZZ.d.ts → createServer-Bx5HOcYK.d.ts} +67 -17
- package/dist/{createTestApp-UKVGEmOf.d.ts → createTestApp-DAMANT79.d.ts} +46 -13
- package/dist/{envelope-Cp3YcVxC.d.ts → envelope-BMBaxppV.d.ts} +216 -10
- package/dist/{http-Djj2Inuu.d.ts → http-YzLAar3w.d.ts} +76 -60
- package/dist/index.d.ts +8 -7
- package/dist/index.js +82 -25
- package/dist/index.js.map +1 -1
- package/dist/{infer-Teo1xpsd.d.ts → infer-Br62cnyW.d.ts} +82 -11
- package/dist/logger-Yz93KzdV.d.ts +30 -0
- package/dist/{metrics-BdBu5o8G.d.ts → metrics-CTVpw0gu.d.ts} +2 -2
- package/dist/origins-kvpxnVcW.d.ts +8 -0
- package/dist/parser.js +2 -1
- package/dist/prisma/index.d.ts +14 -7
- package/dist/prisma/index.js +279 -70
- package/dist/prisma/index.js.map +1 -1
- package/dist/{rateLimit-ZMIZJUZF.js → rateLimit-5RKBFLUR.js} +2 -2
- package/dist/rateLimit-5RKBFLUR.js.map +1 -0
- package/dist/remote-N7OUAGOW.js +15 -0
- package/dist/remote-N7OUAGOW.js.map +1 -0
- package/dist/server/auth/index.d.ts +82 -33
- package/dist/server/auth/index.js +277 -270
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/express/index.d.ts +9 -2
- package/dist/server/express/index.js +1 -1
- package/dist/server/index.d.ts +268 -84
- package/dist/server/index.js +664 -418
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/index.d.ts +89 -27
- package/dist/server/mcp/index.js +281 -62
- package/dist/server/mcp/index.js.map +1 -1
- package/dist/server/otel.d.ts +3 -3
- package/dist/{sharing-DVa8W-yR.d.ts → sharing-BrsmlDv_.d.ts} +3 -3
- package/dist/testing/client.d.ts +14 -14
- package/dist/testing/client.js +19 -10
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/index.d.ts +50 -13
- package/dist/testing/index.js +113 -21
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock.d.ts +5 -5
- package/dist/testing/mock.js +17 -9
- package/dist/tracked-BR5b2O-U.d.ts +43 -0
- package/dist/{types-Cu0vmx7c.d.ts → types-CLRCQGnO.d.ts} +4 -3
- package/dist/types-tMagHbjy.d.ts +151 -0
- package/dist/utils/index.d.ts +16 -4
- package/dist/utils/index.js +6 -3
- package/package.json +5 -1
- package/dist/chunk-2PWMUVC7.js.map +0 -1
- package/dist/chunk-4DIQHASK.js.map +0 -1
- package/dist/chunk-4YO5H5ON.js.map +0 -1
- package/dist/chunk-B2P7AZ25.js.map +0 -1
- package/dist/chunk-CDKG4TFA.js.map +0 -1
- package/dist/chunk-DD4SKCNO.js.map +0 -1
- package/dist/chunk-H23KEFBA.js.map +0 -1
- package/dist/chunk-OECZPRPW.js.map +0 -1
- package/dist/chunk-QEKVYG5F.js.map +0 -1
- package/dist/chunk-REX5YPHX.js.map +0 -1
- package/dist/chunk-RT6OILOI.js.map +0 -1
- package/dist/chunk-RZTP4IEW.js.map +0 -1
- package/dist/chunk-SHEGLZEP.js.map +0 -1
- package/dist/chunk-TBMKJ26R.js.map +0 -1
- package/dist/chunk-VSQBHPWH.js.map +0 -1
- package/dist/chunk-X5CWPX7H.js.map +0 -1
- package/dist/chunk-YSWH776Y.js +0 -13
- package/dist/chunk-YSWH776Y.js.map +0 -1
- package/dist/chunk-Z2LZ7IZ7.js.map +0 -1
- package/dist/chunk-ZIVPHYRG.js.map +0 -1
- package/dist/logger-BY28acW-.d.ts +0 -16
- /package/dist/{rateLimit-ZMIZJUZF.js.map → client/core.js.map} +0 -0
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ entries you import need.
|
|
|
48
48
|
| `./server/otel` | `@opentelemetry/api` |
|
|
49
49
|
| `./prisma` | `@prisma/client` 7 |
|
|
50
50
|
| `./client` | `socket.io-client`, `@tanstack/react-query` 5, `react` 19 |
|
|
51
|
+
| `./client/core` | `socket.io-client` |
|
|
51
52
|
| `./utils`, `./parser` | nothing more |
|
|
52
53
|
| `./testing` | `socket.io`, `socket.io-client` |
|
|
53
54
|
| `./testing/client` | the `./client` peers, and `@testing-library/react` |
|
|
@@ -157,6 +158,8 @@ export interface AppPrincipal extends Principal {
|
|
|
157
158
|
export const qd = initQuickdraw<{
|
|
158
159
|
db: typeof db;
|
|
159
160
|
principal: AppPrincipal;
|
|
161
|
+
// what the MCP bridge's context hook gives an agent's calls, as ctx.mcp
|
|
162
|
+
mcp: { readonly scopes: readonly string[] };
|
|
160
163
|
contracts: typeof contracts;
|
|
161
164
|
}>();
|
|
162
165
|
```
|
|
@@ -353,7 +356,9 @@ export const cardSchema = taskSchema.omit({ notes: true });
|
|
|
353
356
|
|
|
354
357
|
The sessions, the secret and the grants the server reads,
|
|
355
358
|
`apps/api/src/auth.ts`. Sessions are issued by the auth routes, mounted on
|
|
356
|
-
the same `app` (see the [auth routes kit](#auth-routes-kit))
|
|
359
|
+
the same `app` (see the [auth routes kit](#auth-routes-kit)). `verifySession`
|
|
360
|
+
checks a bearer token against the session store on every call
|
|
361
|
+
(`bearerAuth`), so a signed-out or revoked session stops at once:
|
|
357
362
|
|
|
358
363
|
<!-- example: apps/api/src/auth.ts -->
|
|
359
364
|
|
|
@@ -361,7 +366,7 @@ the same `app` (see the [auth routes kit](#auth-routes-kit)):
|
|
|
361
366
|
// The app's sessions, token check and grants, for the README's server examples.
|
|
362
367
|
|
|
363
368
|
import type { AccessLevel } from "@fitzzero/quickdraw-core";
|
|
364
|
-
import {
|
|
369
|
+
import { bearerAuth, createMemorySessionStore } from "@fitzzero/quickdraw-core/server/auth";
|
|
365
370
|
import { db } from "./db";
|
|
366
371
|
import type { AppPrincipal } from "./quickdraw";
|
|
367
372
|
|
|
@@ -371,14 +376,17 @@ export const jwtSecret = process.env.JWT_SECRET ?? "";
|
|
|
371
376
|
/** The sessions the auth routes issue. In production, a store over the database (see the auth routes kit). */
|
|
372
377
|
export const sessions = createMemorySessionStore();
|
|
373
378
|
|
|
374
|
-
/**
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
379
|
+
/**
|
|
380
|
+
* The agent a bearer token signs in, or `null` for no token: checked against
|
|
381
|
+
* the session store on every call, so a signed-out or revoked session stops
|
|
382
|
+
* at once. An agent, so methods with `kinds: ["user"]` refuse it. Never
|
|
383
|
+
* `verifyJWT` alone, which checks no session.
|
|
384
|
+
*/
|
|
385
|
+
export const verifySession = bearerAuth({
|
|
386
|
+
sessions,
|
|
387
|
+
jwtSecret,
|
|
388
|
+
loadPrincipal: (userId): AppPrincipal => ({ userId, kind: "agent" }),
|
|
389
|
+
});
|
|
382
390
|
|
|
383
391
|
/** A user's service-wide grants, as stored in `User.serviceAccess`. */
|
|
384
392
|
export async function loadGrants(userId: string): Promise<Record<string, AccessLevel>> {
|
|
@@ -422,6 +430,9 @@ model Project {
|
|
|
422
430
|
}
|
|
423
431
|
|
|
424
432
|
// A membership table: role holds an access level ("Read" | "Moderate" | "Admin").
|
|
433
|
+
// It has an id, and its pair is unique by @@unique: the write tracker keys
|
|
434
|
+
// rows by id, so a table keyed by a composite @@id is never tracked, and a
|
|
435
|
+
// member removed from it would never be revoked.
|
|
425
436
|
model ProjectMember {
|
|
426
437
|
id String @id @default(cuid())
|
|
427
438
|
projectId String
|
|
@@ -541,6 +552,9 @@ export const healthContract = defineContract("healthService", {
|
|
|
541
552
|
because the client exposes each as `qd.<service>.<name>`. `subscribe`,
|
|
542
553
|
`unsubscribe`, `call`, `then`, `useEntity`, `useEntities`, `admin` and
|
|
543
554
|
names starting with `$` are reserved.
|
|
555
|
+
- A service name holds no `:` or `@`: it is part of every room name of the
|
|
556
|
+
service (`qd:e:{service}:{id}@{level}`, `qd:c:{service}:{collection}:{scope}`),
|
|
557
|
+
which must split one way only, so `defineContract` throws on one.
|
|
544
558
|
- Schemas are any Standard Schema. A projection's keys, admin field
|
|
545
559
|
metadata, MCP tool schemas and `quickdraw-docs` read Standard JSON Schema,
|
|
546
560
|
which Zod 4.2 or later provides; a projection whose schema cannot describe
|
|
@@ -583,6 +597,10 @@ export const taskService = qd.defineService(task, {
|
|
|
583
597
|
// instead of the dispatcher's callTimeoutMs (30 s)
|
|
584
598
|
timeoutMs: 5_000,
|
|
585
599
|
handler: async ({ input, ctx, db }) => {
|
|
600
|
+
// an agent's call carries its token's scopes as ctx.mcp; a user's call has none
|
|
601
|
+
if (ctx.mcp !== undefined && !ctx.mcp.scopes.includes("tasks")) {
|
|
602
|
+
throw new QuickdrawError("FORBIDDEN", "This token may not assign tasks");
|
|
603
|
+
}
|
|
586
604
|
const found = await db.task.findUnique({ where: { id: input.id } });
|
|
587
605
|
if (found === null) {
|
|
588
606
|
// the caller receives the code
|
|
@@ -608,12 +626,26 @@ export const taskService = qd.defineService(task, {
|
|
|
608
626
|
Each call runs the whole pipeline with transport `"internal"` (input
|
|
609
627
|
check, access, handler, output check), so access is checked on the inner
|
|
610
628
|
call too; its writes join the calling method's unit of work, so both
|
|
611
|
-
flush together once; and it is cancelled with `ctx.signal`. It
|
|
612
|
-
|
|
629
|
+
flush together once; and it is cancelled with `ctx.signal`. It carries
|
|
630
|
+
the calling method's `ctx.mcp` as well: every call an MCP call makes
|
|
631
|
+
through `ctx.services`, nested ones included, sees the MCP call's fields
|
|
632
|
+
with transport `"internal"`, so tell a token's call apart by `ctx.mcp`,
|
|
633
|
+
never by `ctx.transport`. It is typed by the `contracts` of
|
|
634
|
+
`initQuickdraw`'s types, as `qd.caller` is.
|
|
613
635
|
- `share: "caller"` runs identical concurrent calls of one principal once,
|
|
614
|
-
`share: "all"` across principals
|
|
615
|
-
|
|
616
|
-
|
|
636
|
+
`share: "all"` across principals; `ttlMs` keeps a shared result. A
|
|
637
|
+
`"all"` run's handler sees the first caller's `ctx` and every caller gets
|
|
638
|
+
its result, so a result shared across principals must depend on the
|
|
639
|
+
input only: it never reads `ctx.principal`, `ctx.services`, `ctx.mcp`,
|
|
640
|
+
`ctx.access` or a context field made from them (in development, reading
|
|
641
|
+
one of the first four warns `[quickdraw:shared-principal]`; copying or
|
|
642
|
+
logging the whole `ctx` reads them too). `"all"` is refused with
|
|
643
|
+
`custom` access and on the kit methods whose result depends on the caller
|
|
644
|
+
(the read/write kit's `list` and `getMany`, `search`, the sharing kit's
|
|
645
|
+
`listShares` and `listMembers`, the admin kit's `adminList` and
|
|
646
|
+
`adminGet`): give those `"caller"`. Callers of a shared run whose levels
|
|
647
|
+
hide the same fields get one copy of its result, encoded once for all of
|
|
648
|
+
their sockets.
|
|
617
649
|
`version(input, ctx)` answers "not modified" for a query whose result the
|
|
618
650
|
caller already holds.
|
|
619
651
|
- Every call runs a pipeline: look up, concurrency (16 queries in flight per
|
|
@@ -728,13 +760,30 @@ export const taskService = qd.defineService(task, {
|
|
|
728
760
|
db.task.update({ where: { id: input.id }, data: { assigneeId: ctx.principal.userId } }),
|
|
729
761
|
},
|
|
730
762
|
triage: {
|
|
731
|
-
//
|
|
732
|
-
access:
|
|
733
|
-
|
|
734
|
-
),
|
|
763
|
+
// Moderate on the task's project, from a token that may triage: claims add to the form
|
|
764
|
+
access: { entry: "Moderate" },
|
|
765
|
+
claims: (claims, input) => claims?.scope === "triage" && input.status !== "archived",
|
|
735
766
|
handler: ({ input, db }) =>
|
|
736
767
|
db.task.update({ where: { id: input.id }, data: { status: input.status } }),
|
|
737
768
|
},
|
|
769
|
+
assigned: {
|
|
770
|
+
// Read on the project the input names, asked through ctx.access; none named, the caller's
|
|
771
|
+
// own that they may still read
|
|
772
|
+
access: custom(
|
|
773
|
+
async (ctx, input) =>
|
|
774
|
+
input.projectId === undefined || (await ctx.access.can(project, input.projectId, "Read")),
|
|
775
|
+
),
|
|
776
|
+
handler: async ({ input, ctx, db }) => {
|
|
777
|
+
const readable = await ctx.access.where(task, "Read");
|
|
778
|
+
if (readable === "none") return [];
|
|
779
|
+
return db.task.findMany({
|
|
780
|
+
where: {
|
|
781
|
+
AND: [{ assigneeId: ctx.principal.userId, projectId: input.projectId }, readable],
|
|
782
|
+
},
|
|
783
|
+
take: 100,
|
|
784
|
+
});
|
|
785
|
+
},
|
|
786
|
+
},
|
|
738
787
|
},
|
|
739
788
|
});
|
|
740
789
|
```
|
|
@@ -745,7 +794,10 @@ export const taskService = qd.defineService(task, {
|
|
|
745
794
|
`{ scope: L, of, id }` (the level on a row of another service) and
|
|
746
795
|
`custom(fn)`. Without a principal every form but `"public"` answers
|
|
747
796
|
`UNAUTHENTICATED`; a principal that fails gets `FORBIDDEN`. The levels,
|
|
748
|
-
lowest first, are `Public`, `Read`, `Moderate` and `Admin`.
|
|
797
|
+
lowest first, are `Public`, `Read`, `Moderate` and `Admin`. A `custom`
|
|
798
|
+
form replaces the row check, and a service-wide `Admin` grant skips it: a
|
|
799
|
+
check on the caller's claims is `claims` (below), and one that needs the
|
|
800
|
+
caller's level on a row asks `ctx.access` (`assigned` above, and below).
|
|
749
801
|
- `kinds`, beside `access`, says which kinds of principal
|
|
750
802
|
(`principal.kind`) may call: `initQuickdraw({ kinds: ["user", "agent"] })`
|
|
751
803
|
for every service the app defines, `defineService(contract, { kinds })`
|
|
@@ -758,26 +810,44 @@ export const taskService = qd.defineService(task, {
|
|
|
758
810
|
`"public"` method takes no `kinds` of its own: a refused caller would call
|
|
759
811
|
it signed out. A service's list also holds for its entity, collection,
|
|
760
812
|
topic and stream subscriptions, and its channels drop the messages of
|
|
761
|
-
other kinds.
|
|
762
|
-
|
|
813
|
+
other kinds.
|
|
814
|
+
- `claims: (claims, input) => boolean`, beside `access` on a method, is a
|
|
815
|
+
requirement on the caller's verified claims (`principal.claims`, a
|
|
816
|
+
token's `scope`; `triage` above). It adds to the form, so `triage` needs
|
|
817
|
+
Moderate on the task's project and a triage token. It runs right after
|
|
818
|
+
`kinds`, outside the access engine, so no grant passes it, a service-wide
|
|
819
|
+
`Admin` grant included. Without a principal the call is
|
|
820
|
+
`UNAUTHENTICATED`; when it returns anything but `true`, `FORBIDDEN`. It is
|
|
821
|
+
synchronous, and a `"public"` method takes none. A token narrowed by
|
|
822
|
+
`claims` is narrowed only on the methods that declare it; elsewhere it
|
|
823
|
+
acts with its user's grants (`kinds` keeps a kind of token out).
|
|
763
824
|
- On a service with a policy, a method whose input has `id` under a form
|
|
764
|
-
that
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
825
|
+
that asks the policy nothing about the row that id names would let anyone
|
|
826
|
+
that form admits reach any row by its id, so `defineService` refuses it,
|
|
827
|
+
naming the method and the ways out. Those forms are the ones that check
|
|
828
|
+
no row (`"public"`, `"authenticated"`, `{ service: L }` below `Admin`),
|
|
829
|
+
`custom(fn)`, `{ scope, of, id }` (a row of another service) and
|
|
830
|
+
`{ entry, id }` naming another key. The ways out: a form the policy
|
|
831
|
+
decides (`{ entry: L }`, or `{ service: L, entry: L }` to keep the grant,
|
|
832
|
+
with `claims` beside it for a check on the caller's claims), or
|
|
833
|
+
`rowless: true` on the method when every such caller may reach any row on
|
|
834
|
+
purpose (the `title` method above; public profiles, lookups by an id that
|
|
835
|
+
tells nothing), or when `id` names a row the method creates (an id the
|
|
836
|
+
client made, under a `scope` form). A `rowless` query under a form that
|
|
837
|
+
checks no row also answers "not modified" for any `id`; under any other
|
|
838
|
+
form it runs its handler every time. A kit's methods take it as
|
|
771
839
|
`rowless: ["get"]` in the kit's options. The input's keys come from its
|
|
772
840
|
JSON Schema: an `id` in any branch of a union counts, and so does one
|
|
773
841
|
beside a value JSON Schema cannot write (a `Date`, a `Set`). Not checked:
|
|
774
842
|
an input without JSON Schema (Zod 3) or whose JSON Schema names no keys (a
|
|
775
843
|
`todoSchema` without `keys`), one that is no object (a bare string
|
|
776
|
-
that is the id itself)
|
|
777
|
-
nested `where.id`)
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
844
|
+
that is the id itself), a row named by another key (`ids`, `taskId`, a
|
|
845
|
+
nested `where.id`) under a form that checks the row `id` names, and an
|
|
846
|
+
`entry` form whose `id` is a function, which names its row in code.
|
|
847
|
+
- A service-wide `Admin` grant passes every access form on its service
|
|
848
|
+
(`adminBypass: false` turns that off), though not `kinds` or `claims`. A
|
|
849
|
+
grant below `Admin` counts only where the form names `service`: a `Read`
|
|
850
|
+
grant does not read every row.
|
|
781
851
|
- Grants come from `principal.serviceAccess`, as `authenticate` returns it or
|
|
782
852
|
`createServer({ auth: { loadServiceAccess } })` loads it for a principal
|
|
783
853
|
that carries none: at a socket's handshake, for each HTTP call, and for
|
|
@@ -803,20 +873,51 @@ export const taskService = qd.defineService(task, {
|
|
|
803
873
|
call, so checking 60 ids costs what checking one does. `entry` access
|
|
804
874
|
needs a policy; a service without `model` may only use `"public"`,
|
|
805
875
|
`"authenticated"`, `{ service }` and `custom`. `inherit` uses the
|
|
806
|
-
parent's policy only: grants on the parent's service do not flow down.
|
|
876
|
+
parent's policy only: grants on the parent's service do not flow down. A
|
|
877
|
+
membership table needs an `id` column (the pair unique by `@@unique`),
|
|
878
|
+
or its writes are not tracked and a removed member is never revoked.
|
|
807
879
|
- One policy decides every surface: method calls, entity subscriptions,
|
|
808
880
|
collection scopes, the kits' lists and searches, streams and channels.
|
|
809
|
-
- When a tracked write lowers or removes someone's access,
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
881
|
+
- When a tracked write lowers or removes someone's access, a relation-form
|
|
882
|
+
foreign key included (`project: { connect }`), their sockets leave the
|
|
883
|
+
rooms anchored on that row and get `qd:revoked`; a changed level moves
|
|
884
|
+
them to that level's room. In a cluster the change is broadcast to every
|
|
885
|
+
node. Every socket's re-resolution settles before the flush's frames go
|
|
886
|
+
out, a failed one included.
|
|
813
887
|
- `server.dispatcher.access` gives the same answers to other code:
|
|
814
888
|
`levelsFor(service, principal, ids)`, `accessWhere(service, principal, level)`
|
|
815
889
|
(a `where` filter for `findMany`, or `"none"`) and `onAccessChanged(listener)`.
|
|
890
|
+
- `ctx.access` gives them to a handler or a `custom` form, as the caller:
|
|
891
|
+
`levelsFor(service, ids)`, `can(service, id, level)` and
|
|
892
|
+
`where(service, level)` are those bound to `ctx.principal`, with the
|
|
893
|
+
grants the call runs with (a socket's, or those an in-process caller
|
|
894
|
+
loaded); `service` is a contract or a service name. A form can then check
|
|
895
|
+
a row no `{ entry }` or `{ scope }` form names: a parent the input
|
|
896
|
+
names, or a key that is optional, as in `assigned` above, which passes
|
|
897
|
+
without a project and needs Read on one it names. Its handler adds
|
|
898
|
+
`where(task, "Read")` to its query with `AND`, so a list without a
|
|
899
|
+
project still follows the row policy. Never spread the filter into
|
|
900
|
+
`where`: it and the input both set `projectId`, and whichever comes last
|
|
901
|
+
wins, widening a list with a project or dropping the policy from one
|
|
902
|
+
without. `ctx.access` counts levels as
|
|
903
|
+
`{ entry }` does: a service-wide `Admin` grant reaches every row (with
|
|
904
|
+
`adminBypass`), a row that is not found gives anyone else no level
|
|
905
|
+
(`can` is `false`), and an anonymous caller has none anywhere (an empty
|
|
906
|
+
map, `false`, `"none"`). So a form that asks it about a parent counts a
|
|
907
|
+
service-wide `Admin` grant on the parent's service, which
|
|
908
|
+
`{ scope, of }` does not: prefer `{ scope: L, of, id }` when the input
|
|
909
|
+
always names the parent. A `custom` form is still no row check of the
|
|
910
|
+
method's own `id`: a method whose input has one needs `rowless: true`
|
|
911
|
+
beside it (above). Every method call has it, MCP and `ctx.services` calls
|
|
912
|
+
included, and so do the `ctx` the kits pass to their forms and callbacks;
|
|
913
|
+
a context no dispatcher built throws `INTERNAL` on its use, and `qd` has
|
|
914
|
+
none: a job asks `server.dispatcher.access`.
|
|
816
915
|
- `createServer({ access: { cacheMs: 30_000 } })` keeps policy lookups across
|
|
817
916
|
requests; tracked writes to the columns and membership tables the policies
|
|
818
917
|
read evict them. Writes the tracked client cannot see are picked up only
|
|
819
|
-
when the time passes, so the cache is off by default.
|
|
918
|
+
when the time passes, so the cache is off by default. A lookup inside a
|
|
919
|
+
transaction (`db.$transaction`, a sharing change) reads the database,
|
|
920
|
+
never the cache.
|
|
820
921
|
|
|
821
922
|
## Tracked writes
|
|
822
923
|
|
|
@@ -882,8 +983,53 @@ export async function markStale(before: Date): Promise<number> {
|
|
|
882
983
|
}
|
|
883
984
|
```
|
|
884
985
|
|
|
885
|
-
-
|
|
886
|
-
|
|
986
|
+
- A helper that writes takes the tracked client by its type. `trackPrisma`
|
|
987
|
+
returns `Tracked<C>`: still a `C` wherever one is asked for, while the
|
|
988
|
+
untracked client is no `Tracked<C>`. Type the helper's client
|
|
989
|
+
`Tracked<PrismaClient>`, or `TrackedTransaction<PrismaClient>` when it
|
|
990
|
+
may also run inside `db.$transaction(async (tx) => ...)`, whose `tx` is
|
|
991
|
+
one (`db` is one too), and the untracked client passed to it is a type
|
|
992
|
+
error. A helper typed `PrismaClient` takes either client. Both types are
|
|
993
|
+
type-only, on `./prisma` and on the root export, so a shared package
|
|
994
|
+
names them without Prisma. `no-untracked-write` reads the type too: a
|
|
995
|
+
parameter so typed may be named `prisma`.
|
|
996
|
+
|
|
997
|
+
<!-- example: apps/api/src/jobs/overdue.ts#helper -->
|
|
998
|
+
|
|
999
|
+
```ts
|
|
1000
|
+
import type { TrackedTransaction } from "@fitzzero/quickdraw-core/prisma";
|
|
1001
|
+
import type { PrismaClient } from "@project/db";
|
|
1002
|
+
|
|
1003
|
+
// takes the tracked client, or a transaction's: passing the untracked `prisma` is a type error
|
|
1004
|
+
export async function reopenStale(
|
|
1005
|
+
client: TrackedTransaction<PrismaClient>,
|
|
1006
|
+
projectId: string,
|
|
1007
|
+
): Promise<number> {
|
|
1008
|
+
const { count } = await client.task.updateMany({
|
|
1009
|
+
where: { projectId, status: "stale" },
|
|
1010
|
+
data: { status: "open" },
|
|
1011
|
+
});
|
|
1012
|
+
return count;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export async function restoreProject(projectId: string): Promise<number> {
|
|
1016
|
+
return await qd.run(() =>
|
|
1017
|
+
db.$transaction(async (tx) => {
|
|
1018
|
+
await tx.project.update({ where: { id: projectId }, data: { archived: false } });
|
|
1019
|
+
return await reopenStale(tx, projectId);
|
|
1020
|
+
}),
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
- Not seen: the related rows of a nested write
|
|
1026
|
+
(`{ labels: { create: [...] } }`, which warns in development), raw SQL and
|
|
1027
|
+
database cascades. A foreign key a nested write sets on the written row
|
|
1028
|
+
(`project: { connect }`, `parent: { disconnect: true }`) is seen: such an
|
|
1029
|
+
`update` or `upsert` reads the row's interested columns first, one more
|
|
1030
|
+
statement. Before deleting a row, delete or clear through `db` the rows
|
|
1031
|
+
a cascade would take with it (for the admin kit's `adminDelete`, in its
|
|
1032
|
+
`prepare`). Record raw SQL with
|
|
887
1033
|
`ctx.touch("task", ids)`, or `{ removed: true }` for deleted rows. A job
|
|
888
1034
|
gets the same `touch` from `qd.run`, whose `fn` receives
|
|
889
1035
|
`{ touch, log, principal: null }`:
|
|
@@ -905,7 +1051,13 @@ export async function spreadOrdinals(projectId: string): Promise<void> {
|
|
|
905
1051
|
```
|
|
906
1052
|
|
|
907
1053
|
- Tracked models need a string `id` column; writes to other models pass
|
|
908
|
-
through untracked, with one warning.
|
|
1054
|
+
through untracked, with one warning. Membership and junction tables
|
|
1055
|
+
included: give them `id String @id @default(cuid())` and keep the pair
|
|
1056
|
+
unique with `@@unique([...])`, never a composite `@@id`. `createServer`
|
|
1057
|
+
checks, at start, every model a policy, a membership table or a `via`
|
|
1058
|
+
junction depends on, and logs `[quickdraw:untracked-access-model]` as an
|
|
1059
|
+
error, in every environment, for one it cannot track; a test app made
|
|
1060
|
+
with `strictWarnings` fails to start.
|
|
909
1061
|
- `createServer({ flushSink })` adds the app's own sinks (an audit log, say);
|
|
910
1062
|
`createRecordingSink()` on `./testing` records what is flushed, for tests.
|
|
911
1063
|
|
|
@@ -1003,7 +1155,14 @@ export const taskService = qd.defineService(task, {
|
|
|
1003
1155
|
by `id`) comes from `versionColumn`, or from an in-process change log of
|
|
1004
1156
|
recent flushes. The change log sees only this process's writes: an app
|
|
1005
1157
|
running several processes without a Socket.IO cluster adapter declares
|
|
1006
|
-
`versionColumn`s or passes `changeLog: false`.
|
|
1158
|
+
`versionColumn`s or passes `changeLog: false`. For a query, it comes only
|
|
1159
|
+
when its form checked the row `id` names (an `entry` form on `id`, a
|
|
1160
|
+
`scope` form on the service's own rows by `id`, `{ service: "Admin" }`),
|
|
1161
|
+
for a caller the service's `Admin` bypass passes, or for a `rowless`
|
|
1162
|
+
method under a form that checks no row (`"public"`, `"authenticated"`,
|
|
1163
|
+
`{ service: L }`): it answers before the handler runs, so it would tell
|
|
1164
|
+
any other caller that the row exists and has not changed. Any other
|
|
1165
|
+
query runs its handler.
|
|
1007
1166
|
- Revisions are microseconds since the epoch: a flush takes
|
|
1008
1167
|
`max(Date.now() * 1000, last + 1)`. Clients compare them as numbers.
|
|
1009
1168
|
- Behind a cluster adapter (`setupRedisAdapter`), every touched row is read
|
|
@@ -1015,10 +1174,14 @@ export const taskService = qd.defineService(task, {
|
|
|
1015
1174
|
Flushes take their revisions from a counter in the cluster's Valkey, on
|
|
1016
1175
|
Valkey's clock in microseconds, so revisions from all nodes are one order;
|
|
1017
1176
|
its key needs persistence or replication. A node whose Valkey connection
|
|
1018
|
-
comes back has its clients reconnect to catch up.
|
|
1177
|
+
comes back has its clients reconnect to catch up. Give each deployment its
|
|
1178
|
+
own Valkey, or its own `cluster.keyPrefix` (which `setupRedisAdapter` takes
|
|
1179
|
+
as its key): a node applies only its own deployment's events (once every
|
|
1180
|
+
deployment that shares the Valkey runs this version), and a Valkey's `db`
|
|
1181
|
+
does not separate pub/sub.
|
|
1019
1182
|
[docs/deploying.md](https://github.com/fitzzero/quickdraw/blob/main/docs/deploying.md) has the wiring, what holds across
|
|
1020
|
-
nodes, what it costs,
|
|
1021
|
-
answering.
|
|
1183
|
+
nodes, what it costs, what happens when a node or Valkey stops
|
|
1184
|
+
answering, and a production checklist.
|
|
1022
1185
|
|
|
1023
1186
|
## Collections and change topics
|
|
1024
1187
|
|
|
@@ -1075,7 +1238,7 @@ export const taskService = qd.defineService(task, {
|
|
|
1075
1238
|
collections: {
|
|
1076
1239
|
// Read on the project opens its scope
|
|
1077
1240
|
byProject: { anchor: projectContract },
|
|
1078
|
-
// a user opens only the scope that is their id
|
|
1241
|
+
// a user opens only the scope that is their id; its items still follow access above
|
|
1079
1242
|
assigned: { scopeAccess: "self" },
|
|
1080
1243
|
},
|
|
1081
1244
|
// opens the service topic to Read grants; closed without it
|
|
@@ -1090,7 +1253,13 @@ export const taskService = qd.defineService(task, {
|
|
|
1090
1253
|
pages; `where` is an equality filter on membership; `access` is the level
|
|
1091
1254
|
needed on the anchor (default `Read`).
|
|
1092
1255
|
- A junction write adds or removes its entry in the one scope it links.
|
|
1093
|
-
|
|
1256
|
+
Over the item service's own `members` table (a `members` read of the
|
|
1257
|
+
policy, alone or inside `anyOf`, whose `entry` is the via's `entry` and
|
|
1258
|
+
whose `user` is the via's `scope`), a link counts only when its
|
|
1259
|
+
role gives at least the collection's `access`: a role change across that
|
|
1260
|
+
line adds or removes the entry, and a link below it is in no page, count,
|
|
1261
|
+
index, `qd:col:items` or scoped search. When the item reads the junction
|
|
1262
|
+
(a chat's `memberCount`), declare
|
|
1094
1263
|
`via({ model, entry, scope, refreshEntry: true })`: every junction write
|
|
1095
1264
|
then also sends the entry again, `updated`, to each scope that still
|
|
1096
1265
|
holds it, for one more read of the item per flush.
|
|
@@ -1099,13 +1268,35 @@ export const taskService = qd.defineService(task, {
|
|
|
1099
1268
|
deltas to it (`added`, `updated`, `patched`, `removed`, or `reset` for more
|
|
1100
1269
|
than `bulkThreshold` rows, default 200). A `"self"` scope is the
|
|
1101
1270
|
subscriber's own user id: its items are stripped at `Read`, and it may not
|
|
1102
|
-
declare a higher `access`.
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1271
|
+
declare a higher `access`. On a service with an access policy its items
|
|
1272
|
+
still follow that policy: a page, `qd:col:items` and every flush hold only
|
|
1273
|
+
the rows its user may read at `Read`. A row the user loses goes out
|
|
1274
|
+
`removed`, and a change to their membership of a parent row (or a reload
|
|
1275
|
+
of their grants) sends `reset`, so the list loads again; a client that
|
|
1276
|
+
resumes the list after such a change reads a page. A change to a parent
|
|
1277
|
+
row's own access columns (a project's owner or access list) reaches the
|
|
1278
|
+
list only at the rows' next write or the list's next load.
|
|
1279
|
+
- `requires: { service: L }`, beside `anchor` or `scopeAccess`, also asks
|
|
1280
|
+
for a service-wide grant of at least `L` on the collection's service, as
|
|
1281
|
+
its methods under `{ service: L }` do: both checks must pass, and a
|
|
1282
|
+
service-wide `Admin` passes either. It is for a list whose methods need
|
|
1283
|
+
the grant, `mine: { scopeAccess: "self", requires: { service: "Read" } }`
|
|
1284
|
+
beside methods under `{ service: "Read" }`; a list whose rows its users
|
|
1285
|
+
read by their level on each row (`assigned` above) takes none. Without
|
|
1286
|
+
the grant, `qd:col:sub` (cursor pages included), the scope's change
|
|
1287
|
+
topic and a search kept to the scope are `FORBIDDEN`. The grant comes
|
|
1288
|
+
from the socket's principal, never a read: when the user's grants reload
|
|
1289
|
+
below it (`server.access.refresh`, a tracked write of the grants column,
|
|
1290
|
+
on any node), each of their sockets leaves the scope with `qd:revoked`
|
|
1291
|
+
(`reason: "access"`) and gets nothing more of it. A collection without
|
|
1292
|
+
`requires` is unchanged.
|
|
1293
|
+
- Items are visible to everyone in an anchored scope: no per-row policy or
|
|
1294
|
+
field tier applies inside it. Derive the item service's own access from
|
|
1295
|
+
the anchor (`inherit` from it, as above): a per-row policy on the item
|
|
1296
|
+
service (an owner column, a row's access list) is not applied to an
|
|
1297
|
+
anchored scope's items, so a row it would hide still reaches everyone in
|
|
1298
|
+
its scope. A `"self"` scope is the exception: its items follow the item
|
|
1299
|
+
service's policy for its user.
|
|
1109
1300
|
- `index` sends one small row per member (`[id, rev, ...fields]`, up to
|
|
1110
1301
|
50,000) with the first page, so the client knows the whole membership and
|
|
1111
1302
|
order at once; `views` are pure predicates over index rows the client runs.
|
|
@@ -1128,11 +1319,27 @@ export const taskService = qd.defineService(task, {
|
|
|
1128
1319
|
changed it (`models`). The service topic is closed
|
|
1129
1320
|
(`FORBIDDEN`) unless the service declares `watchAccess` (`"public"`,
|
|
1130
1321
|
`"authenticated"` or `{ service: level }`). A watcher that loses access
|
|
1131
|
-
leaves the topic after one last `qd:changed`.
|
|
1322
|
+
leaves the topic after one last `qd:changed`. A watch whose access
|
|
1323
|
+
changed while it was answered is authorized again before it joins the
|
|
1324
|
+
topic, with the socket's principal as it is then, and answered
|
|
1325
|
+
`FORBIDDEN` when it lost access; it hears nothing until it joins. Entity
|
|
1326
|
+
and collection subscribes re-check the same way, with the grants the
|
|
1327
|
+
socket holds then (a grant lowered mid-subscribe denies, a raised one
|
|
1328
|
+
answers at its level), but from inside their room, which they need to
|
|
1329
|
+
catch every change after the snapshot they read: a frame sent while they
|
|
1330
|
+
re-check can reach a socket that is then refused, and the client drops
|
|
1331
|
+
it with the refusal.
|
|
1132
1332
|
- The socket rate limiter does not count subscription events; each socket
|
|
1133
1333
|
runs `qd:sub`, `qd:col:sub`, `qd:col:items` and `qd:watch` in a lane
|
|
1134
1334
|
instead: `limits.subscriptions` (8 at once, 64 waiting), then
|
|
1135
|
-
`RATE_LIMITED`.
|
|
1335
|
+
`RATE_LIMITED`. A socket holds at most 10,000 entity ids, 1,000
|
|
1336
|
+
collection scopes, 1,000 topic watches and 500 stream feeds at once:
|
|
1337
|
+
one more is `CONFLICT` (a `qd:sub` whose new ids would pass the cap is
|
|
1338
|
+
refused whole, and a row that batches running at once left no room for
|
|
1339
|
+
is answered `CONFLICT` on its own), one held already never counts again,
|
|
1340
|
+
and `qd:hello`
|
|
1341
|
+
announces the caps (`limits.subscriptions.maxEntityIds`, `maxScopes`,
|
|
1342
|
+
`maxTopics`).
|
|
1136
1343
|
|
|
1137
1344
|
## The server and its transports
|
|
1138
1345
|
|
|
@@ -1151,21 +1358,38 @@ over three transports (design: sections 3, 8 and 10):
|
|
|
1151
1358
|
events per minute per socket; channels, cancels and subscription events not
|
|
1152
1359
|
counted); configure it with `rateLimit`, or turn it off with
|
|
1153
1360
|
`rateLimit: false`. There is no default CORS origin: pass `cors`.
|
|
1361
|
+
`peers: true` serves peer links between the clients of an app room
|
|
1362
|
+
([Peer links](#peer-links)).
|
|
1363
|
+
Socket.IO's connection state recovery is refused: `createServer` throws
|
|
1364
|
+
for `socket: { connectionStateRecovery }`, since a recovered socket would
|
|
1365
|
+
come back with its principal and rooms without authenticating again, so a
|
|
1366
|
+
revoked session or a lowered grant would return. Clients resume by
|
|
1367
|
+
revision on every reconnect instead.
|
|
1154
1368
|
- **HTTP**: `POST /qd/{service}/{method}` with the input as a JSON body and
|
|
1155
1369
|
`Content-Type: application/json` (required, even without a body, so a
|
|
1156
1370
|
cross-site page cannot use a session cookie without a CORS preflight). The
|
|
1157
1371
|
principal comes from the session cookie (`__Host-session` over HTTPS,
|
|
1158
1372
|
`session` over plain HTTP or with `COOKIE_DOMAIN`, as the auth routes name
|
|
1159
1373
|
it) or an `Authorization: Bearer` token through the same
|
|
1160
|
-
`authenticate`, which is told which one
|
|
1161
|
-
`socketAuth` answers a cookie sent from a page
|
|
1162
|
-
with `FORBIDDEN`, as it refuses such a socket
|
|
1374
|
+
`authenticate`, which is told which one the transport chose
|
|
1375
|
+
(`request.credential`): `socketAuth` answers a cookie sent from a page
|
|
1376
|
+
outside `allowedOrigins` with `FORBIDDEN`, as it refuses such a socket.
|
|
1377
|
+
The cookie is `auth.token` only for `socketAuth`'s `authenticate`; any
|
|
1378
|
+
other finds it in `request.sessionCookie` alone, and `auth.token` holds
|
|
1379
|
+
only a bearer token. The reply is `{ ok: true, d }` or
|
|
1163
1380
|
`{ ok: false, e: { code, message, data? } }` with the code's HTTP status.
|
|
1164
1381
|
Works on Express 4 and 5, and on a bare Node server. Move it with
|
|
1165
1382
|
`http: { path }`, turn it off with `http: false`, or mount
|
|
1166
1383
|
`createHttpRouter({ dispatcher, auth })` yourself. It has no rate limit of
|
|
1167
1384
|
its own: on Express, set `http: { rateLimit: createCallLimiter() }` (from
|
|
1168
|
-
`./server/express`), which refuses with the `RATE_LIMITED` reply.
|
|
1385
|
+
`./server/express`), which refuses with the `RATE_LIMITED` reply. An app
|
|
1386
|
+
without sockets makes its dispatcher with
|
|
1387
|
+
`createDispatcher({ services, db, serviceAccessSource })` when users'
|
|
1388
|
+
grants are stored in a column (the admin kit protects that column only
|
|
1389
|
+
where the dispatcher names it, as `createServer` does from
|
|
1390
|
+
`auth.serviceAccessSource`), and `createHttpRouter` throws when its
|
|
1391
|
+
`auth.serviceAccessSource` is not its dispatcher's. An MCP process made
|
|
1392
|
+
with `createDispatcher` names it the same way.
|
|
1169
1393
|
- **In process**: `server.dispatcher.caller(principal)` or
|
|
1170
1394
|
`qd.caller(principal)`: `await qd.caller(user).taskService.rename(input)`,
|
|
1171
1395
|
typed by the `contracts` of `initQuickdraw`'s types. A principal that
|
|
@@ -1176,9 +1400,15 @@ over three transports (design: sections 3, 8 and 10):
|
|
|
1176
1400
|
`auth.serviceAccessSource`). One that carries grants (even `{}`) keeps
|
|
1177
1401
|
exactly those. A load that fails rejects the call with `INTERNAL`.
|
|
1178
1402
|
|
|
1179
|
-
`authenticate` takes one request (`{ transport, auth, headers, socket | req }
|
|
1180
|
-
|
|
1181
|
-
anonymous caller; throwing
|
|
1403
|
+
`authenticate` takes one request (`{ transport, auth, headers, socket | req }`,
|
|
1404
|
+
plus `credential` and `sessionCookie` on HTTP) for both transports and
|
|
1405
|
+
returns a principal, a user id, or nothing for an anonymous caller; throwing
|
|
1406
|
+
`QuickdrawError("UNAUTHENTICATED", ...)` (or `"FORBIDDEN"`) refuses. Anything
|
|
1407
|
+
else it, or `loadServiceAccess`, throws is the server's failure (its session
|
|
1408
|
+
store or database down): a socket's handshake is refused `INTERNAL`, and a
|
|
1409
|
+
quickdraw client keeps the user and connects again by itself, while an HTTP
|
|
1410
|
+
call is answered `UNAUTHENTICATED`, as before. On HTTP the session cookie
|
|
1411
|
+
is in `auth.token` only for `socketAuth`'s `authenticate`.
|
|
1182
1412
|
Pass your own HTTP server as `httpServer` together with the `app` it was
|
|
1183
1413
|
created from (or with `http: false`).
|
|
1184
1414
|
|
|
@@ -1231,13 +1461,25 @@ one, by `service.method (kind)`, which tells an agent nothing), with the
|
|
|
1231
1461
|
input schema's JSON Schema as its arguments and `readOnlyHint` on every
|
|
1232
1462
|
query. The API docs print each method's default tool name and its read-only
|
|
1233
1463
|
hint. Every tool call goes through the dispatcher with transport `"mcp"`,
|
|
1234
|
-
so input validation
|
|
1235
|
-
|
|
1236
|
-
|
|
1464
|
+
so input validation and access checks apply as on a socket. Queries share
|
|
1465
|
+
a concurrency lane per caller (16 run and 64 wait by default, the
|
|
1466
|
+
dispatcher's `limits`, then `RATE_LIMITED`): a stdio process, and on HTTP
|
|
1467
|
+
a bearer token, a user without a token, or the address of an anonymous
|
|
1468
|
+
caller (Express's `req.ip`, an IPv6 address by its /56). Behind a proxy,
|
|
1469
|
+
set Express's `trust proxy`, or every anonymous caller shares the proxy's
|
|
1470
|
+
lane. A `principal` hook that authenticates by anything but the bearer
|
|
1471
|
+
token (a cookie, an API key header) must refuse a request whose bearer
|
|
1472
|
+
token it does not verify, or a caller makes a lane per made-up token. The
|
|
1473
|
+
HTTP routes have no rate limit of their own: limit them per address, as
|
|
1474
|
+
below (`createPublicApiLimiter` answers 429 with `{ error }`, not the
|
|
1475
|
+
routes' `{ success: false, code }`). A method whose input cannot describe
|
|
1476
|
+
itself as JSON Schema stops the registry at startup, naming the method,
|
|
1477
|
+
unless it is excluded.
|
|
1237
1478
|
|
|
1238
1479
|
<!-- example: apps/api/src/mcp.ts#mcp -->
|
|
1239
1480
|
|
|
1240
1481
|
```ts
|
|
1482
|
+
import { createPublicApiLimiter } from "@fitzzero/quickdraw-core/server/express";
|
|
1241
1483
|
import {
|
|
1242
1484
|
createMcpHttpRouter,
|
|
1243
1485
|
createMcpRegistry,
|
|
@@ -1249,7 +1491,7 @@ const summarizeInput = z.object({ projectId: z.string() });
|
|
|
1249
1491
|
const registry = createMcpRegistry({
|
|
1250
1492
|
services: [projectService, taskService],
|
|
1251
1493
|
dispatcher: server.dispatcher,
|
|
1252
|
-
//
|
|
1494
|
+
// an agent acting for the user of a session token, checked on every call; no token is anonymous
|
|
1253
1495
|
principal: (request) =>
|
|
1254
1496
|
verifySession(request.transport === "http" ? request.token : process.env.AGENT_TOKEN),
|
|
1255
1497
|
// handlers read it as ctx.mcp
|
|
@@ -1269,7 +1511,9 @@ const registry = createMcpRegistry({
|
|
|
1269
1511
|
],
|
|
1270
1512
|
});
|
|
1271
1513
|
|
|
1272
|
-
// GET /mcp/tools, POST /mcp/invoke
|
|
1514
|
+
// GET /mcp/tools, POST /mcp/invoke; the routes have no rate limit of their own, so limit them per
|
|
1515
|
+
// address (never by the bearer token, which nothing has checked yet)
|
|
1516
|
+
app.use("/mcp", createPublicApiLimiter({ max: 600 }));
|
|
1273
1517
|
app.use(createMcpHttpRouter({ registry }));
|
|
1274
1518
|
// in an MCP client's process
|
|
1275
1519
|
createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
|
|
@@ -1284,7 +1528,17 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
|
|
|
1284
1528
|
sends console output to stderr so only the protocol reaches stdout.
|
|
1285
1529
|
- **HTTP**: `GET /mcp/tools` and `POST /mcp/invoke`, which takes
|
|
1286
1530
|
`{ name, arguments }` and answers `{ success: true, data }`, or
|
|
1287
|
-
`{ success: false, error, code, data? }` with the code's HTTP status.
|
|
1531
|
+
`{ success: false, error, code, data? }` with the code's HTTP status. A
|
|
1532
|
+
request from a web page (one with an `Origin` header) is answered 403
|
|
1533
|
+
unless `createMcpHttpRouter({ allowedOrigins })` lists its origin (exact
|
|
1534
|
+
origins or anchored patterns, as `socketAuth` takes them; none by
|
|
1535
|
+
default); an MCP client sends no `Origin`. `allowedOrigins` adds no CORS
|
|
1536
|
+
headers: a page on another origin still needs the app's CORS setup. For a
|
|
1537
|
+
server on a developer's machine or a LAN,
|
|
1538
|
+
`allowedHosts: ["localhost", "127.0.0.1", "::1"]` also refuses a request
|
|
1539
|
+
for any other `Host`, which a DNS-rebinding page would send. An entry with
|
|
1540
|
+
a port matches that port only (a `Host` without one is for port 80 or
|
|
1541
|
+
443).
|
|
1288
1542
|
- An anonymous caller (the `principal` hook returned nothing) may call
|
|
1289
1543
|
`"public"` methods, and custom tools that declare `access: "public"`; any
|
|
1290
1544
|
other tool answers `UNAUTHENTICATED` before it runs. A failed call reaches
|
|
@@ -1294,6 +1548,19 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
|
|
|
1294
1548
|
tools it may not call (and its call is refused). Keep a tool out of the
|
|
1295
1549
|
list with `exclude` or `include`, per registry, and serve agents of
|
|
1296
1550
|
different reach from separate registries.
|
|
1551
|
+
- `kinds` sees only what the `principal` hook labels: `verifySession` above
|
|
1552
|
+
labels every MCP caller `kind: "agent"`, so a method with
|
|
1553
|
+
`kinds: ["user"]` refuses it. Check a session token against the store on
|
|
1554
|
+
every call (`bearerAuth`), never with `verifyJWT` alone, which a
|
|
1555
|
+
signed-out session still passes. A custom tool obeys the app's
|
|
1556
|
+
`initQuickdraw({ kinds })` too, and takes `kinds` of its own within it
|
|
1557
|
+
(`FORBIDDEN` for another kind, before its arguments are read); a
|
|
1558
|
+
`"public"` tool takes none. Custom tools served beside the services of
|
|
1559
|
+
apps that admit no kind in common stop the registry at startup.
|
|
1560
|
+
- To refuse a token, the `principal` hook throws
|
|
1561
|
+
`QuickdrawError("UNAUTHENTICATED")`, which is logged at debug, as the
|
|
1562
|
+
caller's doing; anything else it throws is logged as an error. Never put
|
|
1563
|
+
the token in the message.
|
|
1297
1564
|
|
|
1298
1565
|
`bind` fills chosen arguments from who is calling instead of from the agent,
|
|
1299
1566
|
so an agent bound to one project cannot name another in its calls. Here an
|
|
@@ -1322,9 +1589,14 @@ app.use(createMcpHttpRouter({ registry: projectAgents, path: "/agents" }));
|
|
|
1322
1589
|
|
|
1323
1590
|
- Each bound argument is left out of the `inputSchema` of every method's tool
|
|
1324
1591
|
whose object input has it: `create_task` lists `id` and `title`, and
|
|
1325
|
-
`count_tasks` takes no arguments.
|
|
1326
|
-
|
|
1327
|
-
|
|
1592
|
+
`count_tasks` takes no arguments. An object schema with `.meta({ id })`,
|
|
1593
|
+
which JSON Schema writes as a `$ref`, counts as the object it names; a
|
|
1594
|
+
schema made from it (`.optional()`, `.refine()`, `.default()` and the
|
|
1595
|
+
like) is not an object input. Every call of such a tool gets the binder's
|
|
1596
|
+
value before its input is validated, through `tools/call` and both shapes
|
|
1597
|
+
of `POST /mcp/invoke` alike, and a call of any other method's tool that
|
|
1598
|
+
sends a bound argument is `FORBIDDEN`. Only top-level arguments are
|
|
1599
|
+
bound.
|
|
1328
1600
|
- A call fails closed, before the method runs: `UNAUTHENTICATED` for an
|
|
1329
1601
|
anonymous caller, and `FORBIDDEN` when the binder returns `undefined` or
|
|
1330
1602
|
`null`, or when the agent sent the argument with another value. A binder
|
|
@@ -1332,9 +1604,10 @@ app.use(createMcpHttpRouter({ registry: projectAgents, path: "/agents" }));
|
|
|
1332
1604
|
`QuickdrawError` it threw.
|
|
1333
1605
|
- `createMcpRegistry` throws at startup when a bound argument is in no
|
|
1334
1606
|
selected method's object input (a misspelled name would leave the real
|
|
1335
|
-
argument to the agent), and when
|
|
1336
|
-
|
|
1337
|
-
|
|
1607
|
+
argument to the agent), and when an input may hold it anywhere it cannot
|
|
1608
|
+
be filled (an input that is not an object, or a branch of a union or an
|
|
1609
|
+
intersection, behind a `$ref` too), which it names: leave that method
|
|
1610
|
+
out, or make its input an object.
|
|
1338
1611
|
- Custom tools are never bound, and neither are the calls their `caller`
|
|
1339
1612
|
makes: a custom tool reads `principal` itself. `describeTools` and the API
|
|
1340
1613
|
docs list the inputs as the contracts declare them, with nothing bound.
|
|
@@ -1342,18 +1615,69 @@ app.use(createMcpHttpRouter({ registry: projectAgents, path: "/agents" }));
|
|
|
1342
1615
|
socket or HTTP with nothing bound, so the method's access must still
|
|
1343
1616
|
confine it to its project.
|
|
1344
1617
|
|
|
1618
|
+
An app that signs the caller in itself, such as its own MCP transport over
|
|
1619
|
+
`callMethod` or an in-process assistant, passes the caller as `principal`
|
|
1620
|
+
in the call's options, and the registry's `principal` hook is not asked:
|
|
1621
|
+
|
|
1622
|
+
<!-- example: apps/api/src/mcp.ts#signed-in -->
|
|
1623
|
+
|
|
1624
|
+
```ts
|
|
1625
|
+
import { requireSession, sessionOf } from "@fitzzero/quickdraw-core/server/auth";
|
|
1626
|
+
import { toToolResult } from "@fitzzero/quickdraw-core/server/mcp";
|
|
1627
|
+
|
|
1628
|
+
// the app's own MCP route: its sessions sign the caller in, as an agent of their user
|
|
1629
|
+
const signedIn = requireSession(
|
|
1630
|
+
{ sessions, jwtSecret },
|
|
1631
|
+
{ loadPrincipal: (userId): AppPrincipal => ({ userId, kind: "agent" }) },
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
app.post("/assistant/:service/:method", signedIn, (req, res) => {
|
|
1635
|
+
void (async () => {
|
|
1636
|
+
const result = await registry.callMethod(req.params.service, req.params.method, req.body, {
|
|
1637
|
+
// no bearer token: the call's queries share its user's lane
|
|
1638
|
+
request: {
|
|
1639
|
+
transport: "http",
|
|
1640
|
+
sessionId: crypto.randomUUID(),
|
|
1641
|
+
token: null,
|
|
1642
|
+
headers: req.headers,
|
|
1643
|
+
req,
|
|
1644
|
+
},
|
|
1645
|
+
// the registry's principal hook is not asked; the user's grants load as qd.caller's do
|
|
1646
|
+
principal: sessionOf<AppPrincipal>(req).principal,
|
|
1647
|
+
});
|
|
1648
|
+
res.json(toToolResult(result));
|
|
1649
|
+
})();
|
|
1650
|
+
});
|
|
1651
|
+
```
|
|
1652
|
+
|
|
1653
|
+
- `principal: null` calls anonymously; without the key, the hook says who
|
|
1654
|
+
calls, as before. `registry.call` takes it as `callMethod` does. The
|
|
1655
|
+
`context` hook, bound arguments and the call's concurrency lane see the
|
|
1656
|
+
given principal, and `request` is still needed: the lane and `context`
|
|
1657
|
+
read it.
|
|
1658
|
+
- A principal that carries no `serviceAccess` gets the grants the server's
|
|
1659
|
+
`auth.loadServiceAccess` loads, at each call, as `qd.caller` gives them;
|
|
1660
|
+
one that carries grants (`{}` included) keeps exactly those. The `principal`
|
|
1661
|
+
hook's principal is used as the hook returns it.
|
|
1662
|
+
- It is an in-process option: `POST /mcp/invoke` never reads a principal
|
|
1663
|
+
from its body.
|
|
1664
|
+
|
|
1345
1665
|
Two more needs are met by the options above, with nothing added:
|
|
1346
1666
|
|
|
1347
1667
|
- **A tool set per kind of caller.** Make one registry per kind, each with its
|
|
1348
1668
|
own `include`, `bind` and `principal`, and serve each at its own `path` or
|
|
1349
1669
|
from its own stdio entry, as above. A registry's `principal` hook refuses
|
|
1350
|
-
the other kinds: throwing fails their
|
|
1670
|
+
the other kinds: throwing `QuickdrawError("UNAUTHENTICATED")` fails their
|
|
1671
|
+
calls with `UNAUTHENTICATED` and logs at debug; never put a token in the
|
|
1672
|
+
message.
|
|
1351
1673
|
- **Frozen public tools.** `name` gives a method's tool any public name, as
|
|
1352
1674
|
`create_task` above. When agents depend on tool names and argument shapes
|
|
1353
1675
|
that must never change, give that surface a contract of its own: its
|
|
1354
1676
|
methods' inputs and `describe` texts are the frozen shapes, its handlers
|
|
1355
1677
|
call the app's services through `ctx.services`, and a registry with
|
|
1356
|
-
`name: (_service, method) => method` serves it.
|
|
1678
|
+
`name: (_service, method) => method` serves it. Those calls carry the
|
|
1679
|
+
tool call's `ctx.mcp`, so a guard that reads it in a method they reach
|
|
1680
|
+
holds as it does on that method's own tool. The bridge never reshapes a
|
|
1357
1681
|
tool's arguments on the way in: such a tool would be invisible to the API
|
|
1358
1682
|
docs and to lint, which read the contracts.
|
|
1359
1683
|
|
|
@@ -1462,7 +1786,9 @@ export function TaskDetail({ id }: { readonly id: string }) {
|
|
|
1462
1786
|
the chat's messages open). A row-level grant with neither sends nothing
|
|
1463
1787
|
the client could notice: remount the view, or call `refresh()`.
|
|
1464
1788
|
- The cache follows the user: a hello naming another user removes everything
|
|
1465
|
-
quickdraw cached
|
|
1789
|
+
quickdraw cached, and no `useQuery` of the client shows a placeholder made
|
|
1790
|
+
from it (`placeholderData` such as `keepPreviousData`), nor `useSearch` its
|
|
1791
|
+
last results; new credentials for the same user refetch it; new grants
|
|
1466
1792
|
(`qd:access`) refetch every query.
|
|
1467
1793
|
- A protocol mismatch reloads the page once per session by default
|
|
1468
1794
|
(`onProtocolMismatch`); `RATE_LIMITED` answers back off with jitter per
|
|
@@ -1551,16 +1877,31 @@ for an anonymous socket and before the server's hello, so a gate waits for
|
|
|
1551
1877
|
`isKnown` (the hello on the current credentials arrived; false again from
|
|
1552
1878
|
new credentials until theirs). `isConnected` turns true before the hello and
|
|
1553
1879
|
false while the connection reconnects; `reconnecting` says the user and the
|
|
1554
|
-
page stay meanwhile
|
|
1880
|
+
page stay meanwhile. When the server ends the session (signed out on
|
|
1881
|
+
another device, revoked, expired) or refuses a connect for it (its
|
|
1882
|
+
`authenticate` refused the credentials, or the handshake was
|
|
1883
|
+
`FORBIDDEN`), the hello is forgotten: `isKnown` is false, `status` is
|
|
1884
|
+
`"disconnected"` or `"refused"`, and everything quickdraw cached is gone,
|
|
1885
|
+
so a gate that finds the connection stopped with nobody known shows the
|
|
1886
|
+
sign-in. A server that could not check the credentials (its session store
|
|
1887
|
+
down: the handshake is refused `INTERNAL`) signs nobody out: the
|
|
1888
|
+
connection keeps the hello and the cache, stays `"connecting"`
|
|
1889
|
+
(`reconnecting` after a connect on these credentials) and connects again
|
|
1890
|
+
by itself after Socket.IO's reconnection delays, so the gate below shows
|
|
1891
|
+
the page with "Reconnecting…", or "Connecting…" before a first connect.
|
|
1892
|
+
Another protocol (`refusal.code` `"PROTOCOL_MISMATCH"`) keeps the hello;
|
|
1893
|
+
the provider reloads the page once for it:
|
|
1555
1894
|
|
|
1556
1895
|
<!-- example: apps/web/src/components/AuthGate.tsx#gate -->
|
|
1557
1896
|
|
|
1558
1897
|
```tsx
|
|
1559
1898
|
export function AuthGate({ children }: { readonly children: ReactNode }) {
|
|
1560
1899
|
// isKnown: the server's hello named the user, so userId is final (null: signed out)
|
|
1561
|
-
const { isKnown, userId, reconnecting } = useQuickdraw();
|
|
1900
|
+
const { status, isKnown, userId, reconnecting } = useQuickdraw();
|
|
1562
1901
|
if (!isKnown) {
|
|
1563
|
-
|
|
1902
|
+
// stopped with nobody known: the server ended or refused the session (signed out elsewhere,
|
|
1903
|
+
// revoked, expired), and what quickdraw cached is gone
|
|
1904
|
+
return status === "disconnected" || status === "refused" ? <SignIn /> : <p>Connecting…</p>;
|
|
1564
1905
|
}
|
|
1565
1906
|
if (userId === null) {
|
|
1566
1907
|
return <SignIn />;
|
|
@@ -1601,13 +1942,18 @@ export async function TasksPage({ projectId, cookie }: { projectId: string; cook
|
|
|
1601
1942
|
}
|
|
1602
1943
|
```
|
|
1603
1944
|
|
|
1604
|
-
A script, a
|
|
1605
|
-
|
|
1945
|
+
A script, a CLI or a worker uses the connection directly, from
|
|
1946
|
+
`@fitzzero/quickdraw-core/client/core`: the client without React, for a
|
|
1947
|
+
process that installs neither React nor TanStack Query. It holds the
|
|
1948
|
+
connection, `call` and `callData`, `newId`, the auth helpers, peer links,
|
|
1949
|
+
host mode and everything on `./utils`. Nothing it imports, in its code or
|
|
1950
|
+
its types, is React or TanStack, and it carries no `"use client"`.
|
|
1951
|
+
`./client` re-exports all of it, the same functions:
|
|
1606
1952
|
|
|
1607
1953
|
<!-- example: apps/api/scripts/report.ts#script -->
|
|
1608
1954
|
|
|
1609
1955
|
```ts
|
|
1610
|
-
import { callData, createQuickdrawConnection } from "@fitzzero/quickdraw-core/client";
|
|
1956
|
+
import { callData, createQuickdrawConnection } from "@fitzzero/quickdraw-core/client/core";
|
|
1611
1957
|
|
|
1612
1958
|
const connection = createQuickdrawConnection({
|
|
1613
1959
|
url: "http://localhost:4000",
|
|
@@ -1624,9 +1970,11 @@ process.stdout.write(`${String(count)} tasks\n`);
|
|
|
1624
1970
|
connection.close();
|
|
1625
1971
|
```
|
|
1626
1972
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1973
|
+
Live rows and collections take a TanStack `QueryClient`, so they are on
|
|
1974
|
+
`./client` only, still without React: `liveDataOf(connection, queryClient)`
|
|
1975
|
+
holds them for code without hooks (a React Native module, a Node client
|
|
1976
|
+
that keeps a `QueryClient`), and `createInvalidationCoordinator(queryClient)`
|
|
1977
|
+
invalidates as the hooks do.
|
|
1630
1978
|
|
|
1631
1979
|
A client in another language (a Godot game, a native app) speaks the wire
|
|
1632
1980
|
itself: [`docs/protocol-v5.md`](https://github.com/fitzzero/quickdraw/blob/main/docs/protocol-v5.md) is its specification,
|
|
@@ -1710,7 +2058,8 @@ export const taskService = qd.defineService(task, {
|
|
|
1710
2058
|
delete: { entry: "Admin" },
|
|
1711
2059
|
reorder: { entry: "Moderate" },
|
|
1712
2060
|
bulkUpdate: "authenticated",
|
|
1713
|
-
|
|
2061
|
+
// all or nothing: Admin on every id, as delete needs
|
|
2062
|
+
bulkDelete: { entry: "Admin", id: "ids" },
|
|
1714
2063
|
},
|
|
1715
2064
|
// what `create` writes: columns from the principal, the next ordinal
|
|
1716
2065
|
prepare: async (input, ctx, db) => ({
|
|
@@ -1720,7 +2069,7 @@ export const taskService = qd.defineService(task, {
|
|
|
1720
2069
|
}),
|
|
1721
2070
|
}),
|
|
1722
2071
|
archive: {
|
|
1723
|
-
access: { entry: "
|
|
2072
|
+
access: { entry: "Moderate" },
|
|
1724
2073
|
handler: ({ input, db }) =>
|
|
1725
2074
|
db.task.update({ where: { id: input.id }, data: { status: "archived" } }),
|
|
1726
2075
|
},
|
|
@@ -1736,11 +2085,20 @@ export const taskService = qd.defineService(task, {
|
|
|
1736
2085
|
`get`, `update`, `delete` and `reorder` act on `input.id`, which
|
|
1737
2086
|
`{ entry: L }` checks; a write on one row also needs the row level
|
|
1738
2087
|
(`Moderate`, or the form's `entry`) on that row whatever its form, so
|
|
1739
|
-
`update: "authenticated"` edits no row. `
|
|
2088
|
+
`update: "authenticated"` edits no row. `create` makes a new row and
|
|
2089
|
+
checks no row of its own: it needs that row level on each parent its new
|
|
2090
|
+
row names (each anchor column it writes, below), whatever its form, so
|
|
2091
|
+
`create: "authenticated"` writes into no project its caller cannot
|
|
2092
|
+
moderate; give it `{ scope: "Moderate", of: project, id: "projectId" }`.
|
|
2093
|
+
`list`, `getMany`, `bulkUpdate`
|
|
1740
2094
|
and `bulkDelete` act on many rows, so they also keep only the rows the
|
|
1741
2095
|
service's policy gives the caller at the form's `entry` or `scope` level
|
|
1742
|
-
(else `Read` for reads and `Moderate` for writes
|
|
1743
|
-
|
|
2096
|
+
(else `Read` for reads and `Moderate` for writes; a bulk method's form
|
|
2097
|
+
that names none takes `update`'s or `delete`'s row level when it is
|
|
2098
|
+
higher): a list never shows a row `get` would refuse, and a bulk delete
|
|
2099
|
+
never deletes a row `delete`'s `{ entry: L }` would refuse. When the
|
|
2100
|
+
single-row form names no row level (`{ service: L }`, `custom`), give the
|
|
2101
|
+
bulk method the same form. A service-wide `Admin` grant reaches every row,
|
|
1744
2102
|
and a grant a `{ service: L }` form names is a row level of the grant on
|
|
1745
2103
|
every row (the read is not filtered, and its rows are stripped at the
|
|
1746
2104
|
grant). A `"public"` read's rows are not filtered (a public bulk write
|
|
@@ -1754,26 +2112,59 @@ export const taskService = qd.defineService(task, {
|
|
|
1754
2112
|
above the caller's level is `FORBIDDEN`, and a default sort on one falls
|
|
1755
2113
|
back to `id`), keyset cursors that stay put when rows are inserted, 50
|
|
1756
2114
|
items by default and at most 200 (a larger `limit` is clamped), and a
|
|
1757
|
-
total only when asked (a second statement).
|
|
2115
|
+
total only when asked (a second statement). A sort value too long for a
|
|
2116
|
+
cursor (4,096 characters) makes the cursor name its row instead: the next
|
|
2117
|
+
page reads that row first, and the cursor is `VALIDATION` once the row has
|
|
2118
|
+
left the list (or for a caller whose list never held it). Such a cursor
|
|
2119
|
+
follows its row: a change to that row's sort value between pages can skip
|
|
2120
|
+
or repeat rows. Items are the `item`
|
|
1758
2121
|
projection, stripped of fields above the level the page was read at, as
|
|
1759
2122
|
collection items are.
|
|
1760
2123
|
- `getMany({ ids })` (at most 200) leaves out ids the caller cannot read and
|
|
1761
2124
|
ids with no row. Bulk writes skip rows the caller cannot write, run in one
|
|
1762
|
-
transaction and return `{ count }
|
|
2125
|
+
transaction and return `{ count }`; give one `{ entry: L, id: "ids" }` to
|
|
2126
|
+
refuse the whole call instead of skipping rows (an empty `ids`, or an id
|
|
2127
|
+
with no row, is then `FORBIDDEN`).
|
|
1763
2128
|
- Writes go through the tracked client: `create` sends `added`, `update` a
|
|
1764
2129
|
patch and `delete` `removed`, and a bulk write past a scope's
|
|
1765
2130
|
`bulkThreshold` sends it one `reset`. A missing row is `NOT_FOUND`, a
|
|
1766
2131
|
unique violation `CONFLICT`.
|
|
1767
|
-
- `update` and `bulkUpdate` (and the
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
`
|
|
2132
|
+
- Without a service-wide `Admin` grant, `update` and `bulkUpdate` (and the
|
|
2133
|
+
admin kit's writes) never set a column the policy reads (an owner column,
|
|
2134
|
+
an access list), even one an anchored collection also scopes by. They
|
|
2135
|
+
never set a membership table's entry, user or role column that any policy
|
|
2136
|
+
reads either: change memberships with the sharing kit. They move a row
|
|
2137
|
+
into another parent (an `inherit` policy's `via`, an anchored
|
|
2138
|
+
collection's scope column) only when the caller's level on the new parent
|
|
2139
|
+
meets the method's row level. Each refusal is `FORBIDDEN`. An `inherit`
|
|
2140
|
+
`via` column stays movable, so never inherit a row's access from the
|
|
2141
|
+
users' own rows (`inherit({ from: user, via: "ownerId" })`): a caller
|
|
2142
|
+
would hand the row to themself.
|
|
2143
|
+
- `create` takes a column the policy reads, and a membership column, only
|
|
2144
|
+
from `prepare` (`ownerId: ctx.principal.userId`), never from a caller
|
|
2145
|
+
without a service-wide `Admin` grant. It writes a parent only where the
|
|
2146
|
+
caller has the row level on it, after `prepare` ran.
|
|
2147
|
+
- Every kit write holds column values only, in every environment and for
|
|
2148
|
+
every caller, a service-wide Admin included: a key that is a relation of
|
|
2149
|
+
the model (one a projection selects with a nested `select`) or a value
|
|
2150
|
+
that is a Prisma nested write (`{ connect }`, `{ update }`, a to-many
|
|
2151
|
+
`{ set: [...] }`) is `VALIDATION`, since nested writes are not tracked
|
|
2152
|
+
and would skip the checks above. A `prepare` that returns one is
|
|
2153
|
+
`INTERNAL`. A Json value whose own key is a relation operation holding an
|
|
2154
|
+
object or list, or `delete`/`disconnect` set to `true`, is refused too;
|
|
2155
|
+
keep such data one level deeper. Any `{ set: [...] }` is refused, a scalar
|
|
2156
|
+
list's included: write a scalar list as the list itself.
|
|
1773
2157
|
- `reorder({ id, beforeId?, afterId? })` puts the row between its new
|
|
1774
2158
|
neighbors (`beforeId` comes right before it) with one write, or renumbers
|
|
1775
2159
|
the `within` list in steps of 1,024 when no gap is left, in a transaction
|
|
1776
|
-
given 5 s plus 10 ms per row of the list.
|
|
2160
|
+
given 5 s plus 10 ms per row of the list. The neighbors it names need the
|
|
2161
|
+
method's row level, as the moved row does (a neighbor below it is
|
|
2162
|
+
`NOT_FOUND`, as a missing one is), and the list is the rows sharing the
|
|
2163
|
+
`within` values that the caller may write: a list with no `within` or a
|
|
2164
|
+
null one is each caller's own. The rows it names are read through that
|
|
2165
|
+
same filter, so a policy whose filter matches no row (a `resolver` without
|
|
2166
|
+
`where`) moves nothing below a service-wide grant, as its lists show
|
|
2167
|
+
nothing. Moves run SERIALIZABLE: two at
|
|
1777
2168
|
once into one gap fail one of them with `CONFLICT` (try again).
|
|
1778
2169
|
- The generated inputs carry JSON Schema, so the kit's methods are MCP tools
|
|
1779
2170
|
too. For hand-written handlers, `./server` has `requireRow(row, message?)`
|
|
@@ -1895,8 +2286,8 @@ export function TaskSearch({ projectId }: { readonly projectId: string }) {
|
|
|
1895
2286
|
- `search({ q, scope?, cursor?, limit? })` returns `{ items, nextCursor }`
|
|
1896
2287
|
(and `rev`, below). `q` is trimmed, and one shorter than `minLength`
|
|
1897
2288
|
(default 2) finds nothing and reads nothing. 20 results by default, at
|
|
1898
|
-
most 100, with keyset cursors as for `list`. There is no ranking:
|
|
1899
|
-
|
|
2289
|
+
most 100, with keyset cursors as for `list`. There is no ranking: a call
|
|
2290
|
+
with `scope` comes in the scope collection's order, one without by id.
|
|
1900
2291
|
- By default a row matches when one of the declared `fields` contains `q`,
|
|
1901
2292
|
ignoring case; `%`, `_` and `\` in `q` are taken literally. `fields` is an
|
|
1902
2293
|
explicit list because an unbounded "contains" over every column is slow. A
|
|
@@ -1984,7 +2375,7 @@ export const project = defineContract("projectService", {
|
|
|
1984
2375
|
}),
|
|
1985
2376
|
},
|
|
1986
2377
|
collections: {
|
|
1987
|
-
// each user's projects: an invite adds the project
|
|
2378
|
+
// each user's projects: an invite adds the project; a remove, a leave or a role below Read takes it out
|
|
1988
2379
|
mine: {
|
|
1989
2380
|
describe: "The projects a user is a member of.",
|
|
1990
2381
|
scope: via({ model: "projectMember", entry: "projectId", scope: "userId" }),
|
|
@@ -2009,12 +2400,27 @@ export const projectService = qd.defineService(project, {
|
|
|
2009
2400
|
collections: { mine: { scopeAccess: "self" } },
|
|
2010
2401
|
methods: {
|
|
2011
2402
|
...sharing.handlers(project, {
|
|
2012
|
-
//
|
|
2013
|
-
|
|
2014
|
-
|
|
2403
|
+
// the one user a by-name call means, or null (NOT_FOUND): an email is unique
|
|
2404
|
+
// (and wins when both are given), a display name counts only when exactly
|
|
2405
|
+
// one user has it
|
|
2406
|
+
resolveUser: async ({ name, email }) => {
|
|
2407
|
+
if (email !== undefined) {
|
|
2408
|
+
return (await db.user.findUnique({ where: { email }, select: { id: true } }))?.id;
|
|
2409
|
+
}
|
|
2410
|
+
if (name === undefined) {
|
|
2411
|
+
return null;
|
|
2412
|
+
}
|
|
2413
|
+
const named = await db.user.findMany({ where: { name }, select: { id: true }, take: 2 });
|
|
2414
|
+
return named.length === 1 ? named[0]?.id : null;
|
|
2415
|
+
},
|
|
2015
2416
|
// runs inside the change's transaction: its writes commit with it, a throw undoes it
|
|
2016
|
-
onChange: (change, ctx) => {
|
|
2417
|
+
onChange: async (change, ctx) => {
|
|
2017
2418
|
ctx.log.info("sharing changed", { kind: change.kind, id: change.id, user: change.userId });
|
|
2419
|
+
// the flush revokes their subscriptions, but rooms are the app's: a user whose entry or
|
|
2420
|
+
// membership is gone leaves the project's board on every node (removeFromBoard, rooms.ts)
|
|
2421
|
+
if (change.after === null) {
|
|
2422
|
+
await removeFromBoard(change.id, change.userId);
|
|
2423
|
+
}
|
|
2018
2424
|
},
|
|
2019
2425
|
}),
|
|
2020
2426
|
},
|
|
@@ -2022,8 +2428,9 @@ export const projectService = qd.defineService(project, {
|
|
|
2022
2428
|
```
|
|
2023
2429
|
|
|
2024
2430
|
- Access list methods: `share({ id, userId, level })` gives a user `Read`,
|
|
2025
|
-
`Moderate` or `Admin` (replacing
|
|
2026
|
-
the level of a user the row is shared
|
|
2431
|
+
`Moderate` or `Admin` (replacing a level they had, unless it is above the
|
|
2432
|
+
caller's), `setLevel` changes the level of a user the row is shared
|
|
2433
|
+
with, `unshare({ id, userId })`
|
|
2027
2434
|
takes it away, and `listShares({ id })` lists the row's shares; each
|
|
2028
2435
|
returns the list as `[{ userId, level }]`, one entry per user. Membership
|
|
2029
2436
|
methods: `invite({ entryId, userId, role? })` and
|
|
@@ -2032,22 +2439,32 @@ export const projectService = qd.defineService(project, {
|
|
|
2032
2439
|
`leave({ entryId })` return `null`, and
|
|
2033
2440
|
`listMembers({ entryId, cursor?, limit? })` pages the members in user id
|
|
2034
2441
|
order (50 by default, at most 200). `shareByName` and `inviteByName` take
|
|
2035
|
-
`name` or `email` instead of `userId`, and need `resolveUser
|
|
2036
|
-
|
|
2442
|
+
`name` or `email` instead of `userId`, and need `resolveUser`, which
|
|
2443
|
+
returns exactly one user's id or `null` (`NOT_FOUND`): an email by
|
|
2444
|
+
`findUnique`, a name only when exactly one user has it, as above, never
|
|
2445
|
+
the first of several (a `findFirst` by name shares with whoever
|
|
2446
|
+
registered that name first); a mode adds every method but those two
|
|
2447
|
+
unless `methods` names its own list.
|
|
2037
2448
|
- Who may call: a change needs `{ entry: "Admin" }` on the row, a list
|
|
2038
2449
|
`{ entry: "Read" }`, and `leave` a signed-in member (`FORBIDDEN` for
|
|
2039
2450
|
anyone else). `access: { share: { entry: "Moderate" } }` replaces one
|
|
2040
2451
|
method's form. Whatever the form, no caller shares, sets or invites at a
|
|
2041
|
-
level above their own on the row
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2452
|
+
level above their own on the row, nor changes or removes a user whose
|
|
2453
|
+
current level there is above their own (`FORBIDDEN`; a service-wide
|
|
2454
|
+
grant counts where the form names `service`; equal levels may, so one
|
|
2455
|
+
Admin changes another), and each change reads the caller's level again
|
|
2456
|
+
inside its transaction, refusing one lowered by a concurrent change
|
|
2457
|
+
(`FORBIDDEN`).
|
|
2045
2458
|
- The kit changes the list or the table the service's policy reads, alone
|
|
2046
2459
|
or inside `anyOf`, and takes their names from it: a service whose policy
|
|
2047
2460
|
has none for a mode the contract uses (or two) fails when it is defined.
|
|
2048
2461
|
Roles are the policy's `levels` keys, or the level names `Read`,
|
|
2049
2462
|
`Moderate` and `Admin` without it; another role is `VALIDATION`, and
|
|
2050
2463
|
`invite` without a role gives the lowest one that can read the row.
|
|
2464
|
+
Roles that map to one level are peers: with
|
|
2465
|
+
`{ owner: "Admin", admin: "Admin" }` an `admin` may demote or remove the
|
|
2466
|
+
`owner`, so protect an owner with an owner column
|
|
2467
|
+
(`anyOf(owner("ownerId"), members(...))`).
|
|
2051
2468
|
- The owner's access never changes (`CONFLICT`). A row keeps its last
|
|
2052
2469
|
Admin, counted across every policy of an `anyOf`: an owner column, an
|
|
2053
2470
|
`Admin` entry of the access list, an `Admin` member (so a project's owner
|
|
@@ -2065,6 +2482,17 @@ export const projectService = qd.defineService(project, {
|
|
|
2065
2482
|
the `via` collections over the table `added` and `removed`; the kit sends
|
|
2066
2483
|
nothing itself. The by-name methods tell a row's owner whether an account
|
|
2067
2484
|
exists; list them only where that is acceptable.
|
|
2485
|
+
- App rooms are not subscriptions: a user who loses access stays in the
|
|
2486
|
+
rooms a method joined them to until the app takes them out. The example's
|
|
2487
|
+
`onChange` does that when the change leaves the user with no entry or
|
|
2488
|
+
role (`change.after === null`): `removeFromBoard` takes their sockets out
|
|
2489
|
+
of the project's board on every node. With custom `members` `levels`, a
|
|
2490
|
+
role below the join's level must take the user out too. Under `anyOf` a user who still has
|
|
2491
|
+
the other list is taken out too, and joins again on their next connection
|
|
2492
|
+
(`useJoin`). `onChange` runs inside the change's transaction, before it
|
|
2493
|
+
commits: a join that runs before the commit (a check that still sees the
|
|
2494
|
+
old list) puts the user back until they disconnect, and a change whose
|
|
2495
|
+
commit fails has still taken them out until their next connection.
|
|
2068
2496
|
|
|
2069
2497
|
### Admin kit
|
|
2070
2498
|
|
|
@@ -2121,8 +2549,12 @@ export function AdminTasks() {
|
|
|
2121
2549
|
const { services } = useAdminServices(qd);
|
|
2122
2550
|
const { data } = qd.task.admin.adminList.useQuery({ page: 1, sort: { field: "title" } });
|
|
2123
2551
|
const update = qd.task.admin.adminUpdate.useMutation({
|
|
2124
|
-
//
|
|
2125
|
-
|
|
2552
|
+
// the kit's reads are queries, not live data: after this screen's own write,
|
|
2553
|
+
// read the page again and put the row the write returned in adminGet's cache
|
|
2554
|
+
onSuccess: (row) => {
|
|
2555
|
+
qd.invalidate(qd.task.admin.adminList);
|
|
2556
|
+
qd.task.admin.adminGet.setData({ id: row.id }, row);
|
|
2557
|
+
},
|
|
2126
2558
|
});
|
|
2127
2559
|
return (
|
|
2128
2560
|
<table aria-label={services[0]?.displayName}>
|
|
@@ -2169,8 +2601,10 @@ export function AdminTasks() {
|
|
|
2169
2601
|
service-wide `Admin` grant, nor are the columns the policy reads, and a
|
|
2170
2602
|
row moves to another parent only with the row level on it (`FORBIDDEN`,
|
|
2171
2603
|
as for the read/write kit); each value is checked by the entity schema
|
|
2172
|
-
itself, and a value the database refuses is `VALIDATION`. A
|
|
2173
|
-
|
|
2604
|
+
itself, and a value the database refuses is `VALIDATION`. A relation of
|
|
2605
|
+
the model (a key a projection selects with a nested `select`) or a nested
|
|
2606
|
+
write (`{ connect }`, `{ update }`) is `VALIDATION`, for every caller: the
|
|
2607
|
+
kit writes column values only. A missing row is `NOT_FOUND`.
|
|
2174
2608
|
- `editable: ["title", "status"]` names the only fields `adminCreate` and
|
|
2175
2609
|
`adminUpdate` write: `adminMeta` reports every other field
|
|
2176
2610
|
`editable: false`, so a generic form leaves it read-only, and a write
|
|
@@ -2180,8 +2614,10 @@ export function AdminTasks() {
|
|
|
2180
2614
|
list, so the writes' input checks, types and JSON Schema (and the MCP
|
|
2181
2615
|
tools made from it) name only those fields; given to both halves, the
|
|
2182
2616
|
two lists must name the same fields. Without either list the kit writes
|
|
2183
|
-
what it wrote in 5.0.0: every field it shows but `id
|
|
2184
|
-
less those an override made read-only.
|
|
2617
|
+
what it wrote in 5.0.0: every field it shows but `id`, the timestamps and
|
|
2618
|
+
the model's relations, less those an override made read-only. An `editable` list, or a
|
|
2619
|
+
`fieldOverrides` entry with `editable: true`, that names a relation of the
|
|
2620
|
+
model fails when the service is defined.
|
|
2185
2621
|
- `adminMeta()` returns `{ serviceName, displayName, fields }`, one
|
|
2186
2622
|
`{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService?, kind?, showInForm? }`
|
|
2187
2623
|
per field: `type` is
|
|
@@ -2190,11 +2626,19 @@ export function AdminTasks() {
|
|
|
2190
2626
|
`sortable` and `filterable` are the declared fields (`filterable` is
|
|
2191
2627
|
optional in the `AdminFieldConfig` type, default `false`, so field
|
|
2192
2628
|
configurations written for 4.x still type); `id` and the
|
|
2193
|
-
timestamps come first and are not editable;
|
|
2194
|
-
`
|
|
2629
|
+
timestamps come first and are not editable; a relation of the model (a
|
|
2630
|
+
field a projection selects with a nested `select`) is `editable: false`;
|
|
2631
|
+
`acl`, `serviceAccess` and `service_access` are hidden, and so is the
|
|
2632
|
+
column `auth.serviceAccessSource` (a bare dispatcher's
|
|
2633
|
+
`serviceAccessSource`) names on this service's model, whatever its name.
|
|
2195
2634
|
- Grants: on a users service, `admin.handlers(user, { grants: true })`
|
|
2196
|
-
shows and writes the entity's
|
|
2197
|
-
|
|
2635
|
+
shows and writes the entity's grants field (`serviceAccess`, or the
|
|
2636
|
+
column `auth.serviceAccessSource` names, whatever its name and type; the
|
|
2637
|
+
entity schema checks what is written), so the admin screen edits grants
|
|
2638
|
+
through `adminUpdate`. Options that clash with that column (hiding it
|
|
2639
|
+
beside `grants: true`, or naming it in `editable`, `fieldOverrides`, a
|
|
2640
|
+
filter or a sort without), and `grants: true` on an entity with no grants
|
|
2641
|
+
field, fail when the server is made.
|
|
2198
2642
|
Only a caller whose own service-wide grant on that service is `Admin`
|
|
2199
2643
|
reads or writes it, whatever `access` gives the method: anyone else gets
|
|
2200
2644
|
rows without it and `FORBIDDEN` for a write, filter or sort naming it.
|
|
@@ -2217,10 +2661,61 @@ before?, after }, ctx, db)` after each `adminCreate`, `adminUpdate` and
|
|
|
2217
2661
|
unit of work of its own: the reply does not wait for it, its writes flush
|
|
2218
2662
|
on their own, and a throw is logged, the write standing. Apply an edit to
|
|
2219
2663
|
what runs in memory (a game's simulation) there, so an edit that rolled
|
|
2220
|
-
back is never applied. Without
|
|
2221
|
-
Each handler `admin.handlers` returns resolves
|
|
2222
|
-
type (`AdminOutputOf<C, M>`), so a wrapper reads
|
|
2223
|
-
with no cast.
|
|
2664
|
+
back is never applied. Without a hook (these two or `prepare`, below) the
|
|
2665
|
+
kit opens no transaction. Each handler `admin.handlers` returns resolves
|
|
2666
|
+
with its method's output type (`AdminOutputOf<C, M>`), so a wrapper reads
|
|
2667
|
+
the row and returns it with no cast.
|
|
2668
|
+
- `admin.handlers(contract, { prepare })` runs `prepare(call, ctx, db)`
|
|
2669
|
+
before each `adminCreate`, `adminUpdate` and `adminDelete`, once the
|
|
2670
|
+
caller's access and data were checked, in one transaction with the write
|
|
2671
|
+
and `onWrite` (`db` is the transaction's tracked client; a throw undoes
|
|
2672
|
+
its writes and the write, and fails the call). `call` is
|
|
2673
|
+
`{ method: "adminCreate", data }`, `{ method: "adminUpdate", id, data, before }`
|
|
2674
|
+
or `{ method: "adminDelete", id, before }`: `data` is the caller's, and
|
|
2675
|
+
`before` the row, every field, as `onWrite` hears it. Before a create or
|
|
2676
|
+
an update it may return column values, written over the caller's data:
|
|
2677
|
+
a column the server computes (a hash, a slug, the next ordinal), even one
|
|
2678
|
+
the caller may not write (hidden, not editable, above their level), but
|
|
2679
|
+
never `id`, a timestamp, a relation or a nested write (`VALIDATION`). An
|
|
2680
|
+
anchor column it sets (a parent's id, an anchored collection's scope)
|
|
2681
|
+
needs the method's row level on the row it names, as the caller's data
|
|
2682
|
+
and the read/write kit's `prepare` do (`FORBIDDEN`), so a lowered form
|
|
2683
|
+
never moves a row into a parent its caller cannot reach. Returning
|
|
2684
|
+
nothing, or `undefined` for a key, keeps the caller's data, and what it
|
|
2685
|
+
returns before a delete is ignored. The tracked client never sees
|
|
2686
|
+
what the database deletes or clears with a row (`onDelete: Cascade`,
|
|
2687
|
+
`SetNull`), so those rows stay in the lists and entities open on them;
|
|
2688
|
+
delete or clear them through `db` in `prepare`, and their subscribers are
|
|
2689
|
+
told. `prepare` shares the write's transaction, and so its time limit
|
|
2690
|
+
(Prisma's default is 5 seconds; a client's `transactionOptions` sets
|
|
2691
|
+
another):
|
|
2692
|
+
|
|
2693
|
+
<!-- example: apps/api/src/services/kits/admin.ts#prepare -->
|
|
2694
|
+
|
|
2695
|
+
```ts
|
|
2696
|
+
import type { TrackedTransaction } from "@fitzzero/quickdraw-core/prisma";
|
|
2697
|
+
import { nextOrdinal } from "@fitzzero/quickdraw-core/server";
|
|
2698
|
+
import type { PrismaClient } from "@project/db";
|
|
2699
|
+
|
|
2700
|
+
export const taskAdmin = admin.handlers(task, {
|
|
2701
|
+
editable: ["projectId", "title", "status"],
|
|
2702
|
+
// runs before each write, in its transaction: `db` is that transaction's tracked client
|
|
2703
|
+
prepare: async (call, _ctx, db: TrackedTransaction<PrismaClient>) => {
|
|
2704
|
+
if (call.method === "adminCreate" && call.data.projectId !== undefined) {
|
|
2705
|
+
// written over the caller's data, a read-only column too: the new task goes last
|
|
2706
|
+
return { ordinal: await nextOrdinal(db, "task", { projectId: call.data.projectId }) };
|
|
2707
|
+
}
|
|
2708
|
+
if (call.method === "adminDelete") {
|
|
2709
|
+
// the database deletes the task's label links and clears its subtasks' parent,
|
|
2710
|
+
// unseen: do both here, tracked, so the rows and lists open on them are told
|
|
2711
|
+
await db.taskLabel.deleteMany({ where: { taskId: call.id } });
|
|
2712
|
+
await db.task.updateMany({ where: { parentTaskId: call.id }, data: { parentTaskId: null } });
|
|
2713
|
+
}
|
|
2714
|
+
return undefined;
|
|
2715
|
+
},
|
|
2716
|
+
});
|
|
2717
|
+
```
|
|
2718
|
+
|
|
2224
2719
|
- `adminSubscribers({ id })` counts the sockets subscribed to a row per
|
|
2225
2720
|
access level (`{ id, count, levels, complete }`; behind a Redis adapter
|
|
2226
2721
|
the counts are this server's and `complete` is `false`), and
|
|
@@ -2237,14 +2732,20 @@ before?, after }, ctx, db)` after each `adminCreate`, `adminUpdate` and
|
|
|
2237
2732
|
is not asked again until the user's grant on it changes, reconnects
|
|
2238
2733
|
included.
|
|
2239
2734
|
- The kit's rows are not live: `adminList` and `adminGet` are queries that
|
|
2240
|
-
watch nothing, so a screen reads
|
|
2735
|
+
watch nothing, so after its own write a screen reads the page again and
|
|
2736
|
+
puts the row the write returned in `adminGet`'s cache with `setData` (the
|
|
2241
2737
|
`onSuccess` above), and sees other admins' writes when it next reads.
|
|
2738
|
+
Their `useQuery`, and `adminSubscribers`', defaults to `staleTime: 0`, so a
|
|
2739
|
+
screen reads them again whenever it mounts (pass `staleTime` to keep a
|
|
2740
|
+
result longer; `adminMeta` keeps the provider's five minutes).
|
|
2242
2741
|
- A screen that serves every service from its metadata (the route names the
|
|
2243
2742
|
service) takes `adminOf(qd, key)`: the same members, keyed by what the kit
|
|
2244
2743
|
made them for and typed by field name (`AdminScreen`: rows are
|
|
2245
2744
|
`AdminRow`, `adminList` takes `{ page, pageSize, filter, sort: { field, direction } }`
|
|
2246
2745
|
with names read from `adminMeta`), so no cast is needed over a union of
|
|
2247
|
-
keys; a method the contract does not expose is absent.
|
|
2746
|
+
keys; a method the contract does not expose is absent. Its query members
|
|
2747
|
+
have `setData` too: `admin.adminGet?.setData({ id: row.id }, row)` shows
|
|
2748
|
+
the row `adminUpdate` or `adminCreate` returned, with no read.
|
|
2248
2749
|
|
|
2249
2750
|
### Presence, streams and channels
|
|
2250
2751
|
|
|
@@ -2325,9 +2826,9 @@ export const taskService = qd.defineService(task, {
|
|
|
2325
2826
|
},
|
|
2326
2827
|
},
|
|
2327
2828
|
channels: {
|
|
2328
|
-
// relay each cursor to the board's room
|
|
2829
|
+
// relay each cursor to the board's room, the one its requires matched (ctx.room)
|
|
2329
2830
|
cursor: (payload, ctx) => {
|
|
2330
|
-
ctx.rooms.emit(
|
|
2831
|
+
ctx.rooms.emit(ctx.room, task, "cursorMoved", payload);
|
|
2331
2832
|
},
|
|
2332
2833
|
},
|
|
2333
2834
|
// once per socket that leaves app rooms, in every server this service runs in, in a unit of its own
|
|
@@ -2347,6 +2848,17 @@ export function logLine(taskId: string, line: string): void {
|
|
|
2347
2848
|
qd.stream(task, "logs").push(taskId, { line });
|
|
2348
2849
|
}
|
|
2349
2850
|
|
|
2851
|
+
// a job skips the work nobody watches: the feed's subscribers on every node
|
|
2852
|
+
export async function tailLogs(taskId: string, read: () => Promise<string[]>): Promise<void> {
|
|
2853
|
+
if ((await qd.stream(task, "logs").subscribers(taskId)) > 0) {
|
|
2854
|
+
const lines = await read();
|
|
2855
|
+
qd.stream(task, "logs").pushMany(
|
|
2856
|
+
taskId,
|
|
2857
|
+
lines.map((line) => ({ line })),
|
|
2858
|
+
);
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2350
2862
|
export async function isOnline(userId: string): Promise<boolean> {
|
|
2351
2863
|
// also ctx.presence and server.presence
|
|
2352
2864
|
return await qd.presence.isOnline(userId);
|
|
@@ -2395,17 +2907,37 @@ export function TaskRoom({
|
|
|
2395
2907
|
out as its own frame, in order; use it rather than `push` in a loop
|
|
2396
2908
|
(`no-emit-in-loop`). `qd:stream:sub` is authorized with the
|
|
2397
2909
|
stream's `access` through the access engine, the scope being the row an
|
|
2398
|
-
`entry` or `scope` form checks; `access: { room }`
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2910
|
+
`entry` or `scope` form checks; `access: { room }` opens it to the
|
|
2911
|
+
sockets in an app room instead, signed in or not, and a socket that
|
|
2912
|
+
leaves the room, or is taken out, leaves the feed; a stream without
|
|
2913
|
+
`access` is closed. A global stream names the room, or `{ prefix }` for
|
|
2914
|
+
any room whose name starts with it; a scoped stream computes it from the
|
|
2915
|
+
scope, ``(worldId) => `world:${worldId}` ``, because a name or a prefix
|
|
2916
|
+
would let a socket in that room read every scope (`defineContract`
|
|
2917
|
+
refuses both on a scoped stream, by type and when it runs). The answer is the seed; `useStream` then appends, keeps the
|
|
2403
2918
|
latest `max` (default 500), and subscribes again after a reconnect, when
|
|
2404
|
-
the seed replaces what it held. A
|
|
2919
|
+
the seed replaces what it held. A subscribe an access change overlapped
|
|
2920
|
+
is authorized again before it joins the feed, with the socket's
|
|
2921
|
+
principal as it is then, and answered `FORBIDDEN` when it lost access;
|
|
2922
|
+
nothing pushed meanwhile reaches it as a frame, and its seed is read as
|
|
2923
|
+
it joins. A subscribe an unsubscribe overtook answers `seed: []`. A
|
|
2924
|
+
socket holds at most 500 feeds. A
|
|
2405
2925
|
subscriber whose access is lowered is authorized again; one refused
|
|
2406
2926
|
leaves the feed and gets
|
|
2407
2927
|
`qd:revoked { kind: "stream", reason: "access", s, stream, scope? }`, and
|
|
2408
2928
|
`useStream` shows `FORBIDDEN` until the next connect.
|
|
2929
|
+
- A job skips the work nobody watches (`tailLogs` above):
|
|
2930
|
+
`await qd.stream(task, "logs").subscribers(taskId)` (`subscribers()` for
|
|
2931
|
+
a global stream; also on `server.stream` and `dispatcher.stream`) is how
|
|
2932
|
+
many sockets subscribe to the feed, on every node, anonymous ones
|
|
2933
|
+
included: the sockets a push reaches. Behind a cluster adapter the other
|
|
2934
|
+
nodes answer one request with a count each, never their sockets; an
|
|
2935
|
+
answer that does not arrive within `cluster.timeoutMs` counts as 0
|
|
2936
|
+
(warned at most once every 10 minutes), and so does a node of an earlier
|
|
2937
|
+
version. `qd.rooms` refuses the stream rooms' reserved `qd:` names, and
|
|
2938
|
+
Socket.IO's own rooms hold one node's sockets: count with `subscribers`.
|
|
2939
|
+
A host-served stream's handle refuses it, as it refuses `push`: only the
|
|
2940
|
+
host's client knows who subscribes.
|
|
2409
2941
|
- A service's `streams: { <name>: { seed, validate } }` (type
|
|
2410
2942
|
`StreamImplementation`) computes a stream's seed per subscriber, `seed: (scope, ctx) => items` (the current state,
|
|
2411
2943
|
where the items that follow are deltas; on whichever node the subscriber
|
|
@@ -2433,20 +2965,40 @@ export function TaskRoom({
|
|
|
2433
2965
|
sending socket's own: a room another socket of the user joined does not
|
|
2434
2966
|
count, a reconnected socket must join again, and behind a cluster the
|
|
2435
2967
|
check runs on the node the socket is connected to, with no round trip.
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2968
|
+
A channel without `requires` takes a message from every signed-in socket,
|
|
2969
|
+
about any row or room its payload names: a handler that relays it to a
|
|
2970
|
+
room (``ctx.rooms.emit(`project:${payload.projectId}`, ...)``) lets any
|
|
2971
|
+
user reach that room. The server warns `channel-without-requires` for
|
|
2972
|
+
each such channel when it starts (a strict test app does not start). Give
|
|
2973
|
+
the channel a `requires` and relay to `ctx.room`, the room it matched; or,
|
|
2974
|
+
when any signed-in user may send it, say so on the service, `channels: {
|
|
2975
|
+
ping: { access: "authenticated", handler } }`, which delivers as before
|
|
2976
|
+
and silences the warning.
|
|
2977
|
+
- Presence: `isOnline` and `lastSeen` (now while online, else when the
|
|
2978
|
+
user's last socket on this process disconnected) come from this process's
|
|
2979
|
+
sockets, whatever rooms they are in; `count` and `users` (each user once,
|
|
2980
|
+
anonymous sockets left out; app rooms only) come from this process's
|
|
2981
|
+
records of the rooms `ctx.rooms.join` joined (a socket a raw
|
|
2982
|
+
`socket.join` put in a room is not counted). Behind a Redis adapter each
|
|
2983
|
+
also asks the other nodes, which answer from the same records with user
|
|
2984
|
+
ids alone (or whether they hold a socket of the user); concurrent reads
|
|
2985
|
+
of one room or user share one request.
|
|
2440
2986
|
`ctx.rooms.join(room)` and `leave` put the calling socket in an app room
|
|
2441
2987
|
(calls without a socket get `false`; names starting with `qd:` or `user:`
|
|
2442
|
-
are refused with `VALIDATION
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2988
|
+
are refused with `VALIDATION`, and so are names with the shape of a
|
|
2989
|
+
Socket.IO socket id, 20 letters, digits, `_` or `-`, because every socket
|
|
2990
|
+
is alone in the room of its id: give such an id a prefix, `doc:${id}`; at
|
|
2991
|
+
most 100 per socket; a method that shares its runs, `share`, may not join
|
|
2992
|
+
or leave with its caller's socket: `INTERNAL`), and the room's sockets get
|
|
2993
|
+
`qd:presence` frames: the list on joining, then who joins and who leaves.
|
|
2994
|
+
`usePresence(room)` shows them. `rooms.size`, `rooms.leave` and presence
|
|
2995
|
+
refuse the same names.
|
|
2446
2996
|
- Events: `ctx.rooms.emit(room, contract, event, payload)` and
|
|
2447
2997
|
`emitToUser(userId, ...)` check the payload first (`INTERNAL`, nothing
|
|
2448
2998
|
sent, when it fails), then send the validated payload as
|
|
2449
|
-
`qd:event [service, event, payload]`; `useEvent` hears them.
|
|
2999
|
+
`qd:event [service, event, payload]`; `useEvent` hears them. `emit`
|
|
3000
|
+
refuses the framework's rooms (`qd:`, `user:`) with `VALIDATION`: a
|
|
3001
|
+
user's sockets are reached with `emitToUser`.
|
|
2450
3002
|
- Rooms belong to a socket: a reconnect (a lost network, `qd:rotate`, new
|
|
2451
3003
|
credentials) is a new socket in no room, which hears none of its events
|
|
2452
3004
|
and whose channel messages requiring it are dropped. `useJoin(member,
|
|
@@ -2474,8 +3026,9 @@ export function showCursor(projectId: string, taskId: string, x: number): void {
|
|
|
2474
3026
|
qd.rooms.emit(boardRoom(projectId), task, "cursorMoved", { projectId, taskId, x });
|
|
2475
3027
|
}
|
|
2476
3028
|
|
|
2477
|
-
// a member removed from the project
|
|
2478
|
-
// nothing more of it and its cursor channel drops their
|
|
3029
|
+
// a member removed from the project (the sharing kit's onChange calls it): their sockets leave
|
|
3030
|
+
// its board on every node, so they hear nothing more of it and its cursor channel drops their
|
|
3031
|
+
// messages (also ctx.rooms.leave)
|
|
2479
3032
|
export async function removeFromBoard(projectId: string, userId: string): Promise<void> {
|
|
2480
3033
|
await qd.rooms.leave(boardRoom(projectId), { userId });
|
|
2481
3034
|
}
|
|
@@ -2519,6 +3072,440 @@ export function hasAudience(projectId: string): boolean {
|
|
|
2519
3072
|
user leaving two nodes at the same moment may both see the other (a
|
|
2520
3073
|
removal never misses: each node reports its own last socket).
|
|
2521
3074
|
|
|
3075
|
+
### Peer links
|
|
3076
|
+
|
|
3077
|
+
Two players of one app room can link their clients: WebRTC data channels
|
|
3078
|
+
between them, or the server's relay when those cannot form (a browser
|
|
3079
|
+
behind a symmetric NAT, Node, a headless game client), with the same
|
|
3080
|
+
messages either way (design: section 12.5). A server opts in with
|
|
3081
|
+
`createServer({ peers: true })`: it forwards the signals that set a link
|
|
3082
|
+
up and relays its messages, only between two signed-in sockets of the
|
|
3083
|
+
room, and never reads what a link carries. A link has two channels: `rel`,
|
|
3084
|
+
ordered and reliable (state, chat), and `fast`, unordered and never resent
|
|
3085
|
+
(input, positions). The app tells players each other's socket ids, here
|
|
3086
|
+
with an event:
|
|
3087
|
+
|
|
3088
|
+
<!-- example: apps/api/src/services/kits/peers.ts#service -->
|
|
3089
|
+
|
|
3090
|
+
```ts
|
|
3091
|
+
// on a server created with peers: true, which forwards the links' signals and relays their messages
|
|
3092
|
+
export const matchService = qd.defineService(match, {
|
|
3093
|
+
methods: {
|
|
3094
|
+
enterMatch: {
|
|
3095
|
+
// any signed-in user here: a real game checks the caller's seat in the match first
|
|
3096
|
+
access: "authenticated",
|
|
3097
|
+
handler: ({ input, ctx }) => {
|
|
3098
|
+
const room = `match:${input.matchId}`;
|
|
3099
|
+
const joined = ctx.rooms.join(room);
|
|
3100
|
+
if (joined && ctx.socketId !== undefined) {
|
|
3101
|
+
// the socket's own id, which a peer links to
|
|
3102
|
+
ctx.rooms.emit(room, match, "playerJoined", {
|
|
3103
|
+
matchId: input.matchId,
|
|
3104
|
+
userId: ctx.principal.userId,
|
|
3105
|
+
socketId: ctx.socketId,
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
return joined;
|
|
3109
|
+
},
|
|
3110
|
+
},
|
|
3111
|
+
},
|
|
3112
|
+
});
|
|
3113
|
+
```
|
|
3114
|
+
|
|
3115
|
+
<!-- example: apps/web/src/components/kits/MatchLinks.tsx#hook -->
|
|
3116
|
+
|
|
3117
|
+
```tsx
|
|
3118
|
+
function track(links: Map<string, PeerLink>, link: PeerLink, onMove: { current: OnMove }): void {
|
|
3119
|
+
links.set(link.peer, link);
|
|
3120
|
+
link.on("message", (data) => {
|
|
3121
|
+
// a peer's message is its word, never checked by the server: parse it as any input
|
|
3122
|
+
const move = moveSchema.safeParse(data);
|
|
3123
|
+
// userId: the peer's user, as the server named it (known once the link is open)
|
|
3124
|
+
if (move.success && link.userId !== undefined) {
|
|
3125
|
+
onMove.current(link.userId, move.data);
|
|
3126
|
+
}
|
|
3127
|
+
});
|
|
3128
|
+
link.on("close", () => links.delete(link.peer));
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
// each player of a match linked to every other: send a move to all of them, hear theirs
|
|
3132
|
+
export function useMatchLinks(matchId: string, onMove: OnMove): (move: Move) => void {
|
|
3133
|
+
const { connection } = useQuickdraw();
|
|
3134
|
+
const room = `match:${matchId}`;
|
|
3135
|
+
const [links] = useState(() => new Map<string, PeerLink>());
|
|
3136
|
+
const heard = useRef(onMove);
|
|
3137
|
+
useEffect(() => {
|
|
3138
|
+
heard.current = onMove;
|
|
3139
|
+
});
|
|
3140
|
+
// on every connection: a link lives as long as its socket, and a reconnect is a new socket
|
|
3141
|
+
useJoin(qd.match.enterMatch, { matchId });
|
|
3142
|
+
// a player who joins later: this side offers (when both offer, the link settles who answers)
|
|
3143
|
+
qd.match.playerJoined.useEvent((player) => {
|
|
3144
|
+
const self = connection.socket.id;
|
|
3145
|
+
if (player.matchId === matchId && player.socketId !== self && !links.has(player.socketId)) {
|
|
3146
|
+
track(links, createPeerLink(connection, { room, to: player.socketId, offerer: true }), heard);
|
|
3147
|
+
}
|
|
3148
|
+
});
|
|
3149
|
+
// the players here before: they offer, this side answers
|
|
3150
|
+
useEffect(
|
|
3151
|
+
() =>
|
|
3152
|
+
onPeerRequest(connection, (request) => {
|
|
3153
|
+
if (request.room === room) {
|
|
3154
|
+
track(links, request.accept(), heard);
|
|
3155
|
+
}
|
|
3156
|
+
}),
|
|
3157
|
+
[connection, room, links],
|
|
3158
|
+
);
|
|
3159
|
+
useEffect(
|
|
3160
|
+
() => () => {
|
|
3161
|
+
for (const link of links.values()) {
|
|
3162
|
+
link.close();
|
|
3163
|
+
}
|
|
3164
|
+
},
|
|
3165
|
+
[connection, room, links],
|
|
3166
|
+
);
|
|
3167
|
+
// direct when WebRTC connects, the server's relay when it cannot; fast: unordered, never resent
|
|
3168
|
+
return (move) => {
|
|
3169
|
+
for (const link of links.values()) {
|
|
3170
|
+
link.send("fast", move);
|
|
3171
|
+
}
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
```
|
|
3175
|
+
|
|
3176
|
+
- `createPeerLink(connection, { room, to, offerer })` (from `./client`, or
|
|
3177
|
+
`./client/core` without React) opens a link to socket `to` in app room `room`. One side
|
|
3178
|
+
offers; when both do (each heard of the other at once), the side whose
|
|
3179
|
+
socket id sorts after the other's answers instead.
|
|
3180
|
+
`onPeerRequest(connection, listener)` hears a socket of the room that
|
|
3181
|
+
signals this one with no link yet (`{ room, from, userId }`):
|
|
3182
|
+
`request.accept(options?)` answers with a link, `request.reject()`
|
|
3183
|
+
refuses it, and a request nobody answers is dropped after 15 s.
|
|
3184
|
+
- A link has `state` (`connecting`, `open`, `closed`), `path` (`direct` or
|
|
3185
|
+
`relay`), `peer` and `userId` (the peer's socket and user, as the server
|
|
3186
|
+
named them), `on("open" | "message" | "state" | "close", listener)` and
|
|
3187
|
+
`close()`, which tells the peer. `send(label, data)` takes any JSON value
|
|
3188
|
+
of at most 64 KiB as UTF-8 JSON (a `RangeError` above) and returns false
|
|
3189
|
+
when it dropped it: the link is not open, `fast` is backed up, or the
|
|
3190
|
+
relay's rate is spent (`stats` counts what was sent, received and
|
|
3191
|
+
dropped). `closeReason` says why it closed: `closed` (this side), `bye`
|
|
3192
|
+
(the peer closed it, or left the room, was taken out of it or
|
|
3193
|
+
disconnected: the server says so at once), `left` (this socket left the
|
|
3194
|
+
room), `disconnected` (a link lives as long as its socket: open it again
|
|
3195
|
+
after a reconnect), `timeout` (nothing opened within 15 s), `lost`
|
|
3196
|
+
(nothing heard for 8 s, or a `relay: "never"` link's direct path
|
|
3197
|
+
dropped), `failed`, `refused` (the server refused a
|
|
3198
|
+
signal) or `unsupported` (a server without `peers`).
|
|
3199
|
+
- The direct path comes first, over the platform's `RTCPeerConnection`
|
|
3200
|
+
(`peerConnection` takes another, such as react-native-webrtc's;
|
|
3201
|
+
`iceServers` defaults to Google's and Cloudflare's public STUN servers,
|
|
3202
|
+
with no TURN). The relay takes over when ICE fails, when the direct path
|
|
3203
|
+
does not open within 5 s or drops after it opened (`rel` messages in
|
|
3204
|
+
flight on it may be lost: the link emits `state`, so the app can
|
|
3205
|
+
resync; when a data channel closes, the link first waits 2 s for the
|
|
3206
|
+
peer's bye, since a peer that closes its link closes its channels too),
|
|
3207
|
+
and at once on a side without WebRTC or with
|
|
3208
|
+
`relay: "always"`; `relay: "never"` closes the link instead. A link never
|
|
3209
|
+
moves back from the relay.
|
|
3210
|
+
- `link.clock` reads the peer's clock from pings on `fast` (six quick ones
|
|
3211
|
+
as the link opens, then one every 500 ms): `now()` (the peer's clock, in
|
|
3212
|
+
its milliseconds), `offset`, `rtt`, `jitter` and `synced`.
|
|
3213
|
+
- The server's limits: a socket signals at most 64 peers at once
|
|
3214
|
+
(`CONFLICT`) and 20 signals a second (a burst of 40; `RATE_LIMITED`).
|
|
3215
|
+
It relays only between two sockets that signalled each other, at most
|
|
3216
|
+
120 messages a second to each peer (a burst of 240) and 1 MiB a second
|
|
3217
|
+
in all (a burst of 2 MiB), and drops the rest silently; the client keeps
|
|
3218
|
+
under these itself, from the hello's `limits.peers`. A socket that keeps
|
|
3219
|
+
flooding either is disconnected, as for channels. A socket that leaves a
|
|
3220
|
+
room, is taken out of it or disconnects ends its links there: the server
|
|
3221
|
+
tells each peer it signalled, and each that signalled it. Behind a
|
|
3222
|
+
cluster adapter the node that holds a socket checks it, and the relay
|
|
3223
|
+
crosses nodes.
|
|
3224
|
+
- What a peer sends is the peer's word: the server checks who may link,
|
|
3225
|
+
never what a link carries. Parse a link's messages as any input, and
|
|
3226
|
+
check on the server whatever a client reports there.
|
|
3227
|
+
- Tests: `createLoopbackPeerLink({ ms, jitter, loss })` (from `./testing`)
|
|
3228
|
+
links two ends in one process, with a delay and jitter per message and
|
|
3229
|
+
loss on `fast` alone; its ends take `setLag` too.
|
|
3230
|
+
|
|
3231
|
+
A browser test, a bench or a host plays a link over a bad network on its
|
|
3232
|
+
real path, direct or relay, with `setLag`:
|
|
3233
|
+
|
|
3234
|
+
<!-- example: apps/web/src/components/kits/MatchLinks.tsx#lag -->
|
|
3235
|
+
|
|
3236
|
+
```tsx
|
|
3237
|
+
// a browser test or a bench plays the match over a bad network, on the links' real paths: each
|
|
3238
|
+
// holds every message 150 ms plus up to 40 ms more, each way, and loses 5% of fast ones
|
|
3239
|
+
export function playOverBadNetwork(links: readonly PeerLink[]): () => void {
|
|
3240
|
+
for (const link of links) {
|
|
3241
|
+
link.setLag({ ms: 150, jitter: 40, loss: 0.05 });
|
|
3242
|
+
}
|
|
3243
|
+
// rel messages are never lost and keep their order; clock.rtt now reads about 300 ms more
|
|
3244
|
+
return () => {
|
|
3245
|
+
for (const link of links) {
|
|
3246
|
+
// what the lag holds goes on the next task, in order
|
|
3247
|
+
link.setLag(null);
|
|
3248
|
+
}
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
```
|
|
3252
|
+
|
|
3253
|
+
- `link.setLag({ ms, jitter, loss })` holds every message of the link,
|
|
3254
|
+
both ways, `ms` plus up to `jitter` milliseconds, and loses a `fast` one
|
|
3255
|
+
with probability `loss`, from 0 to 1: `stats.simulatedLoss` counts the
|
|
3256
|
+
app's, and `dropped` still counts only what the link dropped. `rel`
|
|
3257
|
+
messages are never lost and keep their order; a held one its path
|
|
3258
|
+
refuses as it comes due (the relay's rate spent) goes again a little
|
|
3259
|
+
later, and `send` on `rel` returns false meanwhile. `send` returns true
|
|
3260
|
+
for a message it holds, and `stats.sent` counts it once a path took it.
|
|
3261
|
+
The clock's pings and host mode's messages wait too, so `link.clock.rtt`
|
|
3262
|
+
reads the lagged round trip. A new lag leaves what is held on its time;
|
|
3263
|
+
`setLag(null)` (or all zeros) ends it, and what is held goes on the next
|
|
3264
|
+
task, in order. `link.lag` reads it, and `random` takes a seeded
|
|
3265
|
+
generator, so a run repeats. The server never sees the lag.
|
|
3266
|
+
|
|
3267
|
+
### Host mode
|
|
3268
|
+
|
|
3269
|
+
One player's client can run a room's authoritative simulation as the
|
|
3270
|
+
room's host (design: section 12.5). A contract declares the channels and
|
|
3271
|
+
streams the host serves (`serve: "host"`): a player's messages and feeds
|
|
3272
|
+
of them go to the room's host over the player's link, and never reach the
|
|
3273
|
+
server. The server only says who hosts: a method makes the calling socket
|
|
3274
|
+
the room's host (`ctx.rooms.setHost(room)`), and each socket of the room
|
|
3275
|
+
hears it (`qd:host`). Host mode runs over peer links, so the server needs
|
|
3276
|
+
`peers: true` (`createServer` throws without it):
|
|
3277
|
+
|
|
3278
|
+
<!-- example: packages/shared/src/kits/host.ts#contract -->
|
|
3279
|
+
|
|
3280
|
+
```ts
|
|
3281
|
+
export const arena = defineContract("arenaService", {
|
|
3282
|
+
describe: "Arenas whose simulation one player's client runs, as the arena's host.",
|
|
3283
|
+
methods: {
|
|
3284
|
+
enterArena: mutation({
|
|
3285
|
+
input: z.object({ arenaId: z.string() }),
|
|
3286
|
+
output: z.boolean(),
|
|
3287
|
+
describe: "Joins the caller's socket to an arena's room.",
|
|
3288
|
+
}),
|
|
3289
|
+
hostArena: mutation({
|
|
3290
|
+
input: z.object({ arenaId: z.string() }),
|
|
3291
|
+
output: z.boolean(),
|
|
3292
|
+
describe: "Makes the caller's socket the arena's host, while it has none.",
|
|
3293
|
+
}),
|
|
3294
|
+
},
|
|
3295
|
+
channels: {
|
|
3296
|
+
// a player's input: to the arena's host over the player's link, never to the server
|
|
3297
|
+
steer: {
|
|
3298
|
+
payload: z.object({
|
|
3299
|
+
arenaId: z.string(),
|
|
3300
|
+
dx: z.number().min(-1).max(1),
|
|
3301
|
+
dy: z.number().min(-1).max(1),
|
|
3302
|
+
}),
|
|
3303
|
+
ratePerSecond: 30,
|
|
3304
|
+
requires: { room: (steer) => arenaRoom(steer.arenaId) },
|
|
3305
|
+
serve: "host",
|
|
3306
|
+
},
|
|
3307
|
+
},
|
|
3308
|
+
streams: {
|
|
3309
|
+
// the host's world, to each player of the arena: the latest snapshot seeds a newcomer
|
|
3310
|
+
world: {
|
|
3311
|
+
item: snapshotSchema,
|
|
3312
|
+
scope: "arenaId",
|
|
3313
|
+
seed: 1,
|
|
3314
|
+
volatile: true,
|
|
3315
|
+
access: { room: arenaRoom },
|
|
3316
|
+
serve: "host",
|
|
3317
|
+
},
|
|
3318
|
+
},
|
|
3319
|
+
});
|
|
3320
|
+
```
|
|
3321
|
+
|
|
3322
|
+
<!-- example: apps/api/src/services/kits/host.ts#service -->
|
|
3323
|
+
|
|
3324
|
+
```ts
|
|
3325
|
+
// on a server created with peers: true, as host mode runs over peer links
|
|
3326
|
+
export const arenaService = qd.defineService(arena, {
|
|
3327
|
+
methods: {
|
|
3328
|
+
enterArena: {
|
|
3329
|
+
access: "authenticated",
|
|
3330
|
+
handler: ({ input, ctx }) => ctx.rooms.join(arenaRoom(input.arenaId)),
|
|
3331
|
+
},
|
|
3332
|
+
hostArena: {
|
|
3333
|
+
// the app picks the host, never the server: here the first player who asks
|
|
3334
|
+
access: "authenticated",
|
|
3335
|
+
handler: ({ input, ctx }) => {
|
|
3336
|
+
const room = arenaRoom(input.arenaId);
|
|
3337
|
+
return ctx.rooms.hostOf(room) === null && ctx.rooms.setHost(room);
|
|
3338
|
+
},
|
|
3339
|
+
},
|
|
3340
|
+
},
|
|
3341
|
+
// no handler for steer and no options for world: the arena's host serves both
|
|
3342
|
+
});
|
|
3343
|
+
```
|
|
3344
|
+
|
|
3345
|
+
A player reads and sends as with a server's stream and channel:
|
|
3346
|
+
|
|
3347
|
+
<!-- example: apps/web/src/components/kits/ArenaHost.tsx#player -->
|
|
3348
|
+
|
|
3349
|
+
```tsx
|
|
3350
|
+
// a player reads the world and sends input as with any stream and channel: both reach the host
|
|
3351
|
+
export function useArena(arenaId: string) {
|
|
3352
|
+
useJoin(qd.arena.enterArena, { arenaId });
|
|
3353
|
+
const { items } = qd.arena.world.useStream(arenaId);
|
|
3354
|
+
const { send } = qd.arena.steer.useChannel();
|
|
3355
|
+
return {
|
|
3356
|
+
world: items.at(-1),
|
|
3357
|
+
// on the link's fast: never resent, so send input every frame, not once
|
|
3358
|
+
steer: (dx: number, dy: number) => send({ arenaId, dx, dy }),
|
|
3359
|
+
};
|
|
3360
|
+
}
|
|
3361
|
+
```
|
|
3362
|
+
|
|
3363
|
+
The host's client serves the contract's host-served members with
|
|
3364
|
+
`defineHost`, while the server names its socket the room's host:
|
|
3365
|
+
|
|
3366
|
+
<!-- example: apps/web/src/components/kits/ArenaHost.tsx#host -->
|
|
3367
|
+
|
|
3368
|
+
```tsx
|
|
3369
|
+
// the arenas this client simulates as their host: each player's position, by user
|
|
3370
|
+
const arenas = new Map<string, { tick: number; players: Map<string, { x: number; y: number }> }>();
|
|
3371
|
+
|
|
3372
|
+
// a peer's input arrives checked against the contract (schema, room, rate), but it is still
|
|
3373
|
+
// that player's word: the simulation applies it as it would any input
|
|
3374
|
+
const arenaHost = defineHost(arena, {
|
|
3375
|
+
channels: {
|
|
3376
|
+
steer: (steer, { peer }) => {
|
|
3377
|
+
const players = arenas.get(steer.arenaId)?.players;
|
|
3378
|
+
const at = players?.get(peer.userId) ?? { x: 0, y: 0 };
|
|
3379
|
+
players?.set(peer.userId, { x: at.x + steer.dx, y: at.y + steer.dy });
|
|
3380
|
+
},
|
|
3381
|
+
},
|
|
3382
|
+
});
|
|
3383
|
+
|
|
3384
|
+
// serves the arena while the server names this socket its host (qd:host), and stops when not
|
|
3385
|
+
export function useArenaHost(arenaId: string): void {
|
|
3386
|
+
const { connection } = useQuickdraw();
|
|
3387
|
+
useEffect(() => {
|
|
3388
|
+
const room = arenaRoom(arenaId);
|
|
3389
|
+
let host: Host<typeof arena> | undefined;
|
|
3390
|
+
const settle = (): void => {
|
|
3391
|
+
const hosting = hostOf(connection, room) === connection.socket.id;
|
|
3392
|
+
if (hosting && host === undefined) {
|
|
3393
|
+
arenas.set(arenaId, { tick: 0, players: new Map() });
|
|
3394
|
+
host = arenaHost.serve(connection, { room });
|
|
3395
|
+
} else if (!hosting && host !== undefined) {
|
|
3396
|
+
host.close();
|
|
3397
|
+
host = undefined;
|
|
3398
|
+
}
|
|
3399
|
+
};
|
|
3400
|
+
const stop = onHostChange(connection, (changed) => {
|
|
3401
|
+
if (changed === room) {
|
|
3402
|
+
settle();
|
|
3403
|
+
}
|
|
3404
|
+
});
|
|
3405
|
+
settle();
|
|
3406
|
+
// the host's loop: twenty snapshots a second to every player of the arena
|
|
3407
|
+
const loop = setInterval(() => {
|
|
3408
|
+
const state = arenas.get(arenaId);
|
|
3409
|
+
if (host !== undefined && state !== undefined) {
|
|
3410
|
+
state.tick += 1;
|
|
3411
|
+
host.stream("world").push(arenaId, {
|
|
3412
|
+
tick: state.tick,
|
|
3413
|
+
players: Object.fromEntries(state.players),
|
|
3414
|
+
});
|
|
3415
|
+
}
|
|
3416
|
+
}, 50);
|
|
3417
|
+
return () => {
|
|
3418
|
+
stop();
|
|
3419
|
+
clearInterval(loop);
|
|
3420
|
+
host?.close();
|
|
3421
|
+
};
|
|
3422
|
+
}, [connection, arenaId]);
|
|
3423
|
+
}
|
|
3424
|
+
```
|
|
3425
|
+
|
|
3426
|
+
- A host-served channel declares `requires: { room }`, the room whose
|
|
3427
|
+
host takes its messages (a name, a function of the payload, or
|
|
3428
|
+
`{ prefix }`); a host-served stream declares a scope and
|
|
3429
|
+
`access: { room: (scope) => room }`, and its `seed: n` is how many items
|
|
3430
|
+
the host keeps per scope for a newcomer. The service gives a host-served
|
|
3431
|
+
channel no handler and a host-served stream no options (`defineService`
|
|
3432
|
+
refuses them). The server drops a `qd:ch` to one (with a development
|
|
3433
|
+
warning), refuses a `qd:stream:sub` to one (`VALIDATION`), and
|
|
3434
|
+
`qd.stream(...).push` throws for one.
|
|
3435
|
+
- `ctx.rooms.setHost(room)` makes the calling socket the room's host and
|
|
3436
|
+
returns true (false for a call without a socket); the socket must be in
|
|
3437
|
+
the room (`FORBIDDEN`). A new `setHost` replaces the host. `hostOf(room)`
|
|
3438
|
+
(on `ctx.rooms`, `qd.rooms` and `server.rooms`) returns the host's
|
|
3439
|
+
socket id or `null`, and `clearHost(room)` clears it.
|
|
3440
|
+
- When the host leaves the room, is taken out of it or disconnects, the
|
|
3441
|
+
room has no host: its sockets hear `qd:host` with `null`, and
|
|
3442
|
+
`onHostLeave({ room, socketId, principal, reason })` (the service's and
|
|
3443
|
+
`createServer`'s) runs once. The server never picks the next host; the
|
|
3444
|
+
app does, as `hostArena` above lets the next player who asks. Behind a
|
|
3445
|
+
cluster adapter each node knows each room's host. A node that dies
|
|
3446
|
+
without closing its sockets leaves the hosts it held recorded on the
|
|
3447
|
+
other nodes until each room's host is set or cleared again, and no
|
|
3448
|
+
`onHostLeave` runs for them. So an app that picks a host only while
|
|
3449
|
+
`hostOf(room)` is `null`, as `hostArena` does, also needs a way for a
|
|
3450
|
+
player to take over a host that stops answering: a method that calls
|
|
3451
|
+
`ctx.rooms.setHost(room)` or `clearHost(room)` after a timeout of the
|
|
3452
|
+
app's own.
|
|
3453
|
+
- `defineHost(contract, { channels, streams })` (from `./client`, or
|
|
3454
|
+
`./client/core` without React) takes one handler per host-served channel, whose `ctx` is
|
|
3455
|
+
`{ peer: { socketId, userId, link }, room }` (the user as the server
|
|
3456
|
+
named it, and the host's end of the player's link), and
|
|
3457
|
+
`streams.<name>.seed(scope)` to compute a newcomer's seed
|
|
3458
|
+
(default: the latest items pushed, as many as the contract's `seed`).
|
|
3459
|
+
`serve(connection, { room })` answers the links of the room's players and
|
|
3460
|
+
returns a `Host` with `stream(name).push(scope, item)` (checked against
|
|
3461
|
+
the stream's schema), `peers` and `close()`. The host checks each message
|
|
3462
|
+
as the server checks a channel's: its schema, its room, and a bucket per
|
|
3463
|
+
player and channel; it ends the link of a player who floods. A player
|
|
3464
|
+
subscribes at most 500 times at once and then 50 times a second
|
|
3465
|
+
(`RATE_LIMITED`). A seed goes whole or not at all: one with an item too
|
|
3466
|
+
large for a message of its own is refused (`INTERNAL`), and what the
|
|
3467
|
+
relay drops on `rel` is sent again, in order. The host's own
|
|
3468
|
+
`useChannel` and `useStream` for its room reach it over an in-process
|
|
3469
|
+
link, so the host's own client is one of its `peers` too. One
|
|
3470
|
+
connection serves several contracts in a room, one `serve` each, over
|
|
3471
|
+
the same links; serving one contract twice in a room throws.
|
|
3472
|
+
- `hostOf(connection, room)` and `onHostChange(connection, listener)`
|
|
3473
|
+
(from `./client`) say who hosts a room the socket is in. A player's feed
|
|
3474
|
+
subscribes again each time a link to the room's host opens (a new host,
|
|
3475
|
+
a reconnect), and its seed replaces what it showed. A channel message is
|
|
3476
|
+
dropped while no link is open, and goes on the link's `fast`, never
|
|
3477
|
+
resent: send input every frame. After a link to the host
|
|
3478
|
+
closed, the next waits 1 s, doubling to 15 s while links keep closing
|
|
3479
|
+
unopened.
|
|
3480
|
+
- The server never sees what a host and its players send each other. A
|
|
3481
|
+
host is one player's client: trust what it reports no more than any
|
|
3482
|
+
client's word, and check on the server whatever a service keeps of it.
|
|
3483
|
+
A service's `kinds` does not reach its host-served members either: the
|
|
3484
|
+
host knows each player's user, not its kind, so any signed-in socket of
|
|
3485
|
+
the room can link to it. Guard who joins the room.
|
|
3486
|
+
|
|
3487
|
+
Each of a host's `peers` comes with `link`, the host's end of that
|
|
3488
|
+
player's link: `link.clock.rtt` is the player's round trip, and
|
|
3489
|
+
`link.setLag(...)` lags that player's messages both ways. The host's own
|
|
3490
|
+
client is one of them, over its in-process link, so a host can play over
|
|
3491
|
+
a link as laggy as its players':
|
|
3492
|
+
|
|
3493
|
+
<!-- example: apps/web/src/components/kits/ArenaHost.tsx#fair -->
|
|
3494
|
+
|
|
3495
|
+
```tsx
|
|
3496
|
+
// the host's own play goes through an in-process link, one of host.peers: lag it to the median
|
|
3497
|
+
// player's one-way delay, at most 100 ms, so the host plays over a link as laggy as its players'
|
|
3498
|
+
export function matchPlayersLag(host: Host<typeof arena>, self: string | undefined): void {
|
|
3499
|
+
const oneWay = host.peers
|
|
3500
|
+
.filter((peer) => peer.socketId !== self)
|
|
3501
|
+
.map((peer) => peer.link.clock.rtt / 2)
|
|
3502
|
+
.sort((a, b) => a - b);
|
|
3503
|
+
const median = oneWay[Math.floor(oneWay.length / 2)] ?? 0;
|
|
3504
|
+
// call it now and then, as the players' round trips change: what is held keeps its time
|
|
3505
|
+
host.peers.find((peer) => peer.socketId === self)?.link.setLag({ ms: Math.min(100, median) });
|
|
3506
|
+
}
|
|
3507
|
+
```
|
|
3508
|
+
|
|
2522
3509
|
### Auth routes kit
|
|
2523
3510
|
|
|
2524
3511
|
Sign-in for Google, Discord, a development mock and guests, as one Express
|
|
@@ -2605,17 +3592,25 @@ nothing is cached:
|
|
|
2605
3592
|
| `GET /{provider}/callback` | 302 to `{origin}{successPath}` with the session cookie, or to `{origin}{errorPath}?error=state`, `denied` or `failed` |
|
|
2606
3593
|
| `POST /guest` | `createUser(body)`, then `{ userId, name? }` with the session cookie (and `token` with `guest({ token: true })`) |
|
|
2607
3594
|
| `GET /providers` | `{ providers: [{ id, name, kind }] }`: the sign-ins served now (`routes.providers()` on the server) |
|
|
2608
|
-
| `GET /me` | `{ userId }`, or 401
|
|
3595
|
+
| `GET /me` | `{ userId }`, or 401; 403 for a cookie from a page `allowedOrigins` does not list |
|
|
2609
3596
|
| `POST /logout` | 204: revokes the session, clears the cookie |
|
|
2610
3597
|
| `POST /logout-all` | 204: revokes every session of the user; 401 without a live session |
|
|
2611
3598
|
| `/mock/provider/*` | the mock provider's own endpoints, while `isMockOAuthEnabled()` |
|
|
2612
3599
|
|
|
2613
3600
|
- The OAuth state is 256 random bits, sent to the provider and kept in a
|
|
2614
3601
|
10-minute HttpOnly, SameSite=Lax cookie (`__Host-qd_oauth` on `/` over a
|
|
2615
|
-
secure request, else `qd_oauth` on
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
3602
|
+
secure request or whenever `publicUrl` is https, else `qd_oauth` on
|
|
3603
|
+
`basePath`). The `__Host-` name is what keeps another site under the
|
|
3604
|
+
same parent domain from planting a state of its own (a state the routes
|
|
3605
|
+
issued to an attacker, to sign the browser in as them): only this host,
|
|
3606
|
+
over HTTPS, can set it. So serve the API over HTTPS with an https
|
|
3607
|
+
`publicUrl`: a plain `qd_oauth` keeps that risk. The cookie is also
|
|
3608
|
+
signed with an HMAC under a key derived from `jwtSecret`, so its state,
|
|
3609
|
+
return origin, provider and age cannot be forged; the MAC does not say
|
|
3610
|
+
which browser a state was issued to. The callback clears it whatever
|
|
3611
|
+
happens, and accepts only that state, for that provider, within its 10
|
|
3612
|
+
minutes, once (each redeemed state is remembered by the process), so a
|
|
3613
|
+
sign-in cannot be finished in another browser or replayed.
|
|
2619
3614
|
- `returnTo` is an origin or a URL on one; only its origin is kept, and only
|
|
2620
3615
|
when `allowedOrigins` lists it (exact origins, or patterns anchored with
|
|
2621
3616
|
`^` and `$`; nothing is read from the environment, and localhost or
|
|
@@ -2638,7 +3633,11 @@ nothing is cached:
|
|
|
2638
3633
|
the same name, and the server cannot tell which is its own.
|
|
2639
3634
|
- `me` answers the same 401 whether the request had no credential, a forged
|
|
2640
3635
|
or expired one, or one whose session was revoked. It, `logout` and
|
|
2641
|
-
`logout-all` read the cookie, else an `Authorization: Bearer` token.
|
|
3636
|
+
`logout-all` read the cookie, else an `Authorization: Bearer` token. A
|
|
3637
|
+
cookie sent from a page `allowedOrigins` does not list is answered 403
|
|
3638
|
+
(`FORBIDDEN`), as on the HTTP transport, and `guest`, which sets the
|
|
3639
|
+
cookie, refuses such a page whatever it sends; a bearer token is not
|
|
3640
|
+
ambient, so any page may send it.
|
|
2642
3641
|
- `socketAuth` reads a socket's `auth.token` (a bearer token, for clients
|
|
2643
3642
|
without cookies), else the session cookie from its handshake; an HTTP
|
|
2644
3643
|
call's credential is the one the transport found. No credential is
|
|
@@ -2657,17 +3656,24 @@ nothing is cached:
|
|
|
2657
3656
|
a browser sends `Origin` with every POST, so it comes from curl or a
|
|
2658
3657
|
server rendering a page with the user's forwarded cookie (refused only
|
|
2659
3658
|
when `Sec-Fetch-Site` names another site). Bearer tokens need no Origin
|
|
2660
|
-
on either transport. An app's own `authenticate`
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
`
|
|
2664
|
-
`
|
|
3659
|
+
on either transport. An app's own `authenticate` finds an HTTP call's
|
|
3660
|
+
session cookie in `request.sessionCookie`, never in `auth.token` (which
|
|
3661
|
+
holds only a bearer token for it), and `request.credential` says which
|
|
3662
|
+
one the transport chose (`"cookie"` or `"bearer"`, type
|
|
3663
|
+
`HttpCredentialSource`). One that accepts the cookie applies the same
|
|
3664
|
+
rule with `cookieOriginAllowed(request, allowedOrigins)` and checks the
|
|
3665
|
+
session in the store on every call (`liveSession`); a
|
|
3666
|
+
`QuickdrawError("FORBIDDEN")` it throws on HTTP is answered as it is. A
|
|
3667
|
+
hook that takes bearer tokens only (the MCP bridge's `principal`, an
|
|
3668
|
+
`authenticate` reading `auth.token`) uses `bearerAuth`.
|
|
2665
3669
|
- `socketAuth({ devCredentials })` signs a socket in by the user id its
|
|
2666
3670
|
handshake names (`auth: { userId }`, no token), as the function answers
|
|
2667
3671
|
(the principal, or `null` to refuse): for a game editor or load-test bots
|
|
2668
|
-
in development.
|
|
2669
|
-
`
|
|
2670
|
-
|
|
3672
|
+
in development. It runs only when `NODE_ENV` is `development` or `test`:
|
|
3673
|
+
`socketAuth` throws when it is given otherwise (an unset `NODE_ENV`
|
|
3674
|
+
included: set `NODE_ENV=development`), refuses such a handshake once
|
|
3675
|
+
`NODE_ENV` says otherwise, and `createServer` warns at startup while it
|
|
3676
|
+
is on; pass it only behind the app's own flag.
|
|
2671
3677
|
- `google.optional(...)` and `discord.optional(...)` build nothing when
|
|
2672
3678
|
neither credential is set (only one is a misconfiguration, refused), and
|
|
2673
3679
|
`providers` skips `undefined`, `null` and `false` entries, so an
|
|
@@ -2706,8 +3712,10 @@ nothing is cached:
|
|
|
2706
3712
|
many users calls from one address: give it its own `keyGenerator` or a
|
|
2707
3713
|
higher `max`.
|
|
2708
3714
|
- The mock provider is mounted only while `isMockOAuthEnabled()`
|
|
2709
|
-
(`ENABLE_MOCK_OAUTH=true` and `NODE_ENV`
|
|
2710
|
-
|
|
3715
|
+
(`ENABLE_MOCK_OAUTH=true` and `NODE_ENV` `development` or `test`; an
|
|
3716
|
+
unset `NODE_ENV` mounts nothing and logs an error saying to set
|
|
3717
|
+
`NODE_ENV=development`), mounting it logs a warning, and every request
|
|
3718
|
+
checks again. Set `mock({ internalUrl })` where the API
|
|
2711
3719
|
cannot reach itself at `publicUrl`. Routes made with nothing that can
|
|
2712
3720
|
sign anyone in (only a mock that is off) warn when they are made.
|
|
2713
3721
|
- `publicUrl` is where providers send the browser back, so a deployment
|
|
@@ -2718,33 +3726,57 @@ nothing is cached:
|
|
|
2718
3726
|
(`X-Forwarded-Host`, else `Host`) logs an error naming it, once.
|
|
2719
3727
|
- One rule names the session cookie, written and read: the routes,
|
|
2720
3728
|
`setSessionCookie`, `socketAuth` and the HTTP transport give a request
|
|
2721
|
-
the same name, and read first
|
|
3729
|
+
the same name, and read `__Host-session` first wherever it is present:
|
|
3730
|
+
only this host, over a secure context, can have set it. Without a
|
|
2722
3731
|
domain, a request over HTTPS reads only `__Host-session`, never
|
|
2723
3732
|
`session`: another site under the same parent domain can set that cookie
|
|
2724
3733
|
for the whole domain, and while the user holds no `__Host-session` it
|
|
2725
3734
|
would sign them in as whoever set it. A plain HTTP request reads
|
|
2726
|
-
`session`, then `
|
|
3735
|
+
`__Host-session`, then `session` (the name the routes set there); when
|
|
3736
|
+
the routes or `setSessionCookie` and `clearSessionCookie` set or clear
|
|
3737
|
+
`session`, they also clear a stale `__Host-session` the request carries,
|
|
3738
|
+
which would otherwise shadow it, or outlive a sign-out. A configured name is read as it is, on
|
|
2727
3739
|
any request, so a changed name goes in all three places:
|
|
2728
3740
|
`createAuthRoutes({ cookie: { name } })`, `socketAuth({ cookieName })`
|
|
2729
3741
|
and `createServer({ http: { cookieName } })`. The transports see the
|
|
2730
3742
|
cookie's domain through `COOKIE_DOMAIN` only: share the cookie with
|
|
2731
3743
|
subdomains by setting `COOKIE_DOMAIN`; a `cookie.domain` given only to
|
|
2732
3744
|
the routes needs `cookieName: "session"` on the other two, and the routes
|
|
2733
|
-
warn at startup until the cookie is named.
|
|
3745
|
+
warn at startup until the cookie is named. They also warn for
|
|
3746
|
+
`cookie.domain: ""` while `COOKIE_DOMAIN` is set: the transports then
|
|
3747
|
+
take a `session` cookie over HTTPS too, which the routes never set there
|
|
3748
|
+
and any site under that domain can. Behind a proxy that ends TLS,
|
|
2734
3749
|
have it send `X-Forwarded-Proto`, which every request carries, so the
|
|
2735
3750
|
name does not depend on which requests carry an `Origin`.
|
|
2736
|
-
- A socket
|
|
2737
|
-
|
|
3751
|
+
- A socket whose session expires is disconnected then (`io server
|
|
3752
|
+
disconnect`; `socketAuth` records the expiry, and an app's own
|
|
3753
|
+
`authenticate` may, through `recordSocketSession(socket, sessionId,
|
|
3754
|
+
expiresAt)`, with `expiresAt` a `Date` or epoch milliseconds: a JWT's
|
|
3755
|
+
`exp` is in seconds). A handshake whose recorded session expired before
|
|
3756
|
+
it ended (a slow `loadPrincipal`) is refused, `UNAUTHENTICATED`. Otherwise a socket keeps the principal it authenticated
|
|
3757
|
+
with until it reconnects, so a revoked session's open sockets are ended with
|
|
2738
3758
|
`server.access.disconnectUser(userId, { sessionId?, reason? })`: every
|
|
2739
3759
|
socket of the user, or those `socketAuth` recorded for one session
|
|
2740
3760
|
(`recordSocketSession` on `./server` for an app's own `authenticate`), on
|
|
2741
3761
|
every node behind a cluster adapter. `onRevoke(userId, sessionId | null)`
|
|
2742
3762
|
tells the app when `logout` (the session's id) or `logout-all` (`null`)
|
|
2743
3763
|
revoked; wire it as above. A disconnected client does not reconnect on
|
|
2744
|
-
its own; its next connect is authenticated afresh.
|
|
3764
|
+
its own; its next connect is authenticated afresh. It forgets the user
|
|
3765
|
+
and everything quickdraw cached (`isKnown` false, `status`
|
|
3766
|
+
`"disconnected"`), and calls waiting to be sent when a reconnect is
|
|
3767
|
+
refused fail with `CANCELLED`, so a shared machine shows nothing of
|
|
3768
|
+
theirs.
|
|
2745
3769
|
- `issueSession({ sessions, jwtSecret }, userId, { provider })` starts a
|
|
2746
3770
|
session for an app's own sign-in flow (login codes, an embedded activity),
|
|
2747
3771
|
and `liveSession` reads a token back; both work with `socketAuth`.
|
|
3772
|
+
- `bearerAuth({ sessions, jwtSecret, loadPrincipal? })` is `socketAuth`'s
|
|
3773
|
+
counterpart for a hook handed a bearer token alone (the MCP bridge's
|
|
3774
|
+
`principal`, `authenticate: ({ auth }) => verify(auth.token)`): no token
|
|
3775
|
+
is anonymous, a token whose session is not live is refused with
|
|
3776
|
+
`UNAUTHENTICATED`, on every call, and it never reads cookies. It records
|
|
3777
|
+
no socket session: use `socketAuth` for `createServer`. `verifyJWT`
|
|
3778
|
+
checks a token's signature and expiry only, never the store, and warns
|
|
3779
|
+
once in development when it verifies a session token.
|
|
2748
3780
|
|
|
2749
3781
|
From the browser, `./client` speaks to these routes: `authProviders({
|
|
2750
3782
|
apiUrl })` lists the sign-ins the API serves (`GET /providers`: a provider
|
|
@@ -2759,7 +3791,8 @@ and the token `setAuthToken` stored, if any, as a bearer token; the stored
|
|
|
2759
3791
|
token is forgotten either way, and a refusal or an unreachable server
|
|
2760
3792
|
rejects with a `QuickdrawError`. A socket keeps the user it signed in as
|
|
2761
3793
|
until it connects again, so reconnect the provider's connection after
|
|
2762
|
-
signing out (
|
|
3794
|
+
signing out: `connection.close()`, then `open()`, as below, or a reload
|
|
3795
|
+
(with a token in `auth`, clearing it does that):
|
|
2763
3796
|
|
|
2764
3797
|
<!-- example: apps/web/src/components/SignIn.tsx#browser -->
|
|
2765
3798
|
|
|
@@ -2918,9 +3951,10 @@ it("sends a rename to the other members' boards", async () => {
|
|
|
2918
3951
|
- Seed rows with the untracked client (`prisma`), or inside `qd.run` once
|
|
2919
3952
|
an app runs: a tracked write outside any unit of work flushes on its own,
|
|
2920
3953
|
with an `ambient-write` warning.
|
|
2921
|
-
- `app.as(principal)` calls in process
|
|
2922
|
-
|
|
2923
|
-
|
|
3954
|
+
- `app.as(principal)` calls in process (with transport `"internal"`, or
|
|
3955
|
+
another, below) and `app.connect(principal)` over a real socket
|
|
3956
|
+
(`{ call, socket, hello, close }`); both are keyed by service name.
|
|
3957
|
+
`app.frames(match?)` lists every frame the server sent, with its
|
|
2924
3958
|
socket and user; `frames.waitFor(match)` waits for one. A query of one
|
|
2925
3959
|
event (`EventQuery`) takes `where`, a predicate over its frames typed by
|
|
2926
3960
|
the event (`{ event: "qd:presence", where: ({ data }) => data.joined === id }`), and
|
|
@@ -2930,6 +3964,40 @@ it("sends a rename to the other members' boards", async () => {
|
|
|
2930
3964
|
`userId`. `emitWithAck` and `waitForEvent` send raw frames and wait for
|
|
2931
3965
|
events.
|
|
2932
3966
|
|
|
3967
|
+
`app.as(principal, { transport, mcp })` calls in process as another
|
|
3968
|
+
transport would, so a guard that reads `ctx.mcp` or `ctx.transport` is
|
|
3969
|
+
tested like any other access. Here the handler example's `assign` (under
|
|
3970
|
+
Services), which refuses an agent's token without the `tasks` scope:
|
|
3971
|
+
|
|
3972
|
+
<!-- example: apps/api/src/services/task.test.ts#token -->
|
|
3973
|
+
|
|
3974
|
+
```ts
|
|
3975
|
+
it("lets an agent's token assign tasks only with the tasks scope", async () => {
|
|
3976
|
+
// the handler example's assign, which reads the token's scopes from ctx.mcp
|
|
3977
|
+
const app = await createTestApp({ services: [assigningService], db });
|
|
3978
|
+
const agent: AppPrincipal = { ...ada, kind: "agent", serviceAccess: { taskService: "Moderate" } };
|
|
3979
|
+
const input = { id: taskId, assigneeId: bo.userId };
|
|
3980
|
+
// in process, as an MCP tool call arrives: transport "mcp", and the context hook's fields as ctx.mcp
|
|
3981
|
+
const reader = app.as(agent, { transport: "mcp", mcp: { scopes: [] } });
|
|
3982
|
+
await expect(reader.taskService.assign(input)).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
3983
|
+
const writer = app.as(agent, { transport: "mcp", mcp: { scopes: ["tasks"] } });
|
|
3984
|
+
expect(await writer.taskService.assign(input)).toMatchObject({ assigneeId: bo.userId });
|
|
3985
|
+
await app.close();
|
|
3986
|
+
});
|
|
3987
|
+
```
|
|
3988
|
+
|
|
3989
|
+
- `transport` is `"internal"` (the default), `"http"` or `"mcp"`; a
|
|
3990
|
+
socket's call is `app.connect`'s. `mcp` is the call's `ctx.mcp`, typed by
|
|
3991
|
+
the app's `QuickdrawTypes["mcp"]`: with `transport: "mcp"` the call is an
|
|
3992
|
+
MCP tool call's, and with the default transport it is a call an MCP call
|
|
3993
|
+
made through `ctx.services`.
|
|
3994
|
+
- Bound arguments and the registry's `principal` and `context` hooks belong
|
|
3995
|
+
to the MCP registry, not the dispatcher, so test them through the
|
|
3996
|
+
registry: `registry.callMethod(service, method, input, { request })`
|
|
3997
|
+
calls a method's tool as an agent would, with the input as the method
|
|
3998
|
+
takes it, and resolves with `{ ok: true, data }` or
|
|
3999
|
+
`{ ok: false, error }`.
|
|
4000
|
+
|
|
2933
4001
|
### Access matrices
|
|
2934
4002
|
|
|
2935
4003
|
`describeAccessMatrix(app, { service, principals, cases, via? })` runs each
|
|
@@ -3097,43 +4165,52 @@ What it does with them:
|
|
|
3097
4165
|
(ids and timestamps vary in length) without counting as a change.
|
|
3098
4166
|
- Await, inside `run`, everything the step should cost: the replies and the
|
|
3099
4167
|
frames it is about. Measure one step at a time.
|
|
3100
|
-
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
4168
|
+
- Whether an order column may hold null comes from the client's own schema,
|
|
4169
|
+
with no statement; only a client that carries no schema text makes the
|
|
4170
|
+
storage adapter ask the database, once per column and process. When a step
|
|
4171
|
+
could be the first to pay for such a read, run it once before measuring it,
|
|
4172
|
+
so its budget does not depend on the order tests run in.
|
|
3104
4173
|
|
|
3105
4174
|
### Development warnings
|
|
3106
4175
|
|
|
3107
4176
|
While `NODE_ENV` is not `"production"`, a running app warns about the slow
|
|
3108
4177
|
and untracked patterns lint cannot see, as they happen. Every warning has
|
|
3109
|
-
one format and names the method call it happened in:
|
|
4178
|
+
one format and names the method call it happened in (one exception:
|
|
4179
|
+
`untracked-access-model` is checked as the server starts and logged as an
|
|
4180
|
+
error under `category: "quickdraw.access"`, in every environment, at each
|
|
4181
|
+
start):
|
|
3110
4182
|
|
|
3111
4183
|
```text
|
|
3112
4184
|
[quickdraw:n-plus-one] taskService.board: task.findUnique by id ran 10 times in one call, once per item (N+1); ...
|
|
3113
4185
|
```
|
|
3114
4186
|
|
|
3115
|
-
| Kind
|
|
3116
|
-
|
|
|
3117
|
-
| `n-plus-one`
|
|
3118
|
-
| `unbounded-read`
|
|
3119
|
-
| `oversized-response`
|
|
3120
|
-
| `nested-write`
|
|
3121
|
-
| `ambient-write`
|
|
3122
|
-
| `batch-read`
|
|
3123
|
-
| `batch-create-many`
|
|
3124
|
-
| `repeated-call`
|
|
3125
|
-
| `tiered-field-in-output`
|
|
3126
|
-
| `resolver-without-reads`
|
|
4187
|
+
| Kind | Raised when |
|
|
4188
|
+
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
4189
|
+
| `n-plus-one` | a call ran 10 statements of one shape (model, operation, `where` keys), outside a `$transaction([])` |
|
|
4190
|
+
| `unbounded-read` | a call ran `findMany` with neither `take` nor ids to read (`id`, `{ in }` or `{ equals }`) |
|
|
4191
|
+
| `oversized-response` | a reply was larger than `maxResponseBytes` (default 1 MiB) |
|
|
4192
|
+
| `nested-write` | a write's `data` wrote a related row, which is not tracked |
|
|
4193
|
+
| `ambient-write` | a tracked write ran outside any unit of work |
|
|
4194
|
+
| `batch-read` | a write in an array-form `$transaction` read its rows outside the batch |
|
|
4195
|
+
| `batch-create-many` | a `createMany` in an array-form `$transaction` could not report its rows |
|
|
4196
|
+
| `repeated-call` | one connection repeated a call or was refused `RATE_LIMITED` again and again (below) |
|
|
4197
|
+
| `tiered-field-in-output` | a method's own output schema declares a field the contract tiers, which only projection outputs strip; raised when the server is made (for an output without JSON Schema, when a reply carries it) |
|
|
4198
|
+
| `resolver-without-reads` | a service's policy is, or combines in `anyOf`, a `resolver` without `reads`, so no tracked write re-checks it; raised when the server is made |
|
|
4199
|
+
| `untracked-access-model` | a model a policy, a membership table or a `via` junction depends on has no `id` the write tracker can key its rows by, so no write to it revokes; checked when the server starts, logged as an error in every environment |
|
|
4200
|
+
| `shared-principal` | the handler of a `share: "all"` run (an app's, not a kit's) read `ctx.principal`, `ctx.services`, `ctx.mcp` or `ctx.access`: the run has the first caller's, and every caller gets its result |
|
|
4201
|
+
| `channel-without-requires` | a channel's contract declares no `requires` and its service no `access`, so every signed-in user may send it; raised when the server is made |
|
|
3127
4202
|
|
|
3128
4203
|
Updates and deletes by id inside an interactive transaction are not counted
|
|
3129
4204
|
toward `n-plus-one`: that is how per-row writes are written (see tracked
|
|
3130
4205
|
writes). Each is logged once per kind, service, method and subject (the
|
|
3131
|
-
model,
|
|
4206
|
+
model, the field of a nested write, or the member a shared run read), under
|
|
4207
|
+
`category: "quickdraw.dev"`.
|
|
3132
4208
|
Only an app's own statements are checked: the framework's reads and the
|
|
3133
4209
|
kits' own reads are not, while the app's callbacks a kit calls (`prepare`,
|
|
3134
4210
|
`onChange`, `resolveUser`, a search strategy) are. A statement filtered by
|
|
3135
4211
|
an `id` list (`{ id: { in: ids } }`, one per chunk of ids) never counts
|
|
3136
|
-
toward `n-plus-one
|
|
4212
|
+
toward `n-plus-one`, and neither does a `createMany` (one per chunk of
|
|
4213
|
+
rows, as a large insert is split). In tests, `createTestApp({ strictWarnings: true })`
|
|
3137
4214
|
(under vitest or jest) throws every warning raised in that app's method
|
|
3138
4215
|
calls as a `DevWarningError` where it is raised, so the test that caused it
|
|
3139
4216
|
fails: the call it happened in fails with `INTERNAL` and the error as its
|
|
@@ -3142,9 +4219,10 @@ once the reply was recorded (over a socket or HTTP the reply was already
|
|
|
3142
4219
|
sent, so that error is logged, not thrown). Strictness belongs to the app:
|
|
3143
4220
|
warnings outside its calls (an ambient write while seeding, another app's
|
|
3144
4221
|
calls) are logged as usual, and `app.close()` ends it. The ones raised when
|
|
3145
|
-
the app is made, `tiered-field-in-output
|
|
3146
|
-
|
|
3147
|
-
|
|
4222
|
+
the app is made, `tiered-field-in-output`, `resolver-without-reads`,
|
|
4223
|
+
`untracked-access-model` and `channel-without-requires`, are thrown from
|
|
4224
|
+
`createTestApp` itself, so a strict app over such a method, policy, table
|
|
4225
|
+
or channel does not start.
|
|
3148
4226
|
|
|
3149
4227
|
A `repeated-call` warning names a client caught in a loop (a mutation fired
|
|
3150
4228
|
from an effect that its own result runs again, a refetch that triggers
|
|
@@ -3280,6 +4358,16 @@ to the test client exactly as in production.
|
|
|
3280
4358
|
|
|
3281
4359
|
## Observability
|
|
3282
4360
|
|
|
4361
|
+
Logs go to `createServer({ logger })`, any logger with Winston's methods
|
|
4362
|
+
(default `consoleLogger`). The console default drops `debug` when
|
|
4363
|
+
`NODE_ENV` is `"production"`, since a call's record logs at debug with its
|
|
4364
|
+
caller's user id: `createConsoleLogger({ level: "debug" })` keeps it, and
|
|
4365
|
+
`level: "warn"` drops `info` too. An error is logged with its message and
|
|
4366
|
+
stack, but a Prisma client error only with its class, `code`, the schema
|
|
4367
|
+
names and codes of its `meta` (never a value, nor the database's own text,
|
|
4368
|
+
which quotes the row), the reason a validation error gives, and its stack
|
|
4369
|
+
frames: its message renders the call, values included.
|
|
4370
|
+
|
|
3283
4371
|
`createServer({ stallWatchdog: true })` watches the event loop. It samples
|
|
3284
4372
|
the loop's delay every 20 ms (`perf_hooks.monitorEventLoopDelay`), reads it
|
|
3285
4373
|
every 10 s, and logs a warning (`category: "quickdraw.stall"`) when the 99th
|
|
@@ -3406,8 +4494,8 @@ so an app can adopt it before fixing old code.
|
|
|
3406
4494
|
|
|
3407
4495
|
The guards follow one order of preference, so an agent moving fast meets a
|
|
3408
4496
|
mistake as early as it can be caught: a type error; then a refusal when the
|
|
3409
|
-
service is defined (an access form that
|
|
3410
|
-
|
|
4497
|
+
service is defined (an access form that asks the policy nothing about the
|
|
4498
|
+
row a method's `id` names, unless it says `rowless: true`); then lint; then a
|
|
3411
4499
|
development warning as it happens (`repeated-call`, `repeated-mutation` and
|
|
3412
4500
|
`repeated-invalidation` name a client loop before the rate limit does).
|
|
3413
4501
|
|
|
@@ -3442,22 +4530,23 @@ bunx @fitzzero/quickdraw-codemod v5 .
|
|
|
3442
4530
|
|
|
3443
4531
|
## Package exports
|
|
3444
4532
|
|
|
3445
|
-
| Export | Holds
|
|
3446
|
-
| ------------------ |
|
|
3447
|
-
| `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names
|
|
3448
|
-
| `./server` | `initQuickdraw`, `createServer`, `createDispatcher`, `createHttpRouter`, policies, `custom`, the kits' handlers, `requireRow`, `nextOrdinal`, `storageOf`, dev warnings, the Redis adapter, the socket rate limiter, env and encryption helpers
|
|
3449
|
-
| `./server/auth` | `createAuthRoutes`, `socketAuth`, `requireSession` and `sessionOf`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers
|
|
3450
|
-
| `./server/express` | Express rate limits: `createAuthLimiter`, `createAuthStatusLimiter`, `createCallLimiter`, `createPublicApiLimiter`, `createWebhookLimiter`
|
|
3451
|
-
| `./server/mcp` | `createMcpRegistry`, `describeTools`, `createMcpStdioServer`, `createMcpHttpRouter`, `bootstrapMcpServer`
|
|
3452
|
-
| `./server/otel` | `otelOnCall`
|
|
3453
|
-
| `./prisma` | `trackPrisma`, `storageOf`
|
|
3454
|
-
| `./client` | `createQuickdrawClient`, `QuickdrawProvider`, `useQuickdraw`, `usePresence`, `useAdminServices`, `createQuickdrawConnection`, `call`, `callData`, `liveDataOf`, the coordinator; everything in `./utils`
|
|
3455
|
-
| `./
|
|
3456
|
-
| `./
|
|
3457
|
-
| `./
|
|
3458
|
-
| `./testing
|
|
3459
|
-
| `./testing/
|
|
3460
|
-
| `./testing/
|
|
4533
|
+
| Export | Holds |
|
|
4534
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
4535
|
+
| `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names, `consoleLogger` and `createConsoleLogger`, the types `Tracked` and `TrackedTransaction` |
|
|
4536
|
+
| `./server` | `initQuickdraw`, `createServer`, `createDispatcher`, `createHttpRouter`, policies, `custom`, the kits' handlers, `requireRow`, `nextOrdinal`, `storageOf`, dev warnings, the Redis adapter, the socket rate limiter, env and encryption helpers |
|
|
4537
|
+
| `./server/auth` | `createAuthRoutes`, `socketAuth`, `bearerAuth`, `requireSession` and `sessionOf`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers |
|
|
4538
|
+
| `./server/express` | Express rate limits: `createAuthLimiter`, `createAuthStatusLimiter`, `createCallLimiter`, `createPublicApiLimiter`, `createWebhookLimiter` |
|
|
4539
|
+
| `./server/mcp` | `createMcpRegistry`, `describeTools`, `createMcpStdioServer`, `createMcpHttpRouter`, `bootstrapMcpServer` |
|
|
4540
|
+
| `./server/otel` | `otelOnCall` |
|
|
4541
|
+
| `./prisma` | `trackPrisma`, `storageOf`, the types `Tracked` and `TrackedTransaction` |
|
|
4542
|
+
| `./client` | `createQuickdrawClient`, `QuickdrawProvider`, `useQuickdraw`, `usePresence`, `useAdminServices`, `createQuickdrawConnection`, `call`, `callData`, `liveDataOf`, the coordinator; everything in `./utils` |
|
|
4543
|
+
| `./client/core` | the client without React or TanStack: `createQuickdrawConnection`, `call`, `callData`, `newId`, the auth helpers, `createPeerLink`, `defineHost`; everything in `./utils` |
|
|
4544
|
+
| `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
|
|
4545
|
+
| `./parser` | the JSON-only Socket.IO parser |
|
|
4546
|
+
| `./testing` | `createTestApp`, `describeAccessMatrix`, `snapshotAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
|
|
4547
|
+
| `./testing/client` | `renderWithQuickdraw`, and everything in `./testing/mock` |
|
|
4548
|
+
| `./testing/mock` | `createMockClient` alone, without Testing Library: for browser bundles such as Storybook |
|
|
4549
|
+
| `./testing/prisma` | test databases on PostgreSQL or PGlite |
|
|
3461
4550
|
|
|
3462
4551
|
The package also ships the `quickdraw-docs` command.
|
|
3463
4552
|
|