@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.
- package/README.md +533 -180
- package/dist/{admin-DKwkIh5i.d.ts → admin-DWWrT1h6.d.ts} +2 -2
- package/dist/{adminSchemas-2DUA_hZ8.d.ts → adminSchemas-D4bFKsrb.d.ts} +6 -4
- package/dist/{auth-BhWqdyb-.d.ts → auth-DXiSJZaf.d.ts} +42 -16
- package/dist/{caller-CXTFswBO.d.ts → caller-BT-Mv4bD.d.ts} +117 -22
- package/dist/{chunk-RT6OILOI.js → chunk-4D2DOGAW.js} +2 -14
- package/dist/chunk-4D2DOGAW.js.map +1 -0
- package/dist/chunk-5CGO426C.js +15 -0
- package/dist/chunk-5CGO426C.js.map +1 -0
- package/dist/{chunk-A6DJBGOG.js → chunk-6K5BY47X.js} +4594 -3481
- package/dist/chunk-6K5BY47X.js.map +1 -0
- package/dist/{chunk-VJTFXHOK.js → chunk-753VJIUP.js} +13 -15
- package/dist/chunk-753VJIUP.js.map +1 -0
- package/dist/{chunk-JZBCLNDU.js → chunk-7QCUX4TK.js} +4 -4
- package/dist/chunk-7QCUX4TK.js.map +1 -0
- package/dist/chunk-AK23XSWD.js +16 -0
- package/dist/chunk-AK23XSWD.js.map +1 -0
- package/dist/{chunk-3674EIRT.js → chunk-AONAFOYU.js} +14 -5
- package/dist/{chunk-3674EIRT.js.map → chunk-AONAFOYU.js.map} +1 -1
- package/dist/chunk-DKE6QO26.js +136 -0
- package/dist/chunk-DKE6QO26.js.map +1 -0
- package/dist/{chunk-H23KEFBA.js → chunk-DUBU3BB3.js} +30 -1
- package/dist/chunk-DUBU3BB3.js.map +1 -0
- package/dist/{chunk-CDKG4TFA.js → chunk-GKH3LJ7N.js} +1 -1
- package/dist/chunk-GKH3LJ7N.js.map +1 -0
- package/dist/{chunk-KNDDCVQJ.js → chunk-GSIPUNJL.js} +49 -9
- package/dist/chunk-GSIPUNJL.js.map +1 -0
- package/dist/{chunk-DD4SKCNO.js → chunk-LCC7HNFP.js} +12 -1
- package/dist/chunk-LCC7HNFP.js.map +1 -0
- package/dist/{chunk-2PWMUVC7.js → chunk-MBH6IDSM.js} +480 -12
- package/dist/chunk-MBH6IDSM.js.map +1 -0
- package/dist/{chunk-2UELNHDD.js → chunk-NMXYENH4.js} +10 -10
- package/dist/{chunk-2UELNHDD.js.map → chunk-NMXYENH4.js.map} +1 -1
- package/dist/chunk-NO4M6PO5.js +101 -0
- package/dist/chunk-NO4M6PO5.js.map +1 -0
- package/dist/{chunk-TBMKJ26R.js → chunk-NW6U45HX.js} +3 -1
- package/dist/chunk-NW6U45HX.js.map +1 -0
- package/dist/{chunk-REX5YPHX.js → chunk-RJ5I2VO3.js} +74 -19
- package/dist/chunk-RJ5I2VO3.js.map +1 -0
- package/dist/{chunk-QEKVYG5F.js → chunk-RL4VKLBX.js} +1 -1
- package/dist/{chunk-QEKVYG5F.js.map → chunk-RL4VKLBX.js.map} +1 -1
- package/dist/chunk-XH2IZVLT.js +62 -0
- package/dist/chunk-XH2IZVLT.js.map +1 -0
- package/dist/{chunk-4DIQHASK.js → chunk-Z5ILULF2.js} +26 -7
- package/dist/chunk-Z5ILULF2.js.map +1 -0
- package/dist/cli/quickdraw-docs.js +5 -3
- package/dist/cli/quickdraw-docs.js.map +1 -1
- package/dist/client/index.d.ts +30 -18
- package/dist/client/index.js +8 -6
- package/dist/client/index.js.map +1 -1
- package/dist/{clientTypes-Bc-G8n-U.d.ts → clientTypes-hWcPUVS9.d.ts} +3 -3
- package/dist/{connection-Cr1BU07W.d.ts → connection-CgijdGpT.d.ts} +8 -4
- package/dist/{createServer-CP9eDHZZ.d.ts → createServer-Cp-Wk500.d.ts} +36 -14
- package/dist/{createTestApp-UKVGEmOf.d.ts → createTestApp-BB_PkqQ7.d.ts} +16 -10
- package/dist/{envelope-Cp3YcVxC.d.ts → envelope-DzWyIXjj.d.ts} +18 -4
- package/dist/{http-Djj2Inuu.d.ts → http-DotQ5bj8.d.ts} +68 -17
- package/dist/index.d.ts +7 -7
- package/dist/index.js +37 -13
- package/dist/index.js.map +1 -1
- package/dist/{infer-Teo1xpsd.d.ts → infer-BmoZsL9a.d.ts} +43 -10
- package/dist/logger-Yz93KzdV.d.ts +30 -0
- package/dist/{metrics-BdBu5o8G.d.ts → metrics-DW80Exve.d.ts} +2 -2
- package/dist/origins-kvpxnVcW.d.ts +8 -0
- package/dist/prisma/index.d.ts +5 -5
- package/dist/prisma/index.js +130 -67
- package/dist/prisma/index.js.map +1 -1
- package/dist/{rateLimit-ZMIZJUZF.js → rateLimit-5RKBFLUR.js} +2 -2
- package/dist/server/auth/index.d.ts +82 -33
- package/dist/server/auth/index.js +273 -268
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/express/index.d.ts +9 -2
- package/dist/server/express/index.js +1 -1
- package/dist/server/index.d.ts +147 -67
- package/dist/server/index.js +520 -381
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/index.d.ts +63 -17
- package/dist/server/mcp/index.js +256 -54
- package/dist/server/mcp/index.js.map +1 -1
- package/dist/server/otel.d.ts +3 -3
- package/dist/{sharing-DVa8W-yR.d.ts → sharing-UDLn1049.d.ts} +3 -3
- package/dist/testing/client.d.ts +14 -14
- package/dist/testing/client.js +9 -7
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/index.d.ts +12 -12
- package/dist/testing/index.js +21 -12
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock.d.ts +5 -5
- package/dist/testing/mock.js +8 -6
- package/dist/{types-Cu0vmx7c.d.ts → types-CYliTP65.d.ts} +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/package.json +1 -1
- package/dist/chunk-2PWMUVC7.js.map +0 -1
- package/dist/chunk-4DIQHASK.js.map +0 -1
- package/dist/chunk-A6DJBGOG.js.map +0 -1
- package/dist/chunk-CDKG4TFA.js.map +0 -1
- package/dist/chunk-DD4SKCNO.js.map +0 -1
- package/dist/chunk-H23KEFBA.js.map +0 -1
- package/dist/chunk-JZBCLNDU.js.map +0 -1
- package/dist/chunk-KNDDCVQJ.js.map +0 -1
- package/dist/chunk-REX5YPHX.js.map +0 -1
- package/dist/chunk-RT6OILOI.js.map +0 -1
- package/dist/chunk-TBMKJ26R.js.map +0 -1
- package/dist/chunk-VJTFXHOK.js.map +0 -1
- package/dist/chunk-YSWH776Y.js +0 -13
- package/dist/chunk-YSWH776Y.js.map +0 -1
- package/dist/logger-BY28acW-.d.ts +0 -16
- /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 {
|
|
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
|
-
/**
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
615
|
-
|
|
616
|
-
|
|
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,
|
|
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
|
-
//
|
|
732
|
-
access:
|
|
733
|
-
|
|
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.
|
|
762
|
-
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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)
|
|
777
|
-
nested `where.id`)
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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,
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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:
|
|
886
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
|
1161
|
-
`socketAuth` answers a cookie sent from a page
|
|
1162
|
-
with `FORBIDDEN`, as it refuses such a socket
|
|
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
|
-
|
|
1181
|
-
anonymous caller; throwing
|
|
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
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
//
|
|
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.
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
|
1336
|
-
|
|
1337
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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. `
|
|
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
|
|
1743
|
-
|
|
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).
|
|
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
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
`
|
|
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.
|
|
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:
|
|
1899
|
-
|
|
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
|
|
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
|
-
//
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
|
2026
|
-
the level of a user the row is shared
|
|
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
|
|
2036
|
-
|
|
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
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
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
|
|
2173
|
-
|
|
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
|
|
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;
|
|
2194
|
-
`
|
|
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
|
|
2197
|
-
|
|
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(
|
|
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 }`
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
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
|
|
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
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
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
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
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
|
|
2478
|
-
// nothing more of it and its cursor channel drops their
|
|
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
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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`
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
`
|
|
2664
|
-
`
|
|
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.
|
|
2669
|
-
`
|
|
2670
|
-
|
|
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`
|
|
2710
|
-
|
|
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
|
|
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 `
|
|
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.
|
|
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
|
|
2737
|
-
|
|
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 (
|
|
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
|
|
3116
|
-
|
|
|
3117
|
-
| `n-plus-one`
|
|
3118
|
-
| `unbounded-read`
|
|
3119
|
-
| `oversized-response`
|
|
3120
|
-
| `nested-write`
|
|
3121
|
-
| `ambient-write`
|
|
3122
|
-
| `batch-read`
|
|
3123
|
-
| `batch-create-many`
|
|
3124
|
-
| `repeated-call`
|
|
3125
|
-
| `tiered-field-in-output`
|
|
3126
|
-
| `resolver-without-reads`
|
|
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,
|
|
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
|
|
3146
|
-
|
|
3147
|
-
|
|
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
|
|
3410
|
-
|
|
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` |
|