@fitzzero/quickdraw-core 5.1.0 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +533 -180
  2. package/dist/{admin-DKwkIh5i.d.ts → admin-DWWrT1h6.d.ts} +2 -2
  3. package/dist/{adminSchemas-2DUA_hZ8.d.ts → adminSchemas-D4bFKsrb.d.ts} +6 -4
  4. package/dist/{auth-BhWqdyb-.d.ts → auth-DXiSJZaf.d.ts} +42 -16
  5. package/dist/{caller-CXTFswBO.d.ts → caller-BT-Mv4bD.d.ts} +117 -22
  6. package/dist/{chunk-RT6OILOI.js → chunk-4D2DOGAW.js} +2 -14
  7. package/dist/chunk-4D2DOGAW.js.map +1 -0
  8. package/dist/chunk-5CGO426C.js +15 -0
  9. package/dist/chunk-5CGO426C.js.map +1 -0
  10. package/dist/{chunk-A6DJBGOG.js → chunk-6K5BY47X.js} +4594 -3481
  11. package/dist/chunk-6K5BY47X.js.map +1 -0
  12. package/dist/{chunk-VJTFXHOK.js → chunk-753VJIUP.js} +13 -15
  13. package/dist/chunk-753VJIUP.js.map +1 -0
  14. package/dist/{chunk-JZBCLNDU.js → chunk-7QCUX4TK.js} +4 -4
  15. package/dist/chunk-7QCUX4TK.js.map +1 -0
  16. package/dist/chunk-AK23XSWD.js +16 -0
  17. package/dist/chunk-AK23XSWD.js.map +1 -0
  18. package/dist/{chunk-3674EIRT.js → chunk-AONAFOYU.js} +14 -5
  19. package/dist/{chunk-3674EIRT.js.map → chunk-AONAFOYU.js.map} +1 -1
  20. package/dist/chunk-DKE6QO26.js +136 -0
  21. package/dist/chunk-DKE6QO26.js.map +1 -0
  22. package/dist/{chunk-H23KEFBA.js → chunk-DUBU3BB3.js} +30 -1
  23. package/dist/chunk-DUBU3BB3.js.map +1 -0
  24. package/dist/{chunk-CDKG4TFA.js → chunk-GKH3LJ7N.js} +1 -1
  25. package/dist/chunk-GKH3LJ7N.js.map +1 -0
  26. package/dist/{chunk-KNDDCVQJ.js → chunk-GSIPUNJL.js} +49 -9
  27. package/dist/chunk-GSIPUNJL.js.map +1 -0
  28. package/dist/{chunk-DD4SKCNO.js → chunk-LCC7HNFP.js} +12 -1
  29. package/dist/chunk-LCC7HNFP.js.map +1 -0
  30. package/dist/{chunk-2PWMUVC7.js → chunk-MBH6IDSM.js} +480 -12
  31. package/dist/chunk-MBH6IDSM.js.map +1 -0
  32. package/dist/{chunk-2UELNHDD.js → chunk-NMXYENH4.js} +10 -10
  33. package/dist/{chunk-2UELNHDD.js.map → chunk-NMXYENH4.js.map} +1 -1
  34. package/dist/chunk-NO4M6PO5.js +101 -0
  35. package/dist/chunk-NO4M6PO5.js.map +1 -0
  36. package/dist/{chunk-TBMKJ26R.js → chunk-NW6U45HX.js} +3 -1
  37. package/dist/chunk-NW6U45HX.js.map +1 -0
  38. package/dist/{chunk-REX5YPHX.js → chunk-RJ5I2VO3.js} +74 -19
  39. package/dist/chunk-RJ5I2VO3.js.map +1 -0
  40. package/dist/{chunk-QEKVYG5F.js → chunk-RL4VKLBX.js} +1 -1
  41. package/dist/{chunk-QEKVYG5F.js.map → chunk-RL4VKLBX.js.map} +1 -1
  42. package/dist/chunk-XH2IZVLT.js +62 -0
  43. package/dist/chunk-XH2IZVLT.js.map +1 -0
  44. package/dist/{chunk-4DIQHASK.js → chunk-Z5ILULF2.js} +26 -7
  45. package/dist/chunk-Z5ILULF2.js.map +1 -0
  46. package/dist/cli/quickdraw-docs.js +5 -3
  47. package/dist/cli/quickdraw-docs.js.map +1 -1
  48. package/dist/client/index.d.ts +30 -18
  49. package/dist/client/index.js +8 -6
  50. package/dist/client/index.js.map +1 -1
  51. package/dist/{clientTypes-Bc-G8n-U.d.ts → clientTypes-hWcPUVS9.d.ts} +3 -3
  52. package/dist/{connection-Cr1BU07W.d.ts → connection-CgijdGpT.d.ts} +8 -4
  53. package/dist/{createServer-CP9eDHZZ.d.ts → createServer-Cp-Wk500.d.ts} +36 -14
  54. package/dist/{createTestApp-UKVGEmOf.d.ts → createTestApp-BB_PkqQ7.d.ts} +16 -10
  55. package/dist/{envelope-Cp3YcVxC.d.ts → envelope-DzWyIXjj.d.ts} +18 -4
  56. package/dist/{http-Djj2Inuu.d.ts → http-DotQ5bj8.d.ts} +68 -17
  57. package/dist/index.d.ts +7 -7
  58. package/dist/index.js +37 -13
  59. package/dist/index.js.map +1 -1
  60. package/dist/{infer-Teo1xpsd.d.ts → infer-BmoZsL9a.d.ts} +43 -10
  61. package/dist/logger-Yz93KzdV.d.ts +30 -0
  62. package/dist/{metrics-BdBu5o8G.d.ts → metrics-DW80Exve.d.ts} +2 -2
  63. package/dist/origins-kvpxnVcW.d.ts +8 -0
  64. package/dist/prisma/index.d.ts +5 -5
  65. package/dist/prisma/index.js +130 -67
  66. package/dist/prisma/index.js.map +1 -1
  67. package/dist/{rateLimit-ZMIZJUZF.js → rateLimit-5RKBFLUR.js} +2 -2
  68. package/dist/server/auth/index.d.ts +82 -33
  69. package/dist/server/auth/index.js +273 -268
  70. package/dist/server/auth/index.js.map +1 -1
  71. package/dist/server/express/index.d.ts +9 -2
  72. package/dist/server/express/index.js +1 -1
  73. package/dist/server/index.d.ts +147 -67
  74. package/dist/server/index.js +520 -381
  75. package/dist/server/index.js.map +1 -1
  76. package/dist/server/mcp/index.d.ts +63 -17
  77. package/dist/server/mcp/index.js +256 -54
  78. package/dist/server/mcp/index.js.map +1 -1
  79. package/dist/server/otel.d.ts +3 -3
  80. package/dist/{sharing-DVa8W-yR.d.ts → sharing-UDLn1049.d.ts} +3 -3
  81. package/dist/testing/client.d.ts +14 -14
  82. package/dist/testing/client.js +9 -7
  83. package/dist/testing/client.js.map +1 -1
  84. package/dist/testing/index.d.ts +12 -12
  85. package/dist/testing/index.js +21 -12
  86. package/dist/testing/index.js.map +1 -1
  87. package/dist/testing/mock.d.ts +5 -5
  88. package/dist/testing/mock.js +8 -6
  89. package/dist/{types-Cu0vmx7c.d.ts → types-CYliTP65.d.ts} +1 -1
  90. package/dist/utils/index.d.ts +1 -1
  91. package/package.json +1 -1
  92. package/dist/chunk-2PWMUVC7.js.map +0 -1
  93. package/dist/chunk-4DIQHASK.js.map +0 -1
  94. package/dist/chunk-A6DJBGOG.js.map +0 -1
  95. package/dist/chunk-CDKG4TFA.js.map +0 -1
  96. package/dist/chunk-DD4SKCNO.js.map +0 -1
  97. package/dist/chunk-H23KEFBA.js.map +0 -1
  98. package/dist/chunk-JZBCLNDU.js.map +0 -1
  99. package/dist/chunk-KNDDCVQJ.js.map +0 -1
  100. package/dist/chunk-REX5YPHX.js.map +0 -1
  101. package/dist/chunk-RT6OILOI.js.map +0 -1
  102. package/dist/chunk-TBMKJ26R.js.map +0 -1
  103. package/dist/chunk-VJTFXHOK.js.map +0 -1
  104. package/dist/chunk-YSWH776Y.js +0 -13
  105. package/dist/chunk-YSWH776Y.js.map +0 -1
  106. package/dist/logger-BY28acW-.d.ts +0 -16
  107. /package/dist/{rateLimit-ZMIZJUZF.js.map → rateLimit-5RKBFLUR.js.map} +0 -0
package/README.md CHANGED
@@ -353,7 +353,9 @@ export const cardSchema = taskSchema.omit({ notes: true });
353
353
 
354
354
  The sessions, the secret and the grants the server reads,
355
355
  `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)):
356
+ the same `app` (see the [auth routes kit](#auth-routes-kit)). `verifySession`
357
+ checks a bearer token against the session store on every call
358
+ (`bearerAuth`), so a signed-out or revoked session stops at once:
357
359
 
358
360
  <!-- example: apps/api/src/auth.ts -->
359
361
 
@@ -361,7 +363,7 @@ the same `app` (see the [auth routes kit](#auth-routes-kit)):
361
363
  // The app's sessions, token check and grants, for the README's server examples.
362
364
 
363
365
  import type { AccessLevel } from "@fitzzero/quickdraw-core";
364
- import { createMemorySessionStore, verifyJWT } from "@fitzzero/quickdraw-core/server/auth";
366
+ import { bearerAuth, createMemorySessionStore } from "@fitzzero/quickdraw-core/server/auth";
365
367
  import { db } from "./db";
366
368
  import type { AppPrincipal } from "./quickdraw";
367
369
 
@@ -371,14 +373,17 @@ export const jwtSecret = process.env.JWT_SECRET ?? "";
371
373
  /** The sessions the auth routes issue. In production, a store over the database (see the auth routes kit). */
372
374
  export const sessions = createMemorySessionStore();
373
375
 
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
- }
376
+ /**
377
+ * The agent a bearer token signs in, or `null` for no token: checked against
378
+ * the session store on every call, so a signed-out or revoked session stops
379
+ * at once. An agent, so methods with `kinds: ["user"]` refuse it. Never
380
+ * `verifyJWT` alone, which checks no session.
381
+ */
382
+ export const verifySession = bearerAuth({
383
+ sessions,
384
+ jwtSecret,
385
+ loadPrincipal: (userId): AppPrincipal => ({ userId, kind: "agent" }),
386
+ });
382
387
 
383
388
  /** A user's service-wide grants, as stored in `User.serviceAccess`. */
384
389
  export async function loadGrants(userId: string): Promise<Record<string, AccessLevel>> {
@@ -422,6 +427,9 @@ model Project {
422
427
  }
423
428
 
424
429
  // A membership table: role holds an access level ("Read" | "Moderate" | "Admin").
430
+ // It has an id, and its pair is unique by @@unique: the write tracker keys
431
+ // rows by id, so a table keyed by a composite @@id is never tracked, and a
432
+ // member removed from it would never be revoked.
425
433
  model ProjectMember {
426
434
  id String @id @default(cuid())
427
435
  projectId String
@@ -541,6 +549,9 @@ export const healthContract = defineContract("healthService", {
541
549
  because the client exposes each as `qd.<service>.<name>`. `subscribe`,
542
550
  `unsubscribe`, `call`, `then`, `useEntity`, `useEntities`, `admin` and
543
551
  names starting with `$` are reserved.
552
+ - A service name holds no `:` or `@`: it is part of every room name of the
553
+ service (`qd:e:{service}:{id}@{level}`, `qd:c:{service}:{collection}:{scope}`),
554
+ which must split one way only, so `defineContract` throws on one.
544
555
  - Schemas are any Standard Schema. A projection's keys, admin field
545
556
  metadata, MCP tool schemas and `quickdraw-docs` read Standard JSON Schema,
546
557
  which Zod 4.2 or later provides; a projection whose schema cannot describe
@@ -611,9 +622,19 @@ export const taskService = qd.defineService(task, {
611
622
  flush together once; and it is cancelled with `ctx.signal`. It is typed
612
623
  by the `contracts` of `initQuickdraw`'s types, as `qd.caller` is.
613
624
  - `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.
625
+ `share: "all"` across principals; `ttlMs` keeps a shared result. A
626
+ `"all"` run's handler sees the first caller's `ctx` and every caller gets
627
+ its result, so a result shared across principals must depend on the
628
+ input only: it never reads `ctx.principal`, `ctx.services`, `ctx.mcp` or
629
+ a context field made from them (in development, reading one of the first
630
+ three warns `[quickdraw:shared-principal]`; copying or logging the whole
631
+ `ctx` reads them too). `"all"` is refused with
632
+ `custom` access and on the kit methods whose result depends on the caller
633
+ (the read/write kit's `list` and `getMany`, `search`, the sharing kit's
634
+ `listShares` and `listMembers`, the admin kit's `adminList` and
635
+ `adminGet`): give those `"caller"`. Callers of a shared run whose levels
636
+ hide the same fields get one copy of its result, encoded once for all of
637
+ their sockets.
617
638
  `version(input, ctx)` answers "not modified" for a query whose result the
618
639
  caller already holds.
619
640
  - Every call runs a pipeline: look up, concurrency (16 queries in flight per
@@ -679,7 +700,7 @@ not exist or a malformed access list denies.
679
700
  <!-- example: apps/api/src/services/examples/access.ts#access -->
680
701
 
681
702
  ```ts
682
- import { anyOf, crud, custom, inherit, jsonAcl, members } from "@fitzzero/quickdraw-core/server";
703
+ import { anyOf, crud, inherit, jsonAcl, members } from "@fitzzero/quickdraw-core/server";
683
704
 
684
705
  export const projectService = qd.defineService(project, {
685
706
  // the Prisma model the rows live in
@@ -728,10 +749,9 @@ export const taskService = qd.defineService(task, {
728
749
  db.task.update({ where: { id: input.id }, data: { assigneeId: ctx.principal.userId } }),
729
750
  },
730
751
  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
- ),
752
+ // Moderate on the task's project, from a token that may triage: claims add to the form
753
+ access: { entry: "Moderate" },
754
+ claims: (claims, input) => claims?.scope === "triage" && input.status !== "archived",
735
755
  handler: ({ input, db }) =>
736
756
  db.task.update({ where: { id: input.id }, data: { status: input.status } }),
737
757
  },
@@ -745,7 +765,9 @@ export const taskService = qd.defineService(task, {
745
765
  `{ scope: L, of, id }` (the level on a row of another service) and
746
766
  `custom(fn)`. Without a principal every form but `"public"` answers
747
767
  `UNAUTHENTICATED`; a principal that fails gets `FORBIDDEN`. The levels,
748
- lowest first, are `Public`, `Read`, `Moderate` and `Admin`.
768
+ lowest first, are `Public`, `Read`, `Moderate` and `Admin`. A `custom`
769
+ form replaces the row check, and a service-wide `Admin` grant skips it: a
770
+ check on the caller's claims is `claims` (below).
749
771
  - `kinds`, beside `access`, says which kinds of principal
750
772
  (`principal.kind`) may call: `initQuickdraw({ kinds: ["user", "agent"] })`
751
773
  for every service the app defines, `defineService(contract, { kinds })`
@@ -758,26 +780,44 @@ export const taskService = qd.defineService(task, {
758
780
  `"public"` method takes no `kinds` of its own: a refused caller would call
759
781
  it signed out. A service's list also holds for its entity, collection,
760
782
  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).
783
+ other kinds.
784
+ - `claims: (claims, input) => boolean`, beside `access` on a method, is a
785
+ requirement on the caller's verified claims (`principal.claims`, a
786
+ token's `scope`; `triage` above). It adds to the form, so `triage` needs
787
+ Moderate on the task's project and a triage token. It runs right after
788
+ `kinds`, outside the access engine, so no grant passes it, a service-wide
789
+ `Admin` grant included. Without a principal the call is
790
+ `UNAUTHENTICATED`; when it returns anything but `true`, `FORBIDDEN`. It is
791
+ synchronous, and a `"public"` method takes none. A token narrowed by
792
+ `claims` is narrowed only on the methods that declare it; elsewhere it
793
+ acts with its user's grants (`kinds` keeps a kind of token out).
763
794
  - 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
795
+ that asks the policy nothing about the row that id names would let anyone
796
+ that form admits reach any row by its id, so `defineService` refuses it,
797
+ naming the method and the ways out. Those forms are the ones that check
798
+ no row (`"public"`, `"authenticated"`, `{ service: L }` below `Admin`),
799
+ `custom(fn)`, `{ scope, of, id }` (a row of another service) and
800
+ `{ entry, id }` naming another key. The ways out: a form the policy
801
+ decides (`{ entry: L }`, or `{ service: L, entry: L }` to keep the grant,
802
+ with `claims` beside it for a check on the caller's claims), or
803
+ `rowless: true` on the method when every such caller may reach any row on
804
+ purpose (the `title` method above; public profiles, lookups by an id that
805
+ tells nothing), or when `id` names a row the method creates (an id the
806
+ client made, under a `scope` form). A `rowless` query under a form that
807
+ checks no row also answers "not modified" for any `id`; under any other
808
+ form it runs its handler every time. A kit's methods take it as
771
809
  `rowless: ["get"]` in the kit's options. The input's keys come from its
772
810
  JSON Schema: an `id` in any branch of a union counts, and so does one
773
811
  beside a value JSON Schema cannot write (a `Date`, a `Set`). Not checked:
774
812
  an input without JSON Schema (Zod 3) or whose JSON Schema names no keys (a
775
813
  `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.
814
+ that is the id itself), a row named by another key (`ids`, `taskId`, a
815
+ nested `where.id`) under a form that checks the row `id` names, and an
816
+ `entry` form whose `id` is a function, which names its row in code.
817
+ - A service-wide `Admin` grant passes every access form on its service
818
+ (`adminBypass: false` turns that off), though not `kinds` or `claims`. A
819
+ grant below `Admin` counts only where the form names `service`: a `Read`
820
+ grant does not read every row.
781
821
  - Grants come from `principal.serviceAccess`, as `authenticate` returns it or
782
822
  `createServer({ auth: { loadServiceAccess } })` loads it for a principal
783
823
  that carries none: at a socket's handshake, for each HTTP call, and for
@@ -803,20 +843,26 @@ export const taskService = qd.defineService(task, {
803
843
  call, so checking 60 ids costs what checking one does. `entry` access
804
844
  needs a policy; a service without `model` may only use `"public"`,
805
845
  `"authenticated"`, `{ service }` and `custom`. `inherit` uses the
806
- parent's policy only: grants on the parent's service do not flow down.
846
+ parent's policy only: grants on the parent's service do not flow down. A
847
+ membership table needs an `id` column (the pair unique by `@@unique`),
848
+ or its writes are not tracked and a removed member is never revoked.
807
849
  - One policy decides every surface: method calls, entity subscriptions,
808
850
  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.
851
+ - When a tracked write lowers or removes someone's access, a relation-form
852
+ foreign key included (`project: { connect }`), their sockets leave the
853
+ rooms anchored on that row and get `qd:revoked`; a changed level moves
854
+ them to that level's room. In a cluster the change is broadcast to every
855
+ node. Every socket's re-resolution settles before the flush's frames go
856
+ out, a failed one included.
813
857
  - `server.dispatcher.access` gives the same answers to other code:
814
858
  `levelsFor(service, principal, ids)`, `accessWhere(service, principal, level)`
815
859
  (a `where` filter for `findMany`, or `"none"`) and `onAccessChanged(listener)`.
816
860
  - `createServer({ access: { cacheMs: 30_000 } })` keeps policy lookups across
817
861
  requests; tracked writes to the columns and membership tables the policies
818
862
  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.
863
+ when the time passes, so the cache is off by default. A lookup inside a
864
+ transaction (`db.$transaction`, a sharing change) reads the database,
865
+ never the cache.
820
866
 
821
867
  ## Tracked writes
822
868
 
@@ -882,8 +928,12 @@ export async function markStale(before: Date): Promise<number> {
882
928
  }
883
929
  ```
884
930
 
885
- - Not seen: nested writes (`{ labels: { create: [...] } }`, which warn in
886
- development), raw SQL and database cascades. Record raw SQL with
931
+ - Not seen: the related rows of a nested write
932
+ (`{ labels: { create: [...] } }`, which warns in development), raw SQL and
933
+ database cascades. A foreign key a nested write sets on the written row
934
+ (`project: { connect }`, `parent: { disconnect: true }`) is seen: such an
935
+ `update` or `upsert` reads the row's interested columns first, one more
936
+ statement. Record raw SQL with
887
937
  `ctx.touch("task", ids)`, or `{ removed: true }` for deleted rows. A job
888
938
  gets the same `touch` from `qd.run`, whose `fn` receives
889
939
  `{ touch, log, principal: null }`:
@@ -905,7 +955,13 @@ export async function spreadOrdinals(projectId: string): Promise<void> {
905
955
  ```
906
956
 
907
957
  - Tracked models need a string `id` column; writes to other models pass
908
- through untracked, with one warning.
958
+ through untracked, with one warning. Membership and junction tables
959
+ included: give them `id String @id @default(cuid())` and keep the pair
960
+ unique with `@@unique([...])`, never a composite `@@id`. `createServer`
961
+ checks, at start, every model a policy, a membership table or a `via`
962
+ junction depends on, and logs `[quickdraw:untracked-access-model]` as an
963
+ error, in every environment, for one it cannot track; a test app made
964
+ with `strictWarnings` fails to start.
909
965
  - `createServer({ flushSink })` adds the app's own sinks (an audit log, say);
910
966
  `createRecordingSink()` on `./testing` records what is flushed, for tests.
911
967
 
@@ -1003,7 +1059,14 @@ export const taskService = qd.defineService(task, {
1003
1059
  by `id`) comes from `versionColumn`, or from an in-process change log of
1004
1060
  recent flushes. The change log sees only this process's writes: an app
1005
1061
  running several processes without a Socket.IO cluster adapter declares
1006
- `versionColumn`s or passes `changeLog: false`.
1062
+ `versionColumn`s or passes `changeLog: false`. For a query, it comes only
1063
+ when its form checked the row `id` names (an `entry` form on `id`, a
1064
+ `scope` form on the service's own rows by `id`, `{ service: "Admin" }`),
1065
+ for a caller the service's `Admin` bypass passes, or for a `rowless`
1066
+ method under a form that checks no row (`"public"`, `"authenticated"`,
1067
+ `{ service: L }`): it answers before the handler runs, so it would tell
1068
+ any other caller that the row exists and has not changed. Any other
1069
+ query runs its handler.
1007
1070
  - Revisions are microseconds since the epoch: a flush takes
1008
1071
  `max(Date.now() * 1000, last + 1)`. Clients compare them as numbers.
1009
1072
  - Behind a cluster adapter (`setupRedisAdapter`), every touched row is read
@@ -1015,10 +1078,14 @@ export const taskService = qd.defineService(task, {
1015
1078
  Flushes take their revisions from a counter in the cluster's Valkey, on
1016
1079
  Valkey's clock in microseconds, so revisions from all nodes are one order;
1017
1080
  its key needs persistence or replication. A node whose Valkey connection
1018
- comes back has its clients reconnect to catch up.
1081
+ comes back has its clients reconnect to catch up. Give each deployment its
1082
+ own Valkey, or its own `cluster.keyPrefix` (which `setupRedisAdapter` takes
1083
+ as its key): a node applies only its own deployment's events (once every
1084
+ deployment that shares the Valkey runs this version), and a Valkey's `db`
1085
+ does not separate pub/sub.
1019
1086
  [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.
1087
+ nodes, what it costs, what happens when a node or Valkey stops
1088
+ answering, and a production checklist.
1022
1089
 
1023
1090
  ## Collections and change topics
1024
1091
 
@@ -1075,7 +1142,7 @@ export const taskService = qd.defineService(task, {
1075
1142
  collections: {
1076
1143
  // Read on the project opens its scope
1077
1144
  byProject: { anchor: projectContract },
1078
- // a user opens only the scope that is their id
1145
+ // a user opens only the scope that is their id; its items still follow access above
1079
1146
  assigned: { scopeAccess: "self" },
1080
1147
  },
1081
1148
  // opens the service topic to Read grants; closed without it
@@ -1090,7 +1157,13 @@ export const taskService = qd.defineService(task, {
1090
1157
  pages; `where` is an equality filter on membership; `access` is the level
1091
1158
  needed on the anchor (default `Read`).
1092
1159
  - 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
1160
+ Over the item service's own `members` table (a `members` read of the
1161
+ policy, alone or inside `anyOf`, whose `entry` is the via's `entry` and
1162
+ whose `user` is the via's `scope`), a link counts only when its
1163
+ role gives at least the collection's `access`: a role change across that
1164
+ line adds or removes the entry, and a link below it is in no page, count,
1165
+ index, `qd:col:items` or scoped search. When the item reads the junction
1166
+ (a chat's `memberCount`), declare
1094
1167
  `via({ model, entry, scope, refreshEntry: true })`: every junction write
1095
1168
  then also sends the entry again, `updated`, to each scope that still
1096
1169
  holds it, for one more read of the item per flush.
@@ -1099,13 +1172,21 @@ export const taskService = qd.defineService(task, {
1099
1172
  deltas to it (`added`, `updated`, `patched`, `removed`, or `reset` for more
1100
1173
  than `bulkThreshold` rows, default 200). A `"self"` scope is the
1101
1174
  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.
1175
+ declare a higher `access`. On a service with an access policy its items
1176
+ still follow that policy: a page, `qd:col:items` and every flush hold only
1177
+ the rows its user may read at `Read`. A row the user loses goes out
1178
+ `removed`, and a change to their membership of a parent row (or a reload
1179
+ of their grants) sends `reset`, so the list loads again; a client that
1180
+ resumes the list after such a change reads a page. A change to a parent
1181
+ row's own access columns (a project's owner or access list) reaches the
1182
+ list only at the rows' next write or the list's next load.
1183
+ - Items are visible to everyone in an anchored scope: no per-row policy or
1184
+ field tier applies inside it. Derive the item service's own access from
1185
+ the anchor (`inherit` from it, as above): a per-row policy on the item
1186
+ service (an owner column, a row's access list) is not applied to an
1187
+ anchored scope's items, so a row it would hide still reaches everyone in
1188
+ its scope. A `"self"` scope is the exception: its items follow the item
1189
+ service's policy for its user.
1109
1190
  - `index` sends one small row per member (`[id, rev, ...fields]`, up to
1110
1191
  50,000) with the first page, so the client knows the whole membership and
1111
1192
  order at once; `views` are pure predicates over index rows the client runs.
@@ -1128,11 +1209,27 @@ export const taskService = qd.defineService(task, {
1128
1209
  changed it (`models`). The service topic is closed
1129
1210
  (`FORBIDDEN`) unless the service declares `watchAccess` (`"public"`,
1130
1211
  `"authenticated"` or `{ service: level }`). A watcher that loses access
1131
- leaves the topic after one last `qd:changed`.
1212
+ leaves the topic after one last `qd:changed`. A watch whose access
1213
+ changed while it was answered is authorized again before it joins the
1214
+ topic, with the socket's principal as it is then, and answered
1215
+ `FORBIDDEN` when it lost access; it hears nothing until it joins. Entity
1216
+ and collection subscribes re-check the same way, with the grants the
1217
+ socket holds then (a grant lowered mid-subscribe denies, a raised one
1218
+ answers at its level), but from inside their room, which they need to
1219
+ catch every change after the snapshot they read: a frame sent while they
1220
+ re-check can reach a socket that is then refused, and the client drops
1221
+ it with the refusal.
1132
1222
  - The socket rate limiter does not count subscription events; each socket
1133
1223
  runs `qd:sub`, `qd:col:sub`, `qd:col:items` and `qd:watch` in a lane
1134
1224
  instead: `limits.subscriptions` (8 at once, 64 waiting), then
1135
- `RATE_LIMITED`.
1225
+ `RATE_LIMITED`. A socket holds at most 10,000 entity ids, 1,000
1226
+ collection scopes, 1,000 topic watches and 500 stream feeds at once:
1227
+ one more is `CONFLICT` (a `qd:sub` whose new ids would pass the cap is
1228
+ refused whole, and a row that batches running at once left no room for
1229
+ is answered `CONFLICT` on its own), one held already never counts again,
1230
+ and `qd:hello`
1231
+ announces the caps (`limits.subscriptions.maxEntityIds`, `maxScopes`,
1232
+ `maxTopics`).
1136
1233
 
1137
1234
  ## The server and its transports
1138
1235
 
@@ -1151,21 +1248,36 @@ over three transports (design: sections 3, 8 and 10):
1151
1248
  events per minute per socket; channels, cancels and subscription events not
1152
1249
  counted); configure it with `rateLimit`, or turn it off with
1153
1250
  `rateLimit: false`. There is no default CORS origin: pass `cors`.
1251
+ Socket.IO's connection state recovery is refused: `createServer` throws
1252
+ for `socket: { connectionStateRecovery }`, since a recovered socket would
1253
+ come back with its principal and rooms without authenticating again, so a
1254
+ revoked session or a lowered grant would return. Clients resume by
1255
+ revision on every reconnect instead.
1154
1256
  - **HTTP**: `POST /qd/{service}/{method}` with the input as a JSON body and
1155
1257
  `Content-Type: application/json` (required, even without a body, so a
1156
1258
  cross-site page cannot use a session cookie without a CORS preflight). The
1157
1259
  principal comes from the session cookie (`__Host-session` over HTTPS,
1158
1260
  `session` over plain HTTP or with `COOKIE_DOMAIN`, as the auth routes name
1159
1261
  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
1262
+ `authenticate`, which is told which one the transport chose
1263
+ (`request.credential`): `socketAuth` answers a cookie sent from a page
1264
+ outside `allowedOrigins` with `FORBIDDEN`, as it refuses such a socket.
1265
+ The cookie is `auth.token` only for `socketAuth`'s `authenticate`; any
1266
+ other finds it in `request.sessionCookie` alone, and `auth.token` holds
1267
+ only a bearer token. The reply is `{ ok: true, d }` or
1163
1268
  `{ ok: false, e: { code, message, data? } }` with the code's HTTP status.
1164
1269
  Works on Express 4 and 5, and on a bare Node server. Move it with
1165
1270
  `http: { path }`, turn it off with `http: false`, or mount
1166
1271
  `createHttpRouter({ dispatcher, auth })` yourself. It has no rate limit of
1167
1272
  its own: on Express, set `http: { rateLimit: createCallLimiter() }` (from
1168
- `./server/express`), which refuses with the `RATE_LIMITED` reply.
1273
+ `./server/express`), which refuses with the `RATE_LIMITED` reply. An app
1274
+ without sockets makes its dispatcher with
1275
+ `createDispatcher({ services, db, serviceAccessSource })` when users'
1276
+ grants are stored in a column (the admin kit protects that column only
1277
+ where the dispatcher names it, as `createServer` does from
1278
+ `auth.serviceAccessSource`), and `createHttpRouter` throws when its
1279
+ `auth.serviceAccessSource` is not its dispatcher's. An MCP process made
1280
+ with `createDispatcher` names it the same way.
1169
1281
  - **In process**: `server.dispatcher.caller(principal)` or
1170
1282
  `qd.caller(principal)`: `await qd.caller(user).taskService.rename(input)`,
1171
1283
  typed by the `contracts` of `initQuickdraw`'s types. A principal that
@@ -1176,9 +1288,11 @@ over three transports (design: sections 3, 8 and 10):
1176
1288
  `auth.serviceAccessSource`). One that carries grants (even `{}`) keeps
1177
1289
  exactly those. A load that fails rejects the call with `INTERNAL`.
1178
1290
 
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.
1291
+ `authenticate` takes one request (`{ transport, auth, headers, socket | req }`,
1292
+ plus `credential` and `sessionCookie` on HTTP) for both transports and
1293
+ returns a principal, a user id, or nothing for an anonymous caller; throwing
1294
+ `QuickdrawError("UNAUTHENTICATED", ...)` refuses. On HTTP the session cookie
1295
+ is in `auth.token` only for `socketAuth`'s `authenticate`.
1182
1296
  Pass your own HTTP server as `httpServer` together with the `app` it was
1183
1297
  created from (or with `http: false`).
1184
1298
 
@@ -1231,13 +1345,25 @@ one, by `service.method (kind)`, which tells an agent nothing), with the
1231
1345
  input schema's JSON Schema as its arguments and `readOnlyHint` on every
1232
1346
  query. The API docs print each method's default tool name and its read-only
1233
1347
  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.
1348
+ so input validation and access checks apply as on a socket. Queries share
1349
+ a concurrency lane per caller (16 run and 64 wait by default, the
1350
+ dispatcher's `limits`, then `RATE_LIMITED`): a stdio process, and on HTTP
1351
+ a bearer token, a user without a token, or the address of an anonymous
1352
+ caller (Express's `req.ip`, an IPv6 address by its /56). Behind a proxy,
1353
+ set Express's `trust proxy`, or every anonymous caller shares the proxy's
1354
+ lane. A `principal` hook that authenticates by anything but the bearer
1355
+ token (a cookie, an API key header) must refuse a request whose bearer
1356
+ token it does not verify, or a caller makes a lane per made-up token. The
1357
+ HTTP routes have no rate limit of their own: limit them per address, as
1358
+ below (`createPublicApiLimiter` answers 429 with `{ error }`, not the
1359
+ routes' `{ success: false, code }`). A method whose input cannot describe
1360
+ itself as JSON Schema stops the registry at startup, naming the method,
1361
+ unless it is excluded.
1237
1362
 
1238
1363
  <!-- example: apps/api/src/mcp.ts#mcp -->
1239
1364
 
1240
1365
  ```ts
1366
+ import { createPublicApiLimiter } from "@fitzzero/quickdraw-core/server/express";
1241
1367
  import {
1242
1368
  createMcpHttpRouter,
1243
1369
  createMcpRegistry,
@@ -1249,7 +1375,7 @@ const summarizeInput = z.object({ projectId: z.string() });
1249
1375
  const registry = createMcpRegistry({
1250
1376
  services: [projectService, taskService],
1251
1377
  dispatcher: server.dispatcher,
1252
- // who a stdio session or an HTTP bearer token stands for; nothing is anonymous
1378
+ // an agent acting for the user of a session token, checked on every call; no token is anonymous
1253
1379
  principal: (request) =>
1254
1380
  verifySession(request.transport === "http" ? request.token : process.env.AGENT_TOKEN),
1255
1381
  // handlers read it as ctx.mcp
@@ -1269,7 +1395,9 @@ const registry = createMcpRegistry({
1269
1395
  ],
1270
1396
  });
1271
1397
 
1272
- // GET /mcp/tools, POST /mcp/invoke
1398
+ // GET /mcp/tools, POST /mcp/invoke; the routes have no rate limit of their own, so limit them per
1399
+ // address (never by the bearer token, which nothing has checked yet)
1400
+ app.use("/mcp", createPublicApiLimiter({ max: 600 }));
1273
1401
  app.use(createMcpHttpRouter({ registry }));
1274
1402
  // in an MCP client's process
1275
1403
  createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
@@ -1284,7 +1412,17 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
1284
1412
  sends console output to stderr so only the protocol reaches stdout.
1285
1413
  - **HTTP**: `GET /mcp/tools` and `POST /mcp/invoke`, which takes
1286
1414
  `{ name, arguments }` and answers `{ success: true, data }`, or
1287
- `{ success: false, error, code, data? }` with the code's HTTP status.
1415
+ `{ success: false, error, code, data? }` with the code's HTTP status. A
1416
+ request from a web page (one with an `Origin` header) is answered 403
1417
+ unless `createMcpHttpRouter({ allowedOrigins })` lists its origin (exact
1418
+ origins or anchored patterns, as `socketAuth` takes them; none by
1419
+ default); an MCP client sends no `Origin`. `allowedOrigins` adds no CORS
1420
+ headers: a page on another origin still needs the app's CORS setup. For a
1421
+ server on a developer's machine or a LAN,
1422
+ `allowedHosts: ["localhost", "127.0.0.1", "::1"]` also refuses a request
1423
+ for any other `Host`, which a DNS-rebinding page would send. An entry with
1424
+ a port matches that port only (a `Host` without one is for port 80 or
1425
+ 443).
1288
1426
  - An anonymous caller (the `principal` hook returned nothing) may call
1289
1427
  `"public"` methods, and custom tools that declare `access: "public"`; any
1290
1428
  other tool answers `UNAUTHENTICATED` before it runs. A failed call reaches
@@ -1294,6 +1432,19 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
1294
1432
  tools it may not call (and its call is refused). Keep a tool out of the
1295
1433
  list with `exclude` or `include`, per registry, and serve agents of
1296
1434
  different reach from separate registries.
1435
+ - `kinds` sees only what the `principal` hook labels: `verifySession` above
1436
+ labels every MCP caller `kind: "agent"`, so a method with
1437
+ `kinds: ["user"]` refuses it. Check a session token against the store on
1438
+ every call (`bearerAuth`), never with `verifyJWT` alone, which a
1439
+ signed-out session still passes. A custom tool obeys the app's
1440
+ `initQuickdraw({ kinds })` too, and takes `kinds` of its own within it
1441
+ (`FORBIDDEN` for another kind, before its arguments are read); a
1442
+ `"public"` tool takes none. Custom tools served beside the services of
1443
+ apps that admit no kind in common stop the registry at startup.
1444
+ - To refuse a token, the `principal` hook throws
1445
+ `QuickdrawError("UNAUTHENTICATED")`, which is logged at debug, as the
1446
+ caller's doing; anything else it throws is logged as an error. Never put
1447
+ the token in the message.
1297
1448
 
1298
1449
  `bind` fills chosen arguments from who is calling instead of from the agent,
1299
1450
  so an agent bound to one project cannot name another in its calls. Here an
@@ -1322,9 +1473,14 @@ app.use(createMcpHttpRouter({ registry: projectAgents, path: "/agents" }));
1322
1473
 
1323
1474
  - Each bound argument is left out of the `inputSchema` of every method's tool
1324
1475
  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.
1476
+ `count_tasks` takes no arguments. An object schema with `.meta({ id })`,
1477
+ which JSON Schema writes as a `$ref`, counts as the object it names; a
1478
+ schema made from it (`.optional()`, `.refine()`, `.default()` and the
1479
+ like) is not an object input. Every call of such a tool gets the binder's
1480
+ value before its input is validated, through `tools/call` and both shapes
1481
+ of `POST /mcp/invoke` alike, and a call of any other method's tool that
1482
+ sends a bound argument is `FORBIDDEN`. Only top-level arguments are
1483
+ bound.
1328
1484
  - A call fails closed, before the method runs: `UNAUTHENTICATED` for an
1329
1485
  anonymous caller, and `FORBIDDEN` when the binder returns `undefined` or
1330
1486
  `null`, or when the agent sent the argument with another value. A binder
@@ -1332,9 +1488,10 @@ app.use(createMcpHttpRouter({ registry: projectAgents, path: "/agents" }));
1332
1488
  `QuickdrawError` it threw.
1333
1489
  - `createMcpRegistry` throws at startup when a bound argument is in no
1334
1490
  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.
1491
+ argument to the agent), and when an input may hold it anywhere it cannot
1492
+ be filled (an input that is not an object, or a branch of a union or an
1493
+ intersection, behind a `$ref` too), which it names: leave that method
1494
+ out, or make its input an object.
1338
1495
  - Custom tools are never bound, and neither are the calls their `caller`
1339
1496
  makes: a custom tool reads `principal` itself. `describeTools` and the API
1340
1497
  docs list the inputs as the contracts declare them, with nothing bound.
@@ -1347,7 +1504,9 @@ Two more needs are met by the options above, with nothing added:
1347
1504
  - **A tool set per kind of caller.** Make one registry per kind, each with its
1348
1505
  own `include`, `bind` and `principal`, and serve each at its own `path` or
1349
1506
  from its own stdio entry, as above. A registry's `principal` hook refuses
1350
- the other kinds: throwing fails their calls with `UNAUTHENTICATED`.
1507
+ the other kinds: throwing `QuickdrawError("UNAUTHENTICATED")` fails their
1508
+ calls with `UNAUTHENTICATED` and logs at debug; never put a token in the
1509
+ message.
1351
1510
  - **Frozen public tools.** `name` gives a method's tool any public name, as
1352
1511
  `create_task` above. When agents depend on tool names and argument shapes
1353
1512
  that must never change, give that surface a contract of its own: its
@@ -1551,16 +1710,25 @@ for an anonymous socket and before the server's hello, so a gate waits for
1551
1710
  `isKnown` (the hello on the current credentials arrived; false again from
1552
1711
  new credentials until theirs). `isConnected` turns true before the hello and
1553
1712
  false while the connection reconnects; `reconnecting` says the user and the
1554
- page stay meanwhile:
1713
+ page stay meanwhile. When the server ends the session (signed out on
1714
+ another device, revoked, expired) or refuses a connect for it (its
1715
+ `authenticate` failed, or the handshake was `FORBIDDEN`), the hello is
1716
+ forgotten: `isKnown` is false, `status` is `"disconnected"` or
1717
+ `"refused"`, and everything quickdraw cached is gone, so a gate that finds
1718
+ the connection stopped with nobody known shows the sign-in. Another
1719
+ protocol (`refusal.code` `"PROTOCOL_MISMATCH"`) keeps the hello; the
1720
+ provider reloads the page once for it:
1555
1721
 
1556
1722
  <!-- example: apps/web/src/components/AuthGate.tsx#gate -->
1557
1723
 
1558
1724
  ```tsx
1559
1725
  export function AuthGate({ children }: { readonly children: ReactNode }) {
1560
1726
  // isKnown: the server's hello named the user, so userId is final (null: signed out)
1561
- const { isKnown, userId, reconnecting } = useQuickdraw();
1727
+ const { status, isKnown, userId, reconnecting } = useQuickdraw();
1562
1728
  if (!isKnown) {
1563
- return <p>Connecting…</p>;
1729
+ // stopped with nobody known: the server ended or refused the session (signed out elsewhere,
1730
+ // revoked, expired), and what quickdraw cached is gone
1731
+ return status === "disconnected" || status === "refused" ? <SignIn /> : <p>Connecting…</p>;
1564
1732
  }
1565
1733
  if (userId === null) {
1566
1734
  return <SignIn />;
@@ -1710,7 +1878,8 @@ export const taskService = qd.defineService(task, {
1710
1878
  delete: { entry: "Admin" },
1711
1879
  reorder: { entry: "Moderate" },
1712
1880
  bulkUpdate: "authenticated",
1713
- bulkDelete: "authenticated",
1881
+ // all or nothing: Admin on every id, as delete needs
1882
+ bulkDelete: { entry: "Admin", id: "ids" },
1714
1883
  },
1715
1884
  // what `create` writes: columns from the principal, the next ordinal
1716
1885
  prepare: async (input, ctx, db) => ({
@@ -1720,7 +1889,7 @@ export const taskService = qd.defineService(task, {
1720
1889
  }),
1721
1890
  }),
1722
1891
  archive: {
1723
- access: { entry: "Admin" },
1892
+ access: { entry: "Moderate" },
1724
1893
  handler: ({ input, db }) =>
1725
1894
  db.task.update({ where: { id: input.id }, data: { status: "archived" } }),
1726
1895
  },
@@ -1736,11 +1905,20 @@ export const taskService = qd.defineService(task, {
1736
1905
  `get`, `update`, `delete` and `reorder` act on `input.id`, which
1737
1906
  `{ entry: L }` checks; a write on one row also needs the row level
1738
1907
  (`Moderate`, or the form's `entry`) on that row whatever its form, so
1739
- `update: "authenticated"` edits no row. `list`, `getMany`, `bulkUpdate`
1908
+ `update: "authenticated"` edits no row. `create` makes a new row and
1909
+ checks no row of its own: it needs that row level on each parent its new
1910
+ row names (each anchor column it writes, below), whatever its form, so
1911
+ `create: "authenticated"` writes into no project its caller cannot
1912
+ moderate; give it `{ scope: "Moderate", of: project, id: "projectId" }`.
1913
+ `list`, `getMany`, `bulkUpdate`
1740
1914
  and `bulkDelete` act on many rows, so they also keep only the rows the
1741
1915
  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,
1916
+ (else `Read` for reads and `Moderate` for writes; a bulk method's form
1917
+ that names none takes `update`'s or `delete`'s row level when it is
1918
+ higher): a list never shows a row `get` would refuse, and a bulk delete
1919
+ never deletes a row `delete`'s `{ entry: L }` would refuse. When the
1920
+ single-row form names no row level (`{ service: L }`, `custom`), give the
1921
+ bulk method the same form. A service-wide `Admin` grant reaches every row,
1744
1922
  and a grant a `{ service: L }` form names is a row level of the grant on
1745
1923
  every row (the read is not filtered, and its rows are stripped at the
1746
1924
  grant). A `"public"` read's rows are not filtered (a public bulk write
@@ -1754,26 +1932,59 @@ export const taskService = qd.defineService(task, {
1754
1932
  above the caller's level is `FORBIDDEN`, and a default sort on one falls
1755
1933
  back to `id`), keyset cursors that stay put when rows are inserted, 50
1756
1934
  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`
1935
+ total only when asked (a second statement). A sort value too long for a
1936
+ cursor (4,096 characters) makes the cursor name its row instead: the next
1937
+ page reads that row first, and the cursor is `VALIDATION` once the row has
1938
+ left the list (or for a caller whose list never held it). Such a cursor
1939
+ follows its row: a change to that row's sort value between pages can skip
1940
+ or repeat rows. Items are the `item`
1758
1941
  projection, stripped of fields above the level the page was read at, as
1759
1942
  collection items are.
1760
1943
  - `getMany({ ids })` (at most 200) leaves out ids the caller cannot read and
1761
1944
  ids with no row. Bulk writes skip rows the caller cannot write, run in one
1762
- transaction and return `{ count }`.
1945
+ transaction and return `{ count }`; give one `{ entry: L, id: "ids" }` to
1946
+ refuse the whole call instead of skipping rows (an empty `ids`, or an id
1947
+ with no row, is then `FORBIDDEN`).
1763
1948
  - Writes go through the tracked client: `create` sends `added`, `update` a
1764
1949
  patch and `delete` `removed`, and a bulk write past a scope's
1765
1950
  `bulkThreshold` sends it one `reset`. A missing row is `NOT_FOUND`, a
1766
1951
  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.
1952
+ - Without a service-wide `Admin` grant, `update` and `bulkUpdate` (and the
1953
+ admin kit's writes) never set a column the policy reads (an owner column,
1954
+ an access list), even one an anchored collection also scopes by. They
1955
+ never set a membership table's entry, user or role column that any policy
1956
+ reads either: change memberships with the sharing kit. They move a row
1957
+ into another parent (an `inherit` policy's `via`, an anchored
1958
+ collection's scope column) only when the caller's level on the new parent
1959
+ meets the method's row level. Each refusal is `FORBIDDEN`. An `inherit`
1960
+ `via` column stays movable, so never inherit a row's access from the
1961
+ users' own rows (`inherit({ from: user, via: "ownerId" })`): a caller
1962
+ would hand the row to themself.
1963
+ - `create` takes a column the policy reads, and a membership column, only
1964
+ from `prepare` (`ownerId: ctx.principal.userId`), never from a caller
1965
+ without a service-wide `Admin` grant. It writes a parent only where the
1966
+ caller has the row level on it, after `prepare` ran.
1967
+ - Every kit write holds column values only, in every environment and for
1968
+ every caller, a service-wide Admin included: a key that is a relation of
1969
+ the model (one a projection selects with a nested `select`) or a value
1970
+ that is a Prisma nested write (`{ connect }`, `{ update }`, a to-many
1971
+ `{ set: [...] }`) is `VALIDATION`, since nested writes are not tracked
1972
+ and would skip the checks above. A `prepare` that returns one is
1973
+ `INTERNAL`. A Json value whose own key is a relation operation holding an
1974
+ object or list, or `delete`/`disconnect` set to `true`, is refused too;
1975
+ keep such data one level deeper. Any `{ set: [...] }` is refused, a scalar
1976
+ list's included: write a scalar list as the list itself.
1773
1977
  - `reorder({ id, beforeId?, afterId? })` puts the row between its new
1774
1978
  neighbors (`beforeId` comes right before it) with one write, or renumbers
1775
1979
  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
1980
+ given 5 s plus 10 ms per row of the list. The neighbors it names need the
1981
+ method's row level, as the moved row does (a neighbor below it is
1982
+ `NOT_FOUND`, as a missing one is), and the list is the rows sharing the
1983
+ `within` values that the caller may write: a list with no `within` or a
1984
+ null one is each caller's own. The rows it names are read through that
1985
+ same filter, so a policy whose filter matches no row (a `resolver` without
1986
+ `where`) moves nothing below a service-wide grant, as its lists show
1987
+ nothing. Moves run SERIALIZABLE: two at
1777
1988
  once into one gap fail one of them with `CONFLICT` (try again).
1778
1989
  - The generated inputs carry JSON Schema, so the kit's methods are MCP tools
1779
1990
  too. For hand-written handlers, `./server` has `requireRow(row, message?)`
@@ -1895,8 +2106,8 @@ export function TaskSearch({ projectId }: { readonly projectId: string }) {
1895
2106
  - `search({ q, scope?, cursor?, limit? })` returns `{ items, nextCursor }`
1896
2107
  (and `rev`, below). `q` is trimmed, and one shorter than `minLength`
1897
2108
  (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.
2109
+ most 100, with keyset cursors as for `list`. There is no ranking: a call
2110
+ with `scope` comes in the scope collection's order, one without by id.
1900
2111
  - By default a row matches when one of the declared `fields` contains `q`,
1901
2112
  ignoring case; `%`, `_` and `\` in `q` are taken literally. `fields` is an
1902
2113
  explicit list because an unbounded "contains" over every column is slow. A
@@ -1984,7 +2195,7 @@ export const project = defineContract("projectService", {
1984
2195
  }),
1985
2196
  },
1986
2197
  collections: {
1987
- // each user's projects: an invite adds the project, a remove or a leave takes it out
2198
+ // each user's projects: an invite adds the project; a remove, a leave or a role below Read takes it out
1988
2199
  mine: {
1989
2200
  describe: "The projects a user is a member of.",
1990
2201
  scope: via({ model: "projectMember", entry: "projectId", scope: "userId" }),
@@ -2009,12 +2220,27 @@ export const projectService = qd.defineService(project, {
2009
2220
  collections: { mine: { scopeAccess: "self" } },
2010
2221
  methods: {
2011
2222
  ...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,
2223
+ // the one user a by-name call means, or null (NOT_FOUND): an email is unique
2224
+ // (and wins when both are given), a display name counts only when exactly
2225
+ // one user has it
2226
+ resolveUser: async ({ name, email }) => {
2227
+ if (email !== undefined) {
2228
+ return (await db.user.findUnique({ where: { email }, select: { id: true } }))?.id;
2229
+ }
2230
+ if (name === undefined) {
2231
+ return null;
2232
+ }
2233
+ const named = await db.user.findMany({ where: { name }, select: { id: true }, take: 2 });
2234
+ return named.length === 1 ? named[0]?.id : null;
2235
+ },
2015
2236
  // runs inside the change's transaction: its writes commit with it, a throw undoes it
2016
- onChange: (change, ctx) => {
2237
+ onChange: async (change, ctx) => {
2017
2238
  ctx.log.info("sharing changed", { kind: change.kind, id: change.id, user: change.userId });
2239
+ // the flush revokes their subscriptions, but rooms are the app's: a user whose entry or
2240
+ // membership is gone leaves the project's board on every node (removeFromBoard, rooms.ts)
2241
+ if (change.after === null) {
2242
+ await removeFromBoard(change.id, change.userId);
2243
+ }
2018
2244
  },
2019
2245
  }),
2020
2246
  },
@@ -2022,8 +2248,9 @@ export const projectService = qd.defineService(project, {
2022
2248
  ```
2023
2249
 
2024
2250
  - 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 })`
2251
+ `Moderate` or `Admin` (replacing a level they had, unless it is above the
2252
+ caller's), `setLevel` changes the level of a user the row is shared
2253
+ with, `unshare({ id, userId })`
2027
2254
  takes it away, and `listShares({ id })` lists the row's shares; each
2028
2255
  returns the list as `[{ userId, level }]`, one entry per user. Membership
2029
2256
  methods: `invite({ entryId, userId, role? })` and
@@ -2032,22 +2259,32 @@ export const projectService = qd.defineService(project, {
2032
2259
  `leave({ entryId })` return `null`, and
2033
2260
  `listMembers({ entryId, cursor?, limit? })` pages the members in user id
2034
2261
  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.
2262
+ `name` or `email` instead of `userId`, and need `resolveUser`, which
2263
+ returns exactly one user's id or `null` (`NOT_FOUND`): an email by
2264
+ `findUnique`, a name only when exactly one user has it, as above, never
2265
+ the first of several (a `findFirst` by name shares with whoever
2266
+ registered that name first); a mode adds every method but those two
2267
+ unless `methods` names its own list.
2037
2268
  - Who may call: a change needs `{ entry: "Admin" }` on the row, a list
2038
2269
  `{ entry: "Read" }`, and `leave` a signed-in member (`FORBIDDEN` for
2039
2270
  anyone else). `access: { share: { entry: "Moderate" } }` replaces one
2040
2271
  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`).
2272
+ level above their own on the row, nor changes or removes a user whose
2273
+ current level there is above their own (`FORBIDDEN`; a service-wide
2274
+ grant counts where the form names `service`; equal levels may, so one
2275
+ Admin changes another), and each change reads the caller's level again
2276
+ inside its transaction, refusing one lowered by a concurrent change
2277
+ (`FORBIDDEN`).
2045
2278
  - The kit changes the list or the table the service's policy reads, alone
2046
2279
  or inside `anyOf`, and takes their names from it: a service whose policy
2047
2280
  has none for a mode the contract uses (or two) fails when it is defined.
2048
2281
  Roles are the policy's `levels` keys, or the level names `Read`,
2049
2282
  `Moderate` and `Admin` without it; another role is `VALIDATION`, and
2050
2283
  `invite` without a role gives the lowest one that can read the row.
2284
+ Roles that map to one level are peers: with
2285
+ `{ owner: "Admin", admin: "Admin" }` an `admin` may demote or remove the
2286
+ `owner`, so protect an owner with an owner column
2287
+ (`anyOf(owner("ownerId"), members(...))`).
2051
2288
  - The owner's access never changes (`CONFLICT`). A row keeps its last
2052
2289
  Admin, counted across every policy of an `anyOf`: an owner column, an
2053
2290
  `Admin` entry of the access list, an `Admin` member (so a project's owner
@@ -2065,6 +2302,17 @@ export const projectService = qd.defineService(project, {
2065
2302
  the `via` collections over the table `added` and `removed`; the kit sends
2066
2303
  nothing itself. The by-name methods tell a row's owner whether an account
2067
2304
  exists; list them only where that is acceptable.
2305
+ - App rooms are not subscriptions: a user who loses access stays in the
2306
+ rooms a method joined them to until the app takes them out. The example's
2307
+ `onChange` does that when the change leaves the user with no entry or
2308
+ role (`change.after === null`): `removeFromBoard` takes their sockets out
2309
+ of the project's board on every node. With custom `members` `levels`, a
2310
+ role below the join's level must take the user out too. Under `anyOf` a user who still has
2311
+ the other list is taken out too, and joins again on their next connection
2312
+ (`useJoin`). `onChange` runs inside the change's transaction, before it
2313
+ commits: a join that runs before the commit (a check that still sees the
2314
+ old list) puts the user back until they disconnect, and a change whose
2315
+ commit fails has still taken them out until their next connection.
2068
2316
 
2069
2317
  ### Admin kit
2070
2318
 
@@ -2169,8 +2417,10 @@ export function AdminTasks() {
2169
2417
  service-wide `Admin` grant, nor are the columns the policy reads, and a
2170
2418
  row moves to another parent only with the row level on it (`FORBIDDEN`,
2171
2419
  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`.
2420
+ itself, and a value the database refuses is `VALIDATION`. A relation of
2421
+ the model (a key a projection selects with a nested `select`) or a nested
2422
+ write (`{ connect }`, `{ update }`) is `VALIDATION`, for every caller: the
2423
+ kit writes column values only. A missing row is `NOT_FOUND`.
2174
2424
  - `editable: ["title", "status"]` names the only fields `adminCreate` and
2175
2425
  `adminUpdate` write: `adminMeta` reports every other field
2176
2426
  `editable: false`, so a generic form leaves it read-only, and a write
@@ -2180,8 +2430,10 @@ export function AdminTasks() {
2180
2430
  list, so the writes' input checks, types and JSON Schema (and the MCP
2181
2431
  tools made from it) name only those fields; given to both halves, the
2182
2432
  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.
2433
+ what it wrote in 5.0.0: every field it shows but `id`, the timestamps and
2434
+ the model's relations, less those an override made read-only. An `editable` list, or a
2435
+ `fieldOverrides` entry with `editable: true`, that names a relation of the
2436
+ model fails when the service is defined.
2185
2437
  - `adminMeta()` returns `{ serviceName, displayName, fields }`, one
2186
2438
  `{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService?, kind?, showInForm? }`
2187
2439
  per field: `type` is
@@ -2190,11 +2442,19 @@ export function AdminTasks() {
2190
2442
  `sortable` and `filterable` are the declared fields (`filterable` is
2191
2443
  optional in the `AdminFieldConfig` type, default `false`, so field
2192
2444
  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.
2445
+ timestamps come first and are not editable; a relation of the model (a
2446
+ field a projection selects with a nested `select`) is `editable: false`;
2447
+ `acl`, `serviceAccess` and `service_access` are hidden, and so is the
2448
+ column `auth.serviceAccessSource` (a bare dispatcher's
2449
+ `serviceAccessSource`) names on this service's model, whatever its name.
2195
2450
  - 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`.
2451
+ shows and writes the entity's grants field (`serviceAccess`, or the
2452
+ column `auth.serviceAccessSource` names, whatever its name and type; the
2453
+ entity schema checks what is written), so the admin screen edits grants
2454
+ through `adminUpdate`. Options that clash with that column (hiding it
2455
+ beside `grants: true`, or naming it in `editable`, `fieldOverrides`, a
2456
+ filter or a sort without), and `grants: true` on an entity with no grants
2457
+ field, fail when the server is made.
2198
2458
  Only a caller whose own service-wide grant on that service is `Admin`
2199
2459
  reads or writes it, whatever `access` gives the method: anyone else gets
2200
2460
  rows without it and `FORBIDDEN` for a write, filter or sort naming it.
@@ -2325,9 +2585,9 @@ export const taskService = qd.defineService(task, {
2325
2585
  },
2326
2586
  },
2327
2587
  channels: {
2328
- // relay each cursor to the board's room
2588
+ // relay each cursor to the board's room, the one its requires matched (ctx.room)
2329
2589
  cursor: (payload, ctx) => {
2330
- ctx.rooms.emit(`board:${payload.projectId}`, task, "cursorMoved", payload);
2590
+ ctx.rooms.emit(ctx.room, task, "cursorMoved", payload);
2331
2591
  },
2332
2592
  },
2333
2593
  // once per socket that leaves app rooms, in every server this service runs in, in a unit of its own
@@ -2395,13 +2655,21 @@ export function TaskRoom({
2395
2655
  out as its own frame, in order; use it rather than `push` in a loop
2396
2656
  (`no-emit-in-loop`). `qd:stream:sub` is authorized with the
2397
2657
  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
2658
+ `entry` or `scope` form checks; `access: { room }` opens it to the
2659
+ sockets in an app room instead, signed in or not, and a socket that
2660
+ leaves the room, or is taken out, leaves the feed; a stream without
2661
+ `access` is closed. A global stream names the room, or `{ prefix }` for
2662
+ any room whose name starts with it; a scoped stream computes it from the
2663
+ scope, ``(worldId) => `world:${worldId}` ``, because a name or a prefix
2664
+ would let a socket in that room read every scope (`defineContract`
2665
+ refuses both on a scoped stream, by type and when it runs). The answer is the seed; `useStream` then appends, keeps the
2403
2666
  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
2667
+ the seed replaces what it held. A subscribe an access change overlapped
2668
+ is authorized again before it joins the feed, with the socket's
2669
+ principal as it is then, and answered `FORBIDDEN` when it lost access;
2670
+ nothing pushed meanwhile reaches it as a frame, and its seed is read as
2671
+ it joins. A subscribe an unsubscribe overtook answers `seed: []`. A
2672
+ socket holds at most 500 feeds. A
2405
2673
  subscriber whose access is lowered is authorized again; one refused
2406
2674
  leaves the feed and gets
2407
2675
  `qd:revoked { kind: "stream", reason: "access", s, stream, scope? }`, and
@@ -2433,20 +2701,40 @@ export function TaskRoom({
2433
2701
  sending socket's own: a room another socket of the user joined does not
2434
2702
  count, a reconnected socket must join again, and behind a cluster the
2435
2703
  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.
2704
+ A channel without `requires` takes a message from every signed-in socket,
2705
+ about any row or room its payload names: a handler that relays it to a
2706
+ room (``ctx.rooms.emit(`project:${payload.projectId}`, ...)``) lets any
2707
+ user reach that room. The server warns `channel-without-requires` for
2708
+ each such channel when it starts (a strict test app does not start). Give
2709
+ the channel a `requires` and relay to `ctx.room`, the room it matched; or,
2710
+ when any signed-in user may send it, say so on the service, `channels: {
2711
+ ping: { access: "authenticated", handler } }`, which delivers as before
2712
+ and silences the warning.
2713
+ - Presence: `isOnline` and `lastSeen` (now while online, else when the
2714
+ user's last socket on this process disconnected) come from this process's
2715
+ sockets, whatever rooms they are in; `count` and `users` (each user once,
2716
+ anonymous sockets left out; app rooms only) come from this process's
2717
+ records of the rooms `ctx.rooms.join` joined (a socket a raw
2718
+ `socket.join` put in a room is not counted). Behind a Redis adapter each
2719
+ also asks the other nodes, which answer from the same records with user
2720
+ ids alone (or whether they hold a socket of the user); concurrent reads
2721
+ of one room or user share one request.
2440
2722
  `ctx.rooms.join(room)` and `leave` put the calling socket in an app room
2441
2723
  (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.
2724
+ are refused with `VALIDATION`, and so are names with the shape of a
2725
+ Socket.IO socket id, 20 letters, digits, `_` or `-`, because every socket
2726
+ is alone in the room of its id: give such an id a prefix, `doc:${id}`; at
2727
+ most 100 per socket; a method that shares its runs, `share`, may not join
2728
+ or leave with its caller's socket: `INTERNAL`), and the room's sockets get
2729
+ `qd:presence` frames: the list on joining, then who joins and who leaves.
2730
+ `usePresence(room)` shows them. `rooms.size`, `rooms.leave` and presence
2731
+ refuse the same names.
2446
2732
  - Events: `ctx.rooms.emit(room, contract, event, payload)` and
2447
2733
  `emitToUser(userId, ...)` check the payload first (`INTERNAL`, nothing
2448
2734
  sent, when it fails), then send the validated payload as
2449
- `qd:event [service, event, payload]`; `useEvent` hears them.
2735
+ `qd:event [service, event, payload]`; `useEvent` hears them. `emit`
2736
+ refuses the framework's rooms (`qd:`, `user:`) with `VALIDATION`: a
2737
+ user's sockets are reached with `emitToUser`.
2450
2738
  - Rooms belong to a socket: a reconnect (a lost network, `qd:rotate`, new
2451
2739
  credentials) is a new socket in no room, which hears none of its events
2452
2740
  and whose channel messages requiring it are dropped. `useJoin(member,
@@ -2474,8 +2762,9 @@ export function showCursor(projectId: string, taskId: string, x: number): void {
2474
2762
  qd.rooms.emit(boardRoom(projectId), task, "cursorMoved", { projectId, taskId, x });
2475
2763
  }
2476
2764
 
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)
2765
+ // a member removed from the project (the sharing kit's onChange calls it): their sockets leave
2766
+ // its board on every node, so they hear nothing more of it and its cursor channel drops their
2767
+ // messages (also ctx.rooms.leave)
2479
2768
  export async function removeFromBoard(projectId: string, userId: string): Promise<void> {
2480
2769
  await qd.rooms.leave(boardRoom(projectId), { userId });
2481
2770
  }
@@ -2605,17 +2894,25 @@ nothing is cached:
2605
2894
  | `GET /{provider}/callback` | 302 to `{origin}{successPath}` with the session cookie, or to `{origin}{errorPath}?error=state`, `denied` or `failed` |
2606
2895
  | `POST /guest` | `createUser(body)`, then `{ userId, name? }` with the session cookie (and `token` with `guest({ token: true })`) |
2607
2896
  | `GET /providers` | `{ providers: [{ id, name, kind }] }`: the sign-ins served now (`routes.providers()` on the server) |
2608
- | `GET /me` | `{ userId }`, or 401 |
2897
+ | `GET /me` | `{ userId }`, or 401; 403 for a cookie from a page `allowedOrigins` does not list |
2609
2898
  | `POST /logout` | 204: revokes the session, clears the cookie |
2610
2899
  | `POST /logout-all` | 204: revokes every session of the user; 401 without a live session |
2611
2900
  | `/mock/provider/*` | the mock provider's own endpoints, while `isMockOAuthEnabled()` |
2612
2901
 
2613
2902
  - The OAuth state is 256 random bits, sent to the provider and kept in a
2614
2903
  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.
2904
+ secure request or whenever `publicUrl` is https, else `qd_oauth` on
2905
+ `basePath`). The `__Host-` name is what keeps another site under the
2906
+ same parent domain from planting a state of its own (a state the routes
2907
+ issued to an attacker, to sign the browser in as them): only this host,
2908
+ over HTTPS, can set it. So serve the API over HTTPS with an https
2909
+ `publicUrl`: a plain `qd_oauth` keeps that risk. The cookie is also
2910
+ signed with an HMAC under a key derived from `jwtSecret`, so its state,
2911
+ return origin, provider and age cannot be forged; the MAC does not say
2912
+ which browser a state was issued to. The callback clears it whatever
2913
+ happens, and accepts only that state, for that provider, within its 10
2914
+ minutes, once (each redeemed state is remembered by the process), so a
2915
+ sign-in cannot be finished in another browser or replayed.
2619
2916
  - `returnTo` is an origin or a URL on one; only its origin is kept, and only
2620
2917
  when `allowedOrigins` lists it (exact origins, or patterns anchored with
2621
2918
  `^` and `$`; nothing is read from the environment, and localhost or
@@ -2638,7 +2935,11 @@ nothing is cached:
2638
2935
  the same name, and the server cannot tell which is its own.
2639
2936
  - `me` answers the same 401 whether the request had no credential, a forged
2640
2937
  or expired one, or one whose session was revoked. It, `logout` and
2641
- `logout-all` read the cookie, else an `Authorization: Bearer` token.
2938
+ `logout-all` read the cookie, else an `Authorization: Bearer` token. A
2939
+ cookie sent from a page `allowedOrigins` does not list is answered 403
2940
+ (`FORBIDDEN`), as on the HTTP transport, and `guest`, which sets the
2941
+ cookie, refuses such a page whatever it sends; a bearer token is not
2942
+ ambient, so any page may send it.
2642
2943
  - `socketAuth` reads a socket's `auth.token` (a bearer token, for clients
2643
2944
  without cookies), else the session cookie from its handshake; an HTTP
2644
2945
  call's credential is the one the transport found. No credential is
@@ -2657,17 +2958,24 @@ nothing is cached:
2657
2958
  a browser sends `Origin` with every POST, so it comes from curl or a
2658
2959
  server rendering a page with the user's forwarded cookie (refused only
2659
2960
  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.
2961
+ on either transport. An app's own `authenticate` finds an HTTP call's
2962
+ session cookie in `request.sessionCookie`, never in `auth.token` (which
2963
+ holds only a bearer token for it), and `request.credential` says which
2964
+ one the transport chose (`"cookie"` or `"bearer"`, type
2965
+ `HttpCredentialSource`). One that accepts the cookie applies the same
2966
+ rule with `cookieOriginAllowed(request, allowedOrigins)` and checks the
2967
+ session in the store on every call (`liveSession`); a
2968
+ `QuickdrawError("FORBIDDEN")` it throws on HTTP is answered as it is. A
2969
+ hook that takes bearer tokens only (the MCP bridge's `principal`, an
2970
+ `authenticate` reading `auth.token`) uses `bearerAuth`.
2665
2971
  - `socketAuth({ devCredentials })` signs a socket in by the user id its
2666
2972
  handshake names (`auth: { userId }`, no token), as the function answers
2667
2973
  (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.
2974
+ in development. It runs only when `NODE_ENV` is `development` or `test`:
2975
+ `socketAuth` throws when it is given otherwise (an unset `NODE_ENV`
2976
+ included: set `NODE_ENV=development`), refuses such a handshake once
2977
+ `NODE_ENV` says otherwise, and `createServer` warns at startup while it
2978
+ is on; pass it only behind the app's own flag.
2671
2979
  - `google.optional(...)` and `discord.optional(...)` build nothing when
2672
2980
  neither credential is set (only one is a misconfiguration, refused), and
2673
2981
  `providers` skips `undefined`, `null` and `false` entries, so an
@@ -2706,8 +3014,10 @@ nothing is cached:
2706
3014
  many users calls from one address: give it its own `keyGenerator` or a
2707
3015
  higher `max`.
2708
3016
  - 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
3017
+ (`ENABLE_MOCK_OAUTH=true` and `NODE_ENV` `development` or `test`; an
3018
+ unset `NODE_ENV` mounts nothing and logs an error saying to set
3019
+ `NODE_ENV=development`), mounting it logs a warning, and every request
3020
+ checks again. Set `mock({ internalUrl })` where the API
2711
3021
  cannot reach itself at `publicUrl`. Routes made with nothing that can
2712
3022
  sign anyone in (only a mock that is off) warn when they are made.
2713
3023
  - `publicUrl` is where providers send the browser back, so a deployment
@@ -2718,33 +3028,57 @@ nothing is cached:
2718
3028
  (`X-Forwarded-Host`, else `Host`) logs an error naming it, once.
2719
3029
  - One rule names the session cookie, written and read: the routes,
2720
3030
  `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
3031
+ the same name, and read `__Host-session` first wherever it is present:
3032
+ only this host, over a secure context, can have set it. Without a
2722
3033
  domain, a request over HTTPS reads only `__Host-session`, never
2723
3034
  `session`: another site under the same parent domain can set that cookie
2724
3035
  for the whole domain, and while the user holds no `__Host-session` it
2725
3036
  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
3037
+ `__Host-session`, then `session` (the name the routes set there); when
3038
+ the routes or `setSessionCookie` and `clearSessionCookie` set or clear
3039
+ `session`, they also clear a stale `__Host-session` the request carries,
3040
+ which would otherwise shadow it, or outlive a sign-out. A configured name is read as it is, on
2727
3041
  any request, so a changed name goes in all three places:
2728
3042
  `createAuthRoutes({ cookie: { name } })`, `socketAuth({ cookieName })`
2729
3043
  and `createServer({ http: { cookieName } })`. The transports see the
2730
3044
  cookie's domain through `COOKIE_DOMAIN` only: share the cookie with
2731
3045
  subdomains by setting `COOKIE_DOMAIN`; a `cookie.domain` given only to
2732
3046
  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,
3047
+ warn at startup until the cookie is named. They also warn for
3048
+ `cookie.domain: ""` while `COOKIE_DOMAIN` is set: the transports then
3049
+ take a `session` cookie over HTTPS too, which the routes never set there
3050
+ and any site under that domain can. Behind a proxy that ends TLS,
2734
3051
  have it send `X-Forwarded-Proto`, which every request carries, so the
2735
3052
  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
3053
+ - A socket whose session expires is disconnected then (`io server
3054
+ disconnect`; `socketAuth` records the expiry, and an app's own
3055
+ `authenticate` may, through `recordSocketSession(socket, sessionId,
3056
+ expiresAt)`, with `expiresAt` a `Date` or epoch milliseconds: a JWT's
3057
+ `exp` is in seconds). A handshake whose recorded session expired before
3058
+ it ended (a slow `loadPrincipal`) is refused, `UNAUTHENTICATED`. Otherwise a socket keeps the principal it authenticated
3059
+ with until it reconnects, so a revoked session's open sockets are ended with
2738
3060
  `server.access.disconnectUser(userId, { sessionId?, reason? })`: every
2739
3061
  socket of the user, or those `socketAuth` recorded for one session
2740
3062
  (`recordSocketSession` on `./server` for an app's own `authenticate`), on
2741
3063
  every node behind a cluster adapter. `onRevoke(userId, sessionId | null)`
2742
3064
  tells the app when `logout` (the session's id) or `logout-all` (`null`)
2743
3065
  revoked; wire it as above. A disconnected client does not reconnect on
2744
- its own; its next connect is authenticated afresh.
3066
+ its own; its next connect is authenticated afresh. It forgets the user
3067
+ and everything quickdraw cached (`isKnown` false, `status`
3068
+ `"disconnected"`), and calls waiting to be sent when a reconnect is
3069
+ refused fail with `CANCELLED`, so a shared machine shows nothing of
3070
+ theirs.
2745
3071
  - `issueSession({ sessions, jwtSecret }, userId, { provider })` starts a
2746
3072
  session for an app's own sign-in flow (login codes, an embedded activity),
2747
3073
  and `liveSession` reads a token back; both work with `socketAuth`.
3074
+ - `bearerAuth({ sessions, jwtSecret, loadPrincipal? })` is `socketAuth`'s
3075
+ counterpart for a hook handed a bearer token alone (the MCP bridge's
3076
+ `principal`, `authenticate: ({ auth }) => verify(auth.token)`): no token
3077
+ is anonymous, a token whose session is not live is refused with
3078
+ `UNAUTHENTICATED`, on every call, and it never reads cookies. It records
3079
+ no socket session: use `socketAuth` for `createServer`. `verifyJWT`
3080
+ checks a token's signature and expiry only, never the store, and warns
3081
+ once in development when it verifies a session token.
2748
3082
 
2749
3083
  From the browser, `./client` speaks to these routes: `authProviders({
2750
3084
  apiUrl })` lists the sign-ins the API serves (`GET /providers`: a provider
@@ -2759,7 +3093,8 @@ and the token `setAuthToken` stored, if any, as a bearer token; the stored
2759
3093
  token is forgotten either way, and a refusal or an unreachable server
2760
3094
  rejects with a `QuickdrawError`. A socket keeps the user it signed in as
2761
3095
  until it connects again, so reconnect the provider's connection after
2762
- signing out (with a token in `auth`, clearing it does that):
3096
+ signing out: `connection.close()`, then `open()`, as below, or a reload
3097
+ (with a token in `auth`, clearing it does that):
2763
3098
 
2764
3099
  <!-- example: apps/web/src/components/SignIn.tsx#browser -->
2765
3100
 
@@ -3106,29 +3441,36 @@ What it does with them:
3106
3441
 
3107
3442
  While `NODE_ENV` is not `"production"`, a running app warns about the slow
3108
3443
  and untracked patterns lint cannot see, as they happen. Every warning has
3109
- one format and names the method call it happened in:
3444
+ one format and names the method call it happened in (one exception:
3445
+ `untracked-access-model` is checked as the server starts and logged as an
3446
+ error under `category: "quickdraw.access"`, in every environment, at each
3447
+ start):
3110
3448
 
3111
3449
  ```text
3112
3450
  [quickdraw:n-plus-one] taskService.board: task.findUnique by id ran 10 times in one call, once per item (N+1); ...
3113
3451
  ```
3114
3452
 
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 |
3453
+ | Kind | Raised when |
3454
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3455
+ | `n-plus-one` | a call ran 10 statements of one shape (model, operation, `where` keys), outside a `$transaction([])` |
3456
+ | `unbounded-read` | a call ran `findMany` with neither `take` nor ids to read (`id`, `{ in }` or `{ equals }`) |
3457
+ | `oversized-response` | a reply was larger than `maxResponseBytes` (default 1 MiB) |
3458
+ | `nested-write` | a write's `data` wrote a related row, which is not tracked |
3459
+ | `ambient-write` | a tracked write ran outside any unit of work |
3460
+ | `batch-read` | a write in an array-form `$transaction` read its rows outside the batch |
3461
+ | `batch-create-many` | a `createMany` in an array-form `$transaction` could not report its rows |
3462
+ | `repeated-call` | one connection repeated a call or was refused `RATE_LIMITED` again and again (below) |
3463
+ | `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) |
3464
+ | `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 |
3465
+ | `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 |
3466
+ | `shared-principal` | the handler of a `share: "all"` run (an app's, not a kit's) read `ctx.principal`, `ctx.services` or `ctx.mcp`: the run has the first caller's, and every caller gets its result |
3467
+ | `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
3468
 
3128
3469
  Updates and deletes by id inside an interactive transaction are not counted
3129
3470
  toward `n-plus-one`: that is how per-row writes are written (see tracked
3130
3471
  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"`.
3472
+ model, the field of a nested write, or the member a shared run read), under
3473
+ `category: "quickdraw.dev"`.
3132
3474
  Only an app's own statements are checked: the framework's reads and the
3133
3475
  kits' own reads are not, while the app's callbacks a kit calls (`prepare`,
3134
3476
  `onChange`, `resolveUser`, a search strategy) are. A statement filtered by
@@ -3142,9 +3484,10 @@ once the reply was recorded (over a socket or HTTP the reply was already
3142
3484
  sent, so that error is logged, not thrown). Strictness belongs to the app:
3143
3485
  warnings outside its calls (an ambient write while seeding, another app's
3144
3486
  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.
3487
+ the app is made, `tiered-field-in-output`, `resolver-without-reads`,
3488
+ `untracked-access-model` and `channel-without-requires`, are thrown from
3489
+ `createTestApp` itself, so a strict app over such a method, policy, table
3490
+ or channel does not start.
3148
3491
 
3149
3492
  A `repeated-call` warning names a client caught in a loop (a mutation fired
3150
3493
  from an effect that its own result runs again, a refetch that triggers
@@ -3280,6 +3623,16 @@ to the test client exactly as in production.
3280
3623
 
3281
3624
  ## Observability
3282
3625
 
3626
+ Logs go to `createServer({ logger })`, any logger with Winston's methods
3627
+ (default `consoleLogger`). The console default drops `debug` when
3628
+ `NODE_ENV` is `"production"`, since a call's record logs at debug with its
3629
+ caller's user id: `createConsoleLogger({ level: "debug" })` keeps it, and
3630
+ `level: "warn"` drops `info` too. An error is logged with its message and
3631
+ stack, but a Prisma client error only with its class, `code`, the schema
3632
+ names and codes of its `meta` (never a value, nor the database's own text,
3633
+ which quotes the row), the reason a validation error gives, and its stack
3634
+ frames: its message renders the call, values included.
3635
+
3283
3636
  `createServer({ stallWatchdog: true })` watches the event loop. It samples
3284
3637
  the loop's delay every 20 ms (`perf_hooks.monitorEventLoopDelay`), reads it
3285
3638
  every 10 s, and logs a warning (`category: "quickdraw.stall"`) when the 99th
@@ -3406,8 +3759,8 @@ so an app can adopt it before fixing old code.
3406
3759
 
3407
3760
  The guards follow one order of preference, so an agent moving fast meets a
3408
3761
  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
3762
+ service is defined (an access form that asks the policy nothing about the
3763
+ row a method's `id` names, unless it says `rowless: true`); then lint; then a
3411
3764
  development warning as it happens (`repeated-call`, `repeated-mutation` and
3412
3765
  `repeated-invalidation` name a client loop before the rate limit does).
3413
3766
 
@@ -3444,9 +3797,9 @@ bunx @fitzzero/quickdraw-codemod v5 .
3444
3797
 
3445
3798
  | Export | Holds |
3446
3799
  | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3447
- | `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names |
3800
+ | `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names, `consoleLogger` and `createConsoleLogger` |
3448
3801
  | `./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 |
3802
+ | `./server/auth` | `createAuthRoutes`, `socketAuth`, `bearerAuth`, `requireSession` and `sessionOf`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers |
3450
3803
  | `./server/express` | Express rate limits: `createAuthLimiter`, `createAuthStatusLimiter`, `createCallLimiter`, `createPublicApiLimiter`, `createWebhookLimiter` |
3451
3804
  | `./server/mcp` | `createMcpRegistry`, `describeTools`, `createMcpStdioServer`, `createMcpHttpRouter`, `bootstrapMcpServer` |
3452
3805
  | `./server/otel` | `otelOnCall` |