@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.
Files changed (152) hide show
  1. package/README.md +1308 -219
  2. package/dist/{admin-DKwkIh5i.d.ts → admin-CS7JB_UH.d.ts} +2 -2
  3. package/dist/{adminSchemas-2DUA_hZ8.d.ts → adminSchemas-29cGgrg5.d.ts} +6 -4
  4. package/dist/{auth-BhWqdyb-.d.ts → auth-DtdYjrZ9.d.ts} +51 -21
  5. package/dist/{caller-CXTFswBO.d.ts → caller-BTwGl7vm.d.ts} +296 -33
  6. package/dist/chunk-2FDSKLBO.js +82 -0
  7. package/dist/chunk-2FDSKLBO.js.map +1 -0
  8. package/dist/{chunk-RT6OILOI.js → chunk-4D2DOGAW.js} +2 -14
  9. package/dist/chunk-4D2DOGAW.js.map +1 -0
  10. package/dist/{chunk-OECZPRPW.js → chunk-64I6MYBC.js} +162 -775
  11. package/dist/chunk-64I6MYBC.js.map +1 -0
  12. package/dist/chunk-7473HK24.js +17 -0
  13. package/dist/chunk-7473HK24.js.map +1 -0
  14. package/dist/chunk-7Y5ROPXL.js +33 -0
  15. package/dist/chunk-7Y5ROPXL.js.map +1 -0
  16. package/dist/chunk-A2RBQD66.js +7 -0
  17. package/dist/chunk-A2RBQD66.js.map +1 -0
  18. package/dist/chunk-AK23XSWD.js +16 -0
  19. package/dist/chunk-AK23XSWD.js.map +1 -0
  20. package/dist/chunk-ASAMUGHG.js +651 -0
  21. package/dist/chunk-ASAMUGHG.js.map +1 -0
  22. package/dist/{chunk-B2P7AZ25.js → chunk-CYE4UBOB.js} +6 -4
  23. package/dist/chunk-CYE4UBOB.js.map +1 -0
  24. package/dist/chunk-DKE6QO26.js +136 -0
  25. package/dist/chunk-DKE6QO26.js.map +1 -0
  26. package/dist/chunk-DNP65TD5.js +832 -0
  27. package/dist/chunk-DNP65TD5.js.map +1 -0
  28. package/dist/{chunk-H23KEFBA.js → chunk-DUBU3BB3.js} +30 -1
  29. package/dist/chunk-DUBU3BB3.js.map +1 -0
  30. package/dist/chunk-GYLWEJHW.js +19 -0
  31. package/dist/chunk-GYLWEJHW.js.map +1 -0
  32. package/dist/{chunk-SHEGLZEP.js → chunk-I3JCFS7H.js} +34 -13
  33. package/dist/chunk-I3JCFS7H.js.map +1 -0
  34. package/dist/{chunk-X5CWPX7H.js → chunk-IIQ3WMJK.js} +13 -2
  35. package/dist/chunk-IIQ3WMJK.js.map +1 -0
  36. package/dist/{chunk-RZTP4IEW.js → chunk-JVTONQFI.js} +6339 -4226
  37. package/dist/chunk-JVTONQFI.js.map +1 -0
  38. package/dist/{chunk-QEKVYG5F.js → chunk-KKQANRLG.js} +2 -2
  39. package/dist/chunk-KKQANRLG.js.map +1 -0
  40. package/dist/{chunk-CDKG4TFA.js → chunk-MPQAT55F.js} +38 -205
  41. package/dist/chunk-MPQAT55F.js.map +1 -0
  42. package/dist/chunk-NO4M6PO5.js +101 -0
  43. package/dist/chunk-NO4M6PO5.js.map +1 -0
  44. package/dist/{chunk-TBMKJ26R.js → chunk-NW6U45HX.js} +3 -1
  45. package/dist/chunk-NW6U45HX.js.map +1 -0
  46. package/dist/chunk-NX2V6SIL.js +202 -0
  47. package/dist/chunk-NX2V6SIL.js.map +1 -0
  48. package/dist/{chunk-ZIVPHYRG.js → chunk-NZHA4TZD.js} +72 -175
  49. package/dist/chunk-NZHA4TZD.js.map +1 -0
  50. package/dist/{chunk-2PWMUVC7.js → chunk-OCZJ4EYV.js} +518 -27
  51. package/dist/chunk-OCZJ4EYV.js.map +1 -0
  52. package/dist/{chunk-REX5YPHX.js → chunk-PHCURHC7.js} +76 -21
  53. package/dist/chunk-PHCURHC7.js.map +1 -0
  54. package/dist/{chunk-4YO5H5ON.js → chunk-Q4OGQM5I.js} +4 -10
  55. package/dist/chunk-Q4OGQM5I.js.map +1 -0
  56. package/dist/chunk-QSU47XEX.js +13 -0
  57. package/dist/chunk-QSU47XEX.js.map +1 -0
  58. package/dist/{chunk-DD4SKCNO.js → chunk-RZXJTDH5.js} +13 -2
  59. package/dist/chunk-RZXJTDH5.js.map +1 -0
  60. package/dist/{chunk-Z2LZ7IZ7.js → chunk-SN3OZVEQ.js} +5 -31
  61. package/dist/chunk-SN3OZVEQ.js.map +1 -0
  62. package/dist/{chunk-4DIQHASK.js → chunk-SQ5VUG77.js} +28 -8
  63. package/dist/chunk-SQ5VUG77.js.map +1 -0
  64. package/dist/chunk-STH5DLQP.js +1192 -0
  65. package/dist/chunk-STH5DLQP.js.map +1 -0
  66. package/dist/{chunk-QCHKWIRS.js → chunk-VZFJTKIW.js} +13 -13
  67. package/dist/{chunk-QCHKWIRS.js.map → chunk-VZFJTKIW.js.map} +1 -1
  68. package/dist/{chunk-VSQBHPWH.js → chunk-X4ZU4QH5.js} +54 -22
  69. package/dist/chunk-X4ZU4QH5.js.map +1 -0
  70. package/dist/chunk-XD7SRDUY.js +188 -0
  71. package/dist/chunk-XD7SRDUY.js.map +1 -0
  72. package/dist/chunk-XH2IZVLT.js +62 -0
  73. package/dist/chunk-XH2IZVLT.js.map +1 -0
  74. package/dist/chunk-Z7I6SONO.js +825 -0
  75. package/dist/chunk-Z7I6SONO.js.map +1 -0
  76. package/dist/cli/quickdraw-docs.js +132 -112
  77. package/dist/cli/quickdraw-docs.js.map +1 -1
  78. package/dist/client/core.d.ts +387 -0
  79. package/dist/client/core.js +109 -0
  80. package/dist/client/index.d.ts +52 -203
  81. package/dist/client/index.js +59 -218
  82. package/dist/client/index.js.map +1 -1
  83. package/dist/{clientTypes-Bc-G8n-U.d.ts → clientTypes-CUUNJwWs.d.ts} +3 -3
  84. package/dist/{connection-Cr1BU07W.d.ts → connection-2EeQ_Et0.d.ts} +22 -11
  85. package/dist/{createServer-CP9eDHZZ.d.ts → createServer-Bx5HOcYK.d.ts} +67 -17
  86. package/dist/{createTestApp-UKVGEmOf.d.ts → createTestApp-DAMANT79.d.ts} +46 -13
  87. package/dist/{envelope-Cp3YcVxC.d.ts → envelope-BMBaxppV.d.ts} +216 -10
  88. package/dist/{http-Djj2Inuu.d.ts → http-YzLAar3w.d.ts} +76 -60
  89. package/dist/index.d.ts +8 -7
  90. package/dist/index.js +82 -25
  91. package/dist/index.js.map +1 -1
  92. package/dist/{infer-Teo1xpsd.d.ts → infer-Br62cnyW.d.ts} +82 -11
  93. package/dist/logger-Yz93KzdV.d.ts +30 -0
  94. package/dist/{metrics-BdBu5o8G.d.ts → metrics-CTVpw0gu.d.ts} +2 -2
  95. package/dist/origins-kvpxnVcW.d.ts +8 -0
  96. package/dist/parser.js +2 -1
  97. package/dist/prisma/index.d.ts +14 -7
  98. package/dist/prisma/index.js +279 -70
  99. package/dist/prisma/index.js.map +1 -1
  100. package/dist/{rateLimit-ZMIZJUZF.js → rateLimit-5RKBFLUR.js} +2 -2
  101. package/dist/rateLimit-5RKBFLUR.js.map +1 -0
  102. package/dist/remote-N7OUAGOW.js +15 -0
  103. package/dist/remote-N7OUAGOW.js.map +1 -0
  104. package/dist/server/auth/index.d.ts +82 -33
  105. package/dist/server/auth/index.js +277 -270
  106. package/dist/server/auth/index.js.map +1 -1
  107. package/dist/server/express/index.d.ts +9 -2
  108. package/dist/server/express/index.js +1 -1
  109. package/dist/server/index.d.ts +268 -84
  110. package/dist/server/index.js +664 -418
  111. package/dist/server/index.js.map +1 -1
  112. package/dist/server/mcp/index.d.ts +89 -27
  113. package/dist/server/mcp/index.js +281 -62
  114. package/dist/server/mcp/index.js.map +1 -1
  115. package/dist/server/otel.d.ts +3 -3
  116. package/dist/{sharing-DVa8W-yR.d.ts → sharing-BrsmlDv_.d.ts} +3 -3
  117. package/dist/testing/client.d.ts +14 -14
  118. package/dist/testing/client.js +19 -10
  119. package/dist/testing/client.js.map +1 -1
  120. package/dist/testing/index.d.ts +50 -13
  121. package/dist/testing/index.js +113 -21
  122. package/dist/testing/index.js.map +1 -1
  123. package/dist/testing/mock.d.ts +5 -5
  124. package/dist/testing/mock.js +17 -9
  125. package/dist/tracked-BR5b2O-U.d.ts +43 -0
  126. package/dist/{types-Cu0vmx7c.d.ts → types-CLRCQGnO.d.ts} +4 -3
  127. package/dist/types-tMagHbjy.d.ts +151 -0
  128. package/dist/utils/index.d.ts +16 -4
  129. package/dist/utils/index.js +6 -3
  130. package/package.json +5 -1
  131. package/dist/chunk-2PWMUVC7.js.map +0 -1
  132. package/dist/chunk-4DIQHASK.js.map +0 -1
  133. package/dist/chunk-4YO5H5ON.js.map +0 -1
  134. package/dist/chunk-B2P7AZ25.js.map +0 -1
  135. package/dist/chunk-CDKG4TFA.js.map +0 -1
  136. package/dist/chunk-DD4SKCNO.js.map +0 -1
  137. package/dist/chunk-H23KEFBA.js.map +0 -1
  138. package/dist/chunk-OECZPRPW.js.map +0 -1
  139. package/dist/chunk-QEKVYG5F.js.map +0 -1
  140. package/dist/chunk-REX5YPHX.js.map +0 -1
  141. package/dist/chunk-RT6OILOI.js.map +0 -1
  142. package/dist/chunk-RZTP4IEW.js.map +0 -1
  143. package/dist/chunk-SHEGLZEP.js.map +0 -1
  144. package/dist/chunk-TBMKJ26R.js.map +0 -1
  145. package/dist/chunk-VSQBHPWH.js.map +0 -1
  146. package/dist/chunk-X5CWPX7H.js.map +0 -1
  147. package/dist/chunk-YSWH776Y.js +0 -13
  148. package/dist/chunk-YSWH776Y.js.map +0 -1
  149. package/dist/chunk-Z2LZ7IZ7.js.map +0 -1
  150. package/dist/chunk-ZIVPHYRG.js.map +0 -1
  151. package/dist/logger-BY28acW-.d.ts +0 -16
  152. /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 { createMemorySessionStore, verifyJWT } from "@fitzzero/quickdraw-core/server/auth";
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
- /** The user a bearer token signs in, or `null` for no token. */
375
- export async function verifySession(token: unknown): Promise<AppPrincipal | null> {
376
- if (typeof token !== "string") {
377
- return null;
378
- }
379
- const payload = await verifyJWT(token, jwtSecret);
380
- return payload === null ? null : { userId: payload.userId, kind: "user" };
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 is typed
612
- by the `contracts` of `initQuickdraw`'s types, as `qd.caller` is.
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 (not with `custom` access); `ttlMs` keeps
615
- a shared result. Callers of a shared run whose levels hide the same fields
616
- get one copy of its result, encoded once for all of their sockets.
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
- // a check on a token's claims stays a custom form
732
- access: custom(
733
- (ctx, input) => ctx.principal.claims?.scope === "triage" && input.status !== "archived",
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. A check on a claim, such as a token's `scope`, stays a
762
- `custom` form (`triage` above).
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 checks no row (`"public"`, `"authenticated"`, `{ service: L }` below
765
- `Admin`) would let anyone that form admits reach any row by its id, so
766
- `defineService` refuses it, naming the method and the two ways out: a form
767
- the policy decides (`{ entry: L }`, or `{ service: L, entry: L }` to keep
768
- the grant), or `rowless: true` on the method when every such caller may
769
- reach any row on purpose (the `title` method above; public profiles,
770
- lookups by an id that tells nothing). A kit's methods take it as
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) and a row named by another key (`ids`, `taskId`, a
777
- nested `where.id`).
778
- - A service-wide `Admin` grant passes every check on its service
779
- (`adminBypass: false` turns that off). A grant below `Admin` counts only
780
- where the form names `service`: a `Read` grant does not read every row.
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, their sockets
810
- leave the rooms anchored on that row and get `qd:revoked`; a changed level
811
- moves them to that level's room. In a cluster the change is broadcast to
812
- every node.
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
- - Not seen: nested writes (`{ labels: { create: [...] } }`, which warn in
886
- development), raw SQL and database cascades. Record raw SQL with
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, and what happens when a node or Valkey stops
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
- When the item reads the junction (a chat's `memberCount`), declare
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
- - Items are visible to everyone in the scope: no per-row policy or field
1104
- tier applies inside a collection. Derive the item service's own access
1105
- from the anchor (`inherit` from it, as above): a per-row policy on the item
1106
- service (an owner column, a row's access list) is not applied to
1107
- collection items, so a row it would hide still reaches everyone in its
1108
- scope.
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 it was (`request.credential`):
1161
- `socketAuth` answers a cookie sent from a page outside `allowedOrigins`
1162
- with `FORBIDDEN`, as it refuses such a socket; the reply is `{ ok: true, d }` or
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
- for both transports and returns a principal, a user id, or nothing for an
1181
- anonymous caller; throwing `QuickdrawError("UNAUTHENTICATED", ...)` refuses.
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, access checks and limits apply exactly as on a socket.
1235
- A method whose input cannot describe itself as JSON Schema stops the
1236
- registry at startup, naming the method, unless it is excluded.
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
- // who a stdio session or an HTTP bearer token stands for; nothing is anonymous
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. Every call of such a tool gets the
1326
- binder's value before its input is validated, through `tools/call` and both
1327
- shapes of `POST /mcp/invoke` alike.
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 it is a property of an input that is not
1336
- an object, which it names: leave that method out, or make its input an
1337
- object.
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 calls with `UNAUTHENTICATED`.
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. The bridge never reshapes a
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; new credentials for the same user refetch it; new grants
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
- return <p>Connecting…</p>;
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 worker or a React Native module without hooks uses the
1605
- connection directly:
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
- `liveDataOf(connection, queryClient)` holds live rows and collections for
1628
- such code, and `createInvalidationCoordinator(queryClient)` invalidates as
1629
- the hooks do.
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
- bulkDelete: "authenticated",
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: "Admin" },
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. `list`, `getMany`, `bulkUpdate`
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): a list never shows a
1743
- row `get` would refuse. A service-wide `Admin` grant reaches every row,
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). Items are the `item`
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 admin kit's writes) never let a caller
1768
- without a service-wide `Admin` grant set a column the policy reads (an
1769
- owner column, an access list), and move a row into another parent (an
1770
- `inherit` policy's `via`, an anchored collection's scope column) only
1771
- when the caller's level on the new parent meets the method's row level:
1772
- `FORBIDDEN` otherwise.
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. Moves run SERIALIZABLE: two at
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: results
1899
- come in the scope collection's order, else by id.
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, a remove or a leave takes it out
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
- // finds the user inviteByName means; none is NOT_FOUND
2013
- resolveUser: async ({ name, email }) =>
2014
- (await db.user.findFirst({ where: name === undefined ? { email } : { name } }))?.id,
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 any level they had), `setLevel` changes
2026
- the level of a user the row is shared with, `unshare({ id, userId })`
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`; a mode adds
2036
- every method but those two unless `methods` names its own list.
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 (`FORBIDDEN`; a service-wide grant
2042
- counts where the form names `service`), and each change reads the
2043
- caller's level again inside its transaction, refusing one lowered by a
2044
- concurrent change (`FORBIDDEN`).
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
- // adminList is a query, not live data: read the page again after this screen's own write
2125
- onSuccess: () => qd.invalidate(qd.task.admin.adminList),
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 missing row
2173
- is `NOT_FOUND`.
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` and the timestamps,
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; `acl`, `serviceAccess` and
2194
- `service_access` are hidden.
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 `serviceAccess` (a `json` field the entity
2197
- schema checks), so the admin screen edits grants through `adminUpdate`.
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 either hook the kit opens no transaction.
2221
- Each handler `admin.handlers` returns resolves with its method's output
2222
- type (`AdminOutputOf<C, M>`), so a wrapper reads the row and returns it
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 them again after its own writes (the
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(`board:${payload.projectId}`, task, "cursorMoved", payload);
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 }` (a name, `{ prefix }`,
2399
- or for a scoped stream a function of the scope) opens it to the sockets
2400
- in that app room instead, signed in or not, and a socket that leaves the
2401
- room, or is taken out, leaves the feed; a stream without `access` is
2402
- closed. The answer is the seed; `useStream` then appends, keeps the
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 socket holds at most 500 feeds. 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
- - Presence: `isOnline`, `lastSeen` (now while online, else when the user's
2437
- last socket on this process disconnected), `count` and `users` (each user
2438
- once, anonymous sockets left out; app rooms only) come from this process's
2439
- sockets, and from every node's (`fetchSockets`) behind a Redis adapter.
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`; at most 100 per socket; a method that shares
2443
- its runs, `share`, may not join or leave with its caller's socket:
2444
- `INTERNAL`), and the room's sockets get `qd:presence` frames: the list on
2445
- joining, then who joins and who leaves. `usePresence(room)` shows them.
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: their sockets leave its board on every node, so they hear
2478
- // nothing more of it and its cursor channel drops their messages (also ctx.rooms.leave)
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 `basePath`). The callback clears it
2616
- whatever happens, and accepts only that state, for that provider, within
2617
- its 10 minutes, once (each redeemed state is remembered by the process),
2618
- so a sign-in cannot be finished in another browser or replayed.
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` learns where an HTTP
2661
- call's token came from in `request.credential` (`"cookie"` or
2662
- `"bearer"`, type `HttpCredentialSource`), applies the same rule with
2663
- `cookieOriginAllowed(request, allowedOrigins)`, and a
2664
- `QuickdrawError("FORBIDDEN")` it throws on HTTP is answered as it is.
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. `socketAuth` throws when it is given while `NODE_ENV` is
2669
- `production`, and refuses such a handshake there anyway; pass it only
2670
- behind the app's own flag.
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` other than `production`), and
2710
- every request checks again. Set `mock({ internalUrl })` where the API
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 the name they would set on it. Without a
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 `__Host-session`. A configured name is read as it is, on
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. Behind a proxy that ends TLS,
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 keeps the principal it authenticated with until it reconnects,
2737
- so a revoked session's open sockets are ended with
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 (with a token in `auth`, clearing it does that):
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 and `app.connect(principal)` over a
2922
- real socket (`{ call, socket, hello, close }`); both are keyed by service
2923
- name. `app.frames(match?)` lists every frame the server sent, with its
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
- - A few reads happen once per process (the storage adapter asks once
3101
- whether an order column may hold null). When a step could be the first to
3102
- pay for one, run it once before measuring it, so its budget does not
3103
- depend on the order tests run in.
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 | Raised when |
3116
- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3117
- | `n-plus-one` | a call ran 10 statements of one shape (model, operation, `where` keys), outside a `$transaction([])` |
3118
- | `unbounded-read` | a call ran `findMany` with neither `take` nor ids to read (`id`, `{ in }` or `{ equals }`) |
3119
- | `oversized-response` | a reply was larger than `maxResponseBytes` (default 1 MiB) |
3120
- | `nested-write` | a write's `data` wrote a related row, which is not tracked |
3121
- | `ambient-write` | a tracked write ran outside any unit of work |
3122
- | `batch-read` | a write in an array-form `$transaction` read its rows outside the batch |
3123
- | `batch-create-many` | a `createMany` in an array-form `$transaction` could not report its rows |
3124
- | `repeated-call` | one connection repeated a call or was refused `RATE_LIMITED` again and again (below) |
3125
- | `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) |
3126
- | `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 |
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, or the field of a nested write), under `category: "quickdraw.dev"`.
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`. In tests, `createTestApp({ strictWarnings: true })`
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` and `resolver-without-reads`, are
3146
- thrown from `createTestApp` itself, so a strict app over such a method or
3147
- policy does not start.
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 checks no row on a method that
3410
- takes an `id`, unless it says `rowless: true`); then lint; then a
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
- | `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
3456
- | `./parser` | the JSON-only Socket.IO parser |
3457
- | `./testing` | `createTestApp`, `describeAccessMatrix`, `snapshotAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
3458
- | `./testing/client` | `renderWithQuickdraw`, and everything in `./testing/mock` |
3459
- | `./testing/mock` | `createMockClient` alone, without Testing Library: for browser bundles such as Storybook |
3460
- | `./testing/prisma` | test databases on PostgreSQL or PGlite |
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