@starci/hfs 4.7.29 → 4.7.31
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/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -6
- package/runtime/knowledge/hfs/fe-stack.yaml +1 -1
- package/runtime/knowledge/hfs/rules.yaml +55 -7
- package/runtime/knowledge/hfs/slots.yaml +38 -18
- package/runtime/knowledge/patterns/be/api.yaml +15 -4
- package/runtime/knowledge/patterns/be/realtime.yaml +128 -29
- package/runtime/modules/kernel/failure-codes.yaml +25 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +7 -4
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +17 -3
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
- package/runtime/scripts/lib/json-formats.mjs +1 -0
- package/scaffold/app.mjs +6 -5
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +4 -1
- package/templates/be/patterns/realtime/channel.controller.spec.ts.tpl +103 -0
- package/templates/be/patterns/realtime/channel.controller.ts.tpl +23 -0
- package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/realtime/channel.sse.module.ts.tpl +7 -0
- package/templates/be/patterns/realtime/channel.sse.options.ts.tpl +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.31 - 2026-10-24
|
|
4
|
+
- Fixed: `HFS_UNUSED_FILE` treats an api app `operations.ts` as a root (the contract emit reads it by path) and the emitted `openapi.json` is a declared JSON format.
|
|
5
|
+
- Fixed: the doc check `HFS_DOC_ENV_UNKNOWN` (R270) no longer reads a documented error code as an environment key: codes the app declares in `errors/`, `*.error.ts` and `*.messages.ts` (`errors.<CODE>` keys) and names in a sentence about error codes pass, while an undeclared environment key still fails.
|
|
6
|
+
- Added: the SSE door templates of the realtime pattern (`channel.sse` module, options and definition); lite may serve SSE beside its socket.io gateway (amended R212).
|
|
7
|
+
- Changed: a new app's starter dependencies (full and lite) carry `zod` at its canon pin, which is `side: both` (the boundary-schema pattern, R304 and R305).
|
|
8
|
+
|
|
9
|
+
## 4.7.30 - 2026-10-24
|
|
10
|
+
- Changed: a new app root overrides multer to its canon pin 2.5.0: @nestjs/platform-express depends on exactly multer 2.4.0, which carries GHSA-q7qp-rjg7-q26m (high); the bundled canon pins add `multer` and select @starci/cli 1.7.30, @starci/eslint-canon-be 3.4.38 and @starci/eslint-canon-fe 8.4.37.
|
|
11
|
+
|
|
3
12
|
## 4.7.29 - 2026-10-24
|
|
4
13
|
- Added: a new full app ships its test world (`scaffold/test-world.mjs`, `templates/be/test-world/`): the `@starci/test-world` declaration under `be/src/tests/world/`, an integration spec of readiness over the real database and one e2e spec per api app, so the integration, e2e and contract rows of the release gate run real specs.
|
|
5
14
|
- Added: `starci app upgrade [--plan]` on a full app without `be/src/tests/world/global-setup.ts` adds that world and the test devDependencies it lacks at canon pins, never overwriting a file (`HFS_UPGRADE_FILE_CONFLICT`); proven by `src/main.spec.mjs`.
|
package/package.json
CHANGED
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.39
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.38
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.7.
|
|
79
|
+
version: 1.7.31
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.7.
|
|
86
|
+
version: 4.7.31
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -338,8 +338,8 @@ pins:
|
|
|
338
338
|
'zod':
|
|
339
339
|
version: 4.6.7
|
|
340
340
|
group: framework
|
|
341
|
-
side:
|
|
342
|
-
why: 'the schema library of front-end forms and boundary
|
|
341
|
+
side: both
|
|
342
|
+
why: 'the schema library of front-end forms (fe-stack forms) and, on the back end, of the boundary parsers (R304 and R305: provider responses, model output and queue payloads, never a transport DTO), also an npm override of every app root; 4.6.7 (the registry latest on 2026-10-24) closes GHSA-mrhx-c7xc-m3w5 (high, fixed in 4.6.6), which npm audit --audit-level=high reported against 4.4.3.'
|
|
343
343
|
'@reduxjs/toolkit':
|
|
344
344
|
version: 2.11.2
|
|
345
345
|
group: framework
|
|
@@ -471,6 +471,11 @@ pins:
|
|
|
471
471
|
group: framework
|
|
472
472
|
side: be
|
|
473
473
|
why: 'an npm override of every app root (and of the examples that serve GraphQL), never a declared dependency: @nestjs/graphql 13.x pulls @graphql-tools/utils <=12.0.0, which npm audit --audit-level=high reports (GHSA-7mx3-vvmw-hjmv, prototype pollution in mergeDeep). 12.1.0 is a fixed release on the same major and the lowest one every declared range accepts (@graphql-tools/schema and /merge, used at runtime, require ^12.1.0; NIVO v0.2.1 6a1d8e80 overrides 12.0.4, below that range), so the advisory closes without the breaking @nestjs/graphql 14. DROP this pin and the override when @nestjs/graphql 14 is adopted (owner ruling graphql-tools-utils-override, 2026-10-18).'
|
|
474
|
+
'multer':
|
|
475
|
+
version: 2.5.0
|
|
476
|
+
group: framework
|
|
477
|
+
side: be
|
|
478
|
+
why: 'an npm override of every app root and of the examples, never a declared dependency: @nestjs/platform-express 11.2.x (and 12.x) depends on exactly multer 2.4.0, which npm audit --audit-level=high reports (GHSA-q7qp-rjg7-q26m, a denial of service through a pending asynchronous fileFilter after request teardown, published 2026-10-24). 2.5.0 is the fixed release on the same major. DROP this pin and the override when a @nestjs/platform-express release depends on multer 2.5.0 or later.'
|
|
474
479
|
'@nestjs/apollo':
|
|
475
480
|
version: 13.4.5
|
|
476
481
|
group: framework
|
|
@@ -75,7 +75,7 @@ concerns:
|
|
|
75
75
|
home: [fe.hooks.socketio]
|
|
76
76
|
pattern: FE-REALTIME
|
|
77
77
|
forbids: [graphql-ws, subscriptions-transport-ws, '@supabase/realtime-js', pusher-js, ably]
|
|
78
|
-
why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; lite runs the gateway in the be api process.
|
|
78
|
+
why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; lite runs the gateway in the be api process. A one-way channel served as server-sent events needs no package, only a fetch reader in modules/api/sse and a use<Channel>Stream hook (FE-REALTIME).
|
|
79
79
|
- id: auth
|
|
80
80
|
tier: core
|
|
81
81
|
packages: []
|
|
@@ -1918,7 +1918,7 @@ rules:
|
|
|
1918
1918
|
- {path: packages/hfs/src/r94-r96-r97-coverage.spec.mjs, editions: [lite, full], codes: [FE_SLOT_FILE_ROLE]}
|
|
1919
1919
|
- id: R95
|
|
1920
1920
|
code: "BE_OPERATION_CONTRACT"
|
|
1921
|
-
law: "A versioned operation is declared once in the app's typed operation table (`OperationContract<Input, Output, RefusalCode>` registered by `defineOperations`): its input and output are closed types (never `any`, `unknown` or `Record<string, unknown>`), its refusal codes a closed union of string literals, and the route that serves the table takes `OperationRequest<Table>` and answers `Promise<OperationReply<Table>>` of that one table."
|
|
1921
|
+
law: "A versioned operation is declared once in the app's typed operation table (`OperationContract<Input, Output, RefusalCode>` registered by `defineOperations`): its input and output are closed types (never `any`, `unknown` or `Record<string, unknown>`), its refusal codes a closed union of string literals, and the route that serves the table takes `OperationRequest<Table>` and answers `Promise<OperationReply<Table>>` of that one table, binding its body to the `OperationValidationService` of `platform/http-security` because the alias leaves no class for the global pipe to validate."
|
|
1922
1922
|
kinds: [lint]
|
|
1923
1923
|
failureCodes: ["BE_OPERATION_CONTRACT"]
|
|
1924
1924
|
enforcers:
|
|
@@ -3193,11 +3193,12 @@ rules:
|
|
|
3193
3193
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_SHAPE]}
|
|
3194
3194
|
exemplifiedBy:
|
|
3195
3195
|
- {path: packages/hfs/templates/be/patterns/realtime/channel.gateway.ts.tpl, editions: [lite, full]}
|
|
3196
|
+
- {path: packages/hfs/templates/be/patterns/realtime/channel.controller.ts.tpl, editions: [lite, full]}
|
|
3196
3197
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_SHAPE]}
|
|
3197
3198
|
witness:
|
|
3198
3199
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: lite, codes: [BE_REALTIME_SHAPE]}
|
|
3199
3200
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: full, codes: [BE_REALTIME_SHAPE]}
|
|
3200
|
-
law: "A realtime file is the one door its role names: `<channel>.gateway.ts` holds one `@WebSocketGateway` class with `@SubscribeMessage` handlers
|
|
3201
|
+
law: "A realtime file is the one door its role names: `<channel>.gateway.ts` holds one `@WebSocketGateway` class with `@SubscribeMessage` handlers, `<x>.subscription.ts` holds one `@Resolver` class with `@Subscription` operations and `<channel>.controller.ts` of the sse slot holds one `@Controller` class whose one route is `@Sse`, and none carries a query, a mutation, a field resolver, a plain REST route or another protocol. A lite app serves SSE (the default door for one-way push) or the socket.io gateway from its api process and no graphql-ws subscription (R212 case-2 (owner:lite-production-swarm), with the Work boundary amended by ruling:R212/case-4 and the realtime adapter amended by ruling:R212/case-5)."
|
|
3201
3202
|
kinds: [lint]
|
|
3202
3203
|
gates: [pre-commit, settle, land, ci]
|
|
3203
3204
|
failureCodes: ["BE_REALTIME_SHAPE"]
|
|
@@ -3211,11 +3212,12 @@ rules:
|
|
|
3211
3212
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_WRITES]}
|
|
3212
3213
|
exemplifiedBy:
|
|
3213
3214
|
- {path: packages/hfs/templates/be/patterns/realtime/channel.gateway.ts.tpl, editions: [lite, full]}
|
|
3215
|
+
- {path: packages/hfs/templates/be/patterns/realtime/channel.controller.ts.tpl, editions: [lite, full]}
|
|
3214
3216
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_WRITES]}
|
|
3215
3217
|
witness:
|
|
3216
3218
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: lite, codes: [BE_REALTIME_WRITES]}
|
|
3217
3219
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: full, codes: [BE_REALTIME_WRITES]}
|
|
3218
|
-
law: "A realtime door reads and pushes only: the one value injected into it is the `RealtimeHub` of `platform/realtime`; no `EntityManager`, bus, queue, event bus or domain service reaches it."
|
|
3220
|
+
law: "A realtime door (gateway, subscription or SSE controller) reads and pushes only: the one value injected into it is the `RealtimeHub` of `platform/realtime`; no `EntityManager`, bus, queue, event bus or domain service reaches it."
|
|
3219
3221
|
kinds: [lint]
|
|
3220
3222
|
gates: [pre-commit, settle, land, ci]
|
|
3221
3223
|
failureCodes: ["BE_REALTIME_WRITES"]
|
|
@@ -3229,11 +3231,12 @@ rules:
|
|
|
3229
3231
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_TOPIC_SCOPE]}
|
|
3230
3232
|
exemplifiedBy:
|
|
3231
3233
|
- {path: packages/hfs/templates/be/patterns/realtime/channel.gateway.ts.tpl, editions: [lite, full]}
|
|
3234
|
+
- {path: packages/hfs/templates/be/patterns/realtime/channel.controller.ts.tpl, editions: [lite, full]}
|
|
3232
3235
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_TOPIC_SCOPE]}
|
|
3233
3236
|
witness:
|
|
3234
3237
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: lite, codes: [BE_REALTIME_TOPIC_SCOPE]}
|
|
3235
3238
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: full, codes: [BE_REALTIME_TOPIC_SCOPE]}
|
|
3236
|
-
law: "The topic a realtime handler passes to `RealtimeHub.subscribe` is built from the handler's `@CurrentPrincipal()` parameter of `domain/identity`, so a client can only listen to channels its principal owns."
|
|
3239
|
+
law: "The topic a realtime handler (including the `@Sse` method of an SSE controller) passes to `RealtimeHub.subscribe` is built from the handler's `@CurrentPrincipal()` parameter of `domain/identity`, so a client can only listen to channels its principal owns; a `Last-Event-ID` header only chooses where the replay inside that topic resumes."
|
|
3237
3240
|
kinds: [lint]
|
|
3238
3241
|
gates: [pre-commit, settle, land, ci]
|
|
3239
3242
|
failureCodes: ["BE_REALTIME_TOPIC_SCOPE"]
|
|
@@ -3568,7 +3571,7 @@ rules:
|
|
|
3568
3571
|
docs: [docs/lite-edition.md#edition-validation]
|
|
3569
3572
|
templateNa: "The hfs.json edition declaration is generated programmatically by packages/hfs/scaffold/app.mjs; no managed declaration template exists. Forbidden paths are rejected rather than scaffolded."
|
|
3570
3573
|
code: "HFS_EDITION_INVALID"
|
|
3571
|
-
law: "hfs.json names an edition the manifest knows (full, or lite; absent means full). Lite follows the 2026-10-13 ruling: the same monorepo, code structure, slots, patterns and back-end unit specs, with only backing infrastructure swapped for managed Supabase (Postgres, Auth, Storage, Supabase Queues/pgmq for queue and event bus, Postgres locks, pg_cron); the api and sidecars run on docker-swarm on a VPS. The realtime pattern
|
|
3574
|
+
law: "hfs.json names an edition the manifest knows (full, or lite; absent means full). Lite follows the 2026-10-13 ruling: the same monorepo, code structure, slots, patterns and back-end unit specs, with only backing infrastructure swapped for managed Supabase (Postgres, Auth, Storage, Supabase Queues/pgmq for queue and event bus, Postgres locks, pg_cron); the api and sidecars run on docker-swarm on a VPS. The realtime pattern serves SSE (the default for one-way push) or the same socket.io gateway inside the be api process (one instance, no Redis adapter; R212 case-5), and both editions share knowledge/hfs/fe-stack.yaml. Lite requires application-stacks.yaml and permits environment metadata, runbooks, infra/swarm and encrypted-secret custody, while refusing infra/compose, infra/k8s, infra/terraform and components duplicating Supabase backing services (runtime/{config,env}, seeds and the sealed secrets of its environments are allowed). Contract specs and `test:contract` are allowed (owner 2026-10-14); it has no e2e spec, `test:e2e`, `test:stack` or `test:browser` script, e2e workflow, browser journey, Work catalog, shell, features, resources, UAT or br records, test container, toxiproxy, browser-runner, BullMQ, Kafka, Redis or graphql-ws dependency, or supabase/functions. Its .starciwork is optional and admits only .gitignore, workspace.yaml and brand/**; workspace.yaml is optional and carries project identity and repo roles for the Work tools."
|
|
3572
3575
|
kinds: [check]
|
|
3573
3576
|
gates: [settle, land, ci]
|
|
3574
3577
|
failureCodes: ["HFS_EDITION_INVALID", "HFS_EDITION_FORBIDDEN_PRESENT", "HFS_STARCIWORK_LITE_FORBIDDEN"]
|
|
@@ -3598,6 +3601,11 @@ rules:
|
|
|
3598
3601
|
source: owner
|
|
3599
3602
|
quote: "In a lite app, .starciwork keeps only the brand records."
|
|
3600
3603
|
ruling: "Amended by the owner on 2026-10-14: under lite, .starciwork is optional when the app has no brand records and admits only .gitignore, workspace.yaml and brand/** in the existing brand tooling layout, including drawing records, references and assets. workspace.yaml is optional and keeps the project id and repo roles so Work root detection and render-proof role mapping can tie brand records to the front end. index.yaml, shell/, features/, _resources/, uat and br remain forbidden. Lite readers read workspace.yaml normally and skip the full-edition catalog, shell and feature metadata; lite scaffolding creates .gitignore and workspace.yaml, with brand/ only when present. Full edition is unchanged."
|
|
3604
|
+
- id: case-5
|
|
3605
|
+
date: "2026-10-24"
|
|
3606
|
+
source: owner
|
|
3607
|
+
quote: "Lite may serve SSE, and SSE is the default for one-way push. (alpha.30 BE patterns research, owner decisions 2026-10-24, relayed by the alpha.30 lead)"
|
|
3608
|
+
ruling: "Server-sent events are the third door of the realtime pattern (be.feature.realtime.sse), legal in lite and in full, and the default door for one-way server-to-client push; this amends the lite realtime adapter clause of case-2, which named the socket.io gateway as lite's only push. The socket.io gateway stays for a bidirectional channel, graphql-ws and the GraphQL subscription door stay full-only, and the hub is single-replica (the lite api pins replicas: 1, STACKS_REALTIME_SCALED)."
|
|
3601
3609
|
proves: [packages/hfs/src/hfs-edition-invalid.spec.mjs, packages/hfs/src/hfs-edition-forbidden-present.spec.mjs, packages/hfs/src/hfs-starciwork-lite-forbidden.spec.mjs]
|
|
3602
3610
|
exemplifiedBy:
|
|
3603
3611
|
- {path: packages/hfs/src/hfs-edition-invalid.spec.mjs, editions: [lite, full]}
|
|
@@ -4750,8 +4758,8 @@ rules:
|
|
|
4750
4758
|
- {path: packages/hfs/src/hfs-secret-custody.spec.mjs, editions: [full], codes: [HFS_SECRET_OUTSIDE_ENC]}
|
|
4751
4759
|
- id: R278
|
|
4752
4760
|
code: "BE_REST_DOOR_EVIDENCE"
|
|
4753
|
-
amendedBy: [owner:credential-guarded-rest-door]
|
|
4754
|
-
law: "GraphQL is the default door in lite and full. Each REST route carries its own evidence: a probe, auth handshake, signed webhook, actual byte stream, a public versioned operation served from the app's nonempty defineOperations table, or a credential-guarded route (owner:credential-guarded-rest-door): the route or its controller carries @Credential({ kind: \"<literal>\" }) imported from the domain identity capability, which also declares the CredentialGuard class implementing CanActivate (resolved by TypeScript symbol, never by name); a local decorator of the same name, a @UseGuards of any other guard and a computed kind prove nothing. A class-level reference or an unused StreamableFile import proves no route, and a public REST route absent from the operation table is refused."
|
|
4761
|
+
amendedBy: [owner:credential-guarded-rest-door, owner:lite-sse-door]
|
|
4762
|
+
law: "GraphQL is the default door in lite and full. Each REST route carries its own evidence: a probe, auth handshake, signed webhook, actual byte stream, an event stream (a text/event-stream route: an `@Sse` method of `@nestjs/common` inside the be.feature.realtime.sse slot, owner:lite-sse-door; an `@Sse` anywhere else proves nothing), a public versioned operation served from the app's nonempty defineOperations table, or a credential-guarded route (owner:credential-guarded-rest-door): the route or its controller carries @Credential({ kind: \"<literal>\" }) imported from the domain identity capability, which also declares the CredentialGuard class implementing CanActivate (resolved by TypeScript symbol, never by name); a local decorator of the same name, a @UseGuards of any other guard and a computed kind prove nothing. A class-level reference or an unused StreamableFile import proves no route, and a public REST route absent from the operation table is refused."
|
|
4755
4763
|
kinds: [lint]
|
|
4756
4764
|
gates: [pre-commit, settle, land, ci]
|
|
4757
4765
|
failureCodes: ["BE_REST_DOOR_EVIDENCE", "BE_REST_ROUTE_UNDECLARED"]
|
|
@@ -5279,3 +5287,43 @@ rules:
|
|
|
5279
5287
|
- {path: packages/hfs/src/bypass-detector.spec.mjs, editions: [lite, full], codes: [HFS_BYPASS_MARKER]}
|
|
5280
5288
|
witness:
|
|
5281
5289
|
- {path: packages/hfs/src/bypass-detector.spec.mjs, editions: [lite, full], codes: [HFS_BYPASS_MARKER]}
|
|
5290
|
+
- id: R304
|
|
5291
|
+
code: "BE_SCHEMA_PLACE"
|
|
5292
|
+
law: "The back end validates by zone. A transport door (GraphQL `@InputType`, REST request class) is validated by `class-validator` (R42); untrusted data that never reaches a DTO (a provider's HTTP response, structured model output, a queue or outbox payload read back, a tool argument) is read through a `zod` schema. `zod` is imported only from a `<name>.schema.ts` file (or its spec) of a `modules/domain`, `modules/platform` or `modules/integrations` owner, never in a feature, a transport door or a `dto/` folder, and a schema file imports neither `class-validator`, `class-transformer` nor `@nestjs/graphql`. The reader that calls the schema maps a failed parse to its owner's error family. No schema is shared between the front end and the back end: the shared contract is the emitted `schema.graphql` and `openapi.json`."
|
|
5293
|
+
kinds: [lint, design]
|
|
5294
|
+
gates: [pre-commit, settle, land, ci]
|
|
5295
|
+
failureCodes: ["BE_SCHEMA_PLACE"]
|
|
5296
|
+
enforcers:
|
|
5297
|
+
- {kind: eslint-be, id: schema-place}
|
|
5298
|
+
editions: [lite, full]
|
|
5299
|
+
docs: [docs/quality-bar.md#r304]
|
|
5300
|
+
proves:
|
|
5301
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, editions: [lite, full], codes: [BE_SCHEMA_PLACE]}
|
|
5302
|
+
exemplifiedBy:
|
|
5303
|
+
- {path: examples/full-app/be/src/modules/integrations/sepay/sepay-transactions.schema.ts, edition: full}
|
|
5304
|
+
- {path: examples/lite-app/be/src/modules/domain/calendar-inbox/calendar-delivery.schema.ts, edition: lite}
|
|
5305
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, editions: [lite, full], codes: [BE_SCHEMA_PLACE]}
|
|
5306
|
+
witness:
|
|
5307
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, edition: lite, codes: [BE_SCHEMA_PLACE]}
|
|
5308
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, edition: full, codes: [BE_SCHEMA_PLACE]}
|
|
5309
|
+
templateNa: "A schema is declared per boundary by its owner; the canon scaffolds none, and the two example schemas are the reference."
|
|
5310
|
+
- id: R305
|
|
5311
|
+
code: "BE_SCHEMA_BOUNDED"
|
|
5312
|
+
law: "Every `zod` schema is bounded: each `z.string()` (and the string formats `z.email()`, `z.url()` and kin) has `.max(n)` or `.length(n)`, each `z.array(...)` and `z.set(...)` has `.max(n)`, `.length(n)` or `.size(n)`, each `z.record(...)` and `z.map(...)` bounds its entry count by a `.refine`/`.check`, and `z.any()`, `z.unknown()` and `z.json()` are refused. A bound is a finite positive number: `Infinity`, `0` and `Number.MAX_*` are no bound. An unbounded collection whose elements all fail validation allocates an issue per element (GHSA-mrhx-c7xc-m3w5), and an unbounded string is an unbounded cost on any zod version."
|
|
5313
|
+
kinds: [lint, design]
|
|
5314
|
+
gates: [pre-commit, settle, land, ci]
|
|
5315
|
+
failureCodes: ["BE_SCHEMA_BOUNDED"]
|
|
5316
|
+
enforcers:
|
|
5317
|
+
- {kind: eslint-be, id: schema-bounded}
|
|
5318
|
+
editions: [lite, full]
|
|
5319
|
+
docs: [docs/quality-bar.md#r305]
|
|
5320
|
+
proves:
|
|
5321
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, editions: [lite, full], codes: [BE_SCHEMA_BOUNDED]}
|
|
5322
|
+
exemplifiedBy:
|
|
5323
|
+
- {path: examples/full-app/be/src/modules/integrations/sepay/sepay-transactions.schema.ts, edition: full}
|
|
5324
|
+
- {path: examples/lite-app/be/src/modules/domain/calendar-inbox/calendar-delivery.schema.ts, edition: lite}
|
|
5325
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, editions: [lite, full], codes: [BE_SCHEMA_BOUNDED]}
|
|
5326
|
+
witness:
|
|
5327
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, edition: lite, codes: [BE_SCHEMA_BOUNDED]}
|
|
5328
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, edition: full, codes: [BE_SCHEMA_BOUNDED]}
|
|
5329
|
+
templateNa: "A schema is declared per boundary by its owner; the canon scaffolds none, and the two example schemas are the reference."
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# catalog READ this file through scripts/hfs/slots.mjs; none of them hardcodes a path.
|
|
8
8
|
# Shape: modules/schemas/hfs-slots.schema.yaml. The repository side is hfs.json: modules/schemas/hfs-repo.schema.yaml.
|
|
9
9
|
schema: starci/hfs-slots@3
|
|
10
|
-
version: 3.
|
|
10
|
+
version: 3.6.0
|
|
11
11
|
|
|
12
12
|
versioning:
|
|
13
13
|
# MAJOR.MINOR.PATCH of this manifest. A repository pins only the MAJOR in hfs.json ("hfs": 3).
|
|
@@ -41,6 +41,8 @@ versioning:
|
|
|
41
41
|
# 3.4.0 is a minor (alpha.25): it ADDS the opt-in be.contract.mcp-tools slot, the committed MCP tool-catalog snapshot of an app.
|
|
42
42
|
# 3.5.0 is a minor (alpha.27): it ADDS the opt-in app.browser-config slot, the managed browser/tsconfig.json that type-checks the browser
|
|
43
43
|
# journey (enabled with app.browser by `browser: true`).
|
|
44
|
+
# 3.6.0 is a minor (alpha.30): it ADDS the opt-in be.feature.realtime.sse slot, the server-sent-events door of the realtime kind (owner decision
|
|
45
|
+
# 2026-10-24: lite may serve SSE, and SSE is the default door for one-way push), and lets fe.modules.api hold the SSE stream readers (sse/).
|
|
44
46
|
|
|
45
47
|
# Field meaning, per slot.
|
|
46
48
|
# id stable name, never changes meaning inside a major.
|
|
@@ -217,7 +219,7 @@ ruleParams:
|
|
|
217
219
|
kafkajs: [platform/event-bus] # pattern event-bus: Kafka is the only event transport
|
|
218
220
|
"@nestjs/schedule": [] # no @Cron / @Interval / @Timeout anywhere: a schedule is a BullMQ job scheduler of a queue
|
|
219
221
|
eventemitter2: []
|
|
220
|
-
setInterval: [platform/retry, platform/event-bus, platform/queue, platform/http]
|
|
222
|
+
setInterval: [platform/retry, platform/event-bus, platform/queue, platform/http, platform/realtime]
|
|
221
223
|
setTimeout: [platform/retry, platform/event-bus, platform/queue, platform/http]
|
|
222
224
|
"node:timers": [platform/retry, platform/event-bus, platform/queue, platform/http]
|
|
223
225
|
"node:timers/promises": [platform/retry, platform/event-bus, platform/queue, platform/http]
|
|
@@ -279,7 +281,7 @@ ruleParams:
|
|
|
279
281
|
# R89 BE_SOURCE_FORM: the closed role-suffix vocabulary of a source file name (<kebab-name>.<suffix>.ts; BE-CONVENTION 1.15)
|
|
280
282
|
# and the suffixes that are never a role. index.ts, main.ts, app.module.ts and the migrations of be.persistence are the
|
|
281
283
|
# only names outside it.
|
|
282
|
-
suffixes: [module, module-definition, decorators, options, config, connection, service, command, query, handler, templates, compensation, saga-state, saga-step, saga, contracts, port, resolver, controller, gateway, webhook, subscription, consumer, cli, event, queue, projection, projection-entity, processor, step, input, type, args, request, response, mapper, policy, entity, sql, rows, error, messages, log-events, cache-keys, guard, interceptor, filter, client, spec, integration-spec, e2e-spec, contract-spec, global-setup, builder]
|
|
284
|
+
suffixes: [module, module-definition, decorators, options, config, connection, service, schema, command, query, handler, templates, compensation, saga-state, saga-step, saga, contracts, port, resolver, controller, gateway, webhook, subscription, consumer, cli, event, queue, projection, projection-entity, processor, step, input, type, args, request, response, mapper, policy, entity, sql, rows, error, messages, log-events, cache-keys, guard, interceptor, filter, client, spec, integration-spec, e2e-spec, contract-spec, global-setup, builder]
|
|
283
285
|
# R47 BE_CONTRACT_UNGUARDED: the world's shape vocabulary. `helper` names the function a contract spec imports to erase the values of a JSON
|
|
284
286
|
# payload (`shapeOf`); a contract spec proves a fake's fixtures by asserting helper(real) equals helper(fixture).
|
|
285
287
|
contractShape: {helper: shapeOf}
|
|
@@ -298,7 +300,7 @@ ruleParams:
|
|
|
298
300
|
# options, config, command, query, contracts, port, event, queue, entity, sql, rows, messages, log-events, error, decorators, cache-keys,
|
|
299
301
|
# projection-entity, connection, and every file with no role: index.ts, main.ts, migrations) is declaration or composition and is never measured.
|
|
300
302
|
# A unit spec beside such a file (`<name>.<role>.spec.ts`) is legal but not required: the file owes 100, not a spec of its own.
|
|
301
|
-
logicRoles: [service, policy, projection, guard, mapper, client, filter, interceptor, processor, step, saga, saga-step, compensation, consumer, webhook]
|
|
303
|
+
logicRoles: [service, schema, policy, projection, guard, mapper, client, filter, interceptor, processor, step, saga, saga-step, compensation, consumer, webhook]
|
|
302
304
|
# R203 BE_FEATURE_THIN. THE roles a feature-tier file is judged thin by: a file `<name>.<role>.ts` of one of these roles inside a slot of tier
|
|
303
305
|
# feature maps parameters and makes ONE delegating call into modules/ (a service, the command or query bus, the event bus, a queue producer): no
|
|
304
306
|
# branch, loop, ternary, try or throw, no computation; orchestration (step order, compensation, claim, fencing, retry) lives in platform/*.
|
|
@@ -1434,9 +1436,9 @@ slots:
|
|
|
1434
1436
|
since: 1.0.0
|
|
1435
1437
|
- id: be.feature.realtime
|
|
1436
1438
|
profiles: [be]
|
|
1437
|
-
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite runs socket.io in the api process, one instance without Redis; full may scale its gateway."
|
|
1439
|
+
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4) and ruling:R212/case-5 (owner decision 2026-10-24): lite serves server-sent events (the default door for one-way push) or runs socket.io in the api process, one instance without Redis; full may scale its gateway."
|
|
1438
1440
|
path: "src/features/realtime/<channel>/"
|
|
1439
|
-
presence: opt-in # a push channel: reads and pushes only (a socket gateway or a GraphQL subscription), no domain write
|
|
1441
|
+
presence: opt-in # a push channel: reads and pushes only (an SSE controller, a socket gateway or a GraphQL subscription), no domain write
|
|
1440
1442
|
pattern: realtime # declaring the pattern (hfs.json sides.be.patterns) enables the kind (knowledge/patterns/be/realtime.yaml)
|
|
1441
1443
|
trigger: realtime
|
|
1442
1444
|
tracked: tracked
|
|
@@ -1447,9 +1449,9 @@ slots:
|
|
|
1447
1449
|
tests: none
|
|
1448
1450
|
lite:
|
|
1449
1451
|
why: >-
|
|
1450
|
-
a push channel
|
|
1451
|
-
|
|
1452
|
-
(knowledge/patterns/be/realtime.yaml, liteAdapter)
|
|
1452
|
+
a push channel: the server-sent-events door (be.feature.realtime.sse, the default for one-way push, owner decision 2026-10-24)
|
|
1453
|
+
or the same socket.io gateway as full (owner ruling 2026-10-13), both run inside the be api process, one instance and no
|
|
1454
|
+
Redis adapter; the front end listens through a use<Channel>Stream hook over modules/api/sse, or hooks/socketio (knowledge/patterns/be/realtime.yaml, liteAdapter)
|
|
1453
1455
|
coverage: none
|
|
1454
1456
|
rules: [BE_KIND_ISOLATION, BE_FEATURE_IMPORTS_FEATURE, BE_FEATURE_NOT_COMPOSED]
|
|
1455
1457
|
since: 1.0.0
|
|
@@ -1457,14 +1459,14 @@ slots:
|
|
|
1457
1459
|
- {path: packages/hfs/src/hfs-stacks-swarm.spec.mjs, edition: lite}
|
|
1458
1460
|
- id: be.feature.realtime.graphql
|
|
1459
1461
|
profiles: [be]
|
|
1460
|
-
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite
|
|
1462
|
+
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite pushes through the SSE door or the in-process socket.io gateway; the GraphQL subscription door remains full-only."
|
|
1461
1463
|
path: "src/features/realtime/<channel>/transport/graphql/"
|
|
1462
1464
|
presence: opt-in # a GraphQL subscription over graphql-ws
|
|
1463
|
-
litePresence: forbidden # ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite pushes through the api's in-process socket.io gateway
|
|
1465
|
+
litePresence: forbidden # ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite pushes through the api's SSE door or its in-process socket.io gateway
|
|
1464
1466
|
pattern: realtime
|
|
1465
1467
|
tracked: tracked
|
|
1466
1468
|
tier: feature
|
|
1467
|
-
goesTo: "src/features/realtime/<channel>/transport/
|
|
1469
|
+
goesTo: "src/features/realtime/<channel>/transport/sse/ (be.feature.realtime.sse, the default for one-way push) or transport/websocket/ (be.feature.realtime.websocket): lite runs either door in the api process"
|
|
1468
1470
|
requires: ["<channel>-graphql.module.ts", "<channel>.subscription.ts", "<channel>.subscription.spec.ts"]
|
|
1469
1471
|
allows: ["<channel>-graphql.module.ts", "<channel>-graphql.module-definition.ts", "<channel>-graphql.options.ts", "<channel>.subscription.ts", "<channel>.subscription.spec.ts", dto/]
|
|
1470
1472
|
tests: unit-beside # the door's own spec beside the door (a subscription has no service of its own)
|
|
@@ -1474,14 +1476,14 @@ slots:
|
|
|
1474
1476
|
since: 1.0.0
|
|
1475
1477
|
- id: be.feature.realtime.dto
|
|
1476
1478
|
profiles: [be]
|
|
1477
|
-
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): GraphQL subscription DTOs exist only with the full subscription door; lite uses socket.io contracts."
|
|
1479
|
+
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): GraphQL subscription DTOs exist only with the full subscription door; lite uses SSE or socket.io contracts."
|
|
1478
1480
|
path: "src/features/realtime/<channel>/transport/graphql/dto/"
|
|
1479
1481
|
presence: opt-in
|
|
1480
1482
|
litePresence: forbidden # the dto of the graphql door, which lite does not serve
|
|
1481
1483
|
pattern: realtime
|
|
1482
1484
|
tracked: tracked
|
|
1483
1485
|
tier: feature
|
|
1484
|
-
goesTo: "src/features/realtime/<channel>/transport/
|
|
1486
|
+
goesTo: "src/features/realtime/<channel>/transport/sse/ (be.feature.realtime.sse, the default for one-way push) or transport/websocket/ (be.feature.realtime.websocket): lite runs either door in the api process"
|
|
1485
1487
|
allows: ["<name>.{input,type,args}.ts"]
|
|
1486
1488
|
tests: none
|
|
1487
1489
|
coverage: none
|
|
@@ -1502,6 +1504,22 @@ slots:
|
|
|
1502
1504
|
composedBy: [api]
|
|
1503
1505
|
rules: [BE_REALTIME_SHAPE, BE_REALTIME_WRITES, BE_REALTIME_TOPIC_SCOPE, BE_DEFAULT_DENY]
|
|
1504
1506
|
since: 1.0.0
|
|
1507
|
+
- id: be.feature.realtime.sse
|
|
1508
|
+
since: 3.6.0
|
|
1509
|
+
profiles: [be]
|
|
1510
|
+
path: "src/features/realtime/<channel>/transport/sse/"
|
|
1511
|
+
presence: opt-in # a server-sent-events door: one @Sse method, the default door for one-way push (owner decision 2026-10-24), served in both editions
|
|
1512
|
+
pattern: realtime
|
|
1513
|
+
tracked: tracked
|
|
1514
|
+
tier: feature
|
|
1515
|
+
requires: ["<channel>-sse.module.ts", "<channel>.controller.ts", "<channel>.controller.spec.ts"]
|
|
1516
|
+
allows: ["<channel>-sse.module.ts", "<channel>-sse.module-definition.ts", "<channel>-sse.options.ts", "<channel>.controller.ts", "<channel>.controller.spec.ts"]
|
|
1517
|
+
tests: unit-beside # the door's own spec beside the door (an SSE controller has no service of its own)
|
|
1518
|
+
coverage: none
|
|
1519
|
+
composedBy: [api]
|
|
1520
|
+
rules: [BE_REALTIME_SHAPE, BE_REALTIME_WRITES, BE_REALTIME_TOPIC_SCOPE, BE_DEFAULT_DENY, BE_REST_DOOR_EVIDENCE]
|
|
1521
|
+
exemplifiedBy:
|
|
1522
|
+
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full]}
|
|
1505
1523
|
- id: be.cli
|
|
1506
1524
|
since: 3.1.0
|
|
1507
1525
|
profiles: [be]
|
|
@@ -1552,7 +1570,7 @@ slots:
|
|
|
1552
1570
|
requires: [index.ts]
|
|
1553
1571
|
# <name>.templates.ts holds the capability's static templates, copy and presets as TS constants (owner ruling 2026-10-13);
|
|
1554
1572
|
# user-editable ones are seed rows, large or user media live in object storage.
|
|
1555
|
-
allows: ["<capability>.module.ts", "<capability>.module-definition.ts", "<capability>.config.ts", "<capability>.options.ts", errors/, messages/, persistence/, policies/, "*.service.ts", "*.service.spec.ts", "*.contracts.ts", "*.templates.ts"]
|
|
1573
|
+
allows: ["<capability>.module.ts", "<capability>.module-definition.ts", "<capability>.config.ts", "<capability>.options.ts", errors/, messages/, persistence/, policies/, "*.service.ts", "*.service.spec.ts", "*.contracts.ts", "*.templates.ts", "*.schema.ts", "*.schema.spec.ts"]
|
|
1556
1574
|
forbids: [testing/, exceptions/, utils/, helpers/, shared/]
|
|
1557
1575
|
tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts, nothing else
|
|
1558
1576
|
coverage: required
|
|
@@ -2254,15 +2272,17 @@ slots:
|
|
|
2254
2272
|
# The browser's calls to the back end (owner ruling 2026-10-13, fe-stack graphql/rest/codegen, FE-API-GRAPHQL): the auth and
|
|
2255
2273
|
# no-auth Apollo clients with their link chain (graphql/clients), one file per operation (graphql/queries/query-<x>.ts,
|
|
2256
2274
|
# graphql/mutations/mutation-<x>.ts with its gql document map and typed request/response in types/), the generated wire types
|
|
2257
|
-
# (graphql/__generated__)
|
|
2275
|
+
# (graphql/__generated__), the axios calls (rest/<area>/*.ts) for uploads, presigned URLs and auth endpoints and the fetch readers of
|
|
2276
|
+
# server-sent-events streams (sse/<area>/*.ts, one file per stream, the bearer token from modules/auth). A repository has
|
|
2258
2277
|
# exactly ONE client folder and ONE GraphQLResponse union: the app's (fe.transport.client/outcome) when one app calls the back end,
|
|
2259
2278
|
# or the api package's (fe.package.api.client/outcome) when several do (FE_TRANSPORT_OWNER, machine).
|
|
2260
2279
|
requires: [index.ts]
|
|
2261
2280
|
allows: [index.ts, graphql/index.ts, graphql/types.ts, "graphql/clients/**", "graphql/queries/query-*.ts", "graphql/queries/types/*.ts",
|
|
2262
|
-
"graphql/mutations/mutation-*.ts", "graphql/mutations/types/*.ts", "graphql/__generated__/**", "rest/index.ts", "rest/<area>/*.ts"
|
|
2281
|
+
"graphql/mutations/mutation-*.ts", "graphql/mutations/types/*.ts", "graphql/__generated__/**", "rest/index.ts", "rest/<area>/*.ts",
|
|
2282
|
+
"sse/index.ts", "sse/<area>/*.ts"]
|
|
2263
2283
|
roles: {entry: index.ts, query: "query-*.ts", mutation: "mutation-*.ts"}
|
|
2264
2284
|
# A hook imports its one operation file directly (one file per operation, FE-API-GRAPHQL), never through a barrel of every operation.
|
|
2265
|
-
entries: ["graphql/queries/query-*.ts", "graphql/queries/types/*.ts", "graphql/mutations/mutation-*.ts", "graphql/mutations/types/*.ts"]
|
|
2285
|
+
entries: ["graphql/queries/query-*.ts", "graphql/queries/types/*.ts", "graphql/mutations/mutation-*.ts", "graphql/mutations/types/*.ts", "sse/<area>/*.ts"]
|
|
2266
2286
|
tests: none
|
|
2267
2287
|
rules: [FE_TRANSPORT_OWNER, FE_HTTP_STATUS_COLLAPSE, FE_WIRE_GENERATED]
|
|
2268
2288
|
- id: fe.transport.generated
|
|
@@ -321,7 +321,9 @@ rules:
|
|
|
321
321
|
hfsRules: [R95, R88, R23, R278]
|
|
322
322
|
requirement: |-
|
|
323
323
|
A versioned operation `<name>@<version>` (for example `sales.policy@1`) that an app serves over one operation route is
|
|
324
|
-
declared once, in the app's operation table `be/apps/<app>/src/operations.ts
|
|
324
|
+
declared once, in the app's operation table, which `be/apps/<app>/src/operations.ts` exports as `OPERATIONS`. A feature cannot
|
|
325
|
+
import an app and a feature's contracts hold no call, so the table is made in a `<name>-operations.contracts.ts` of the
|
|
326
|
+
domain capability that owns the operations, and the app file re-exports it from the capability's public surface. The table is made by
|
|
325
327
|
`defineOperations` of the platform capability `operations` (`be/src/modules/platform/operations/`, which declares
|
|
326
328
|
`OperationContract<Input, Output, RefusalCode>`, `query`, `mutation`, `defineOperations`, `OperationRequest<Table>` and
|
|
327
329
|
`OperationReply<Table>`). `Input` and `Output` are named, closed types: never `any`, `unknown`, `Record<string, unknown>`,
|
|
@@ -333,6 +335,10 @@ rules:
|
|
|
333
335
|
table with the type checker, and the full `starci app check` fails when the committed file is stale.
|
|
334
336
|
A public REST operation in either edition belongs to this nonempty table, and its route decorator supplies its own
|
|
335
337
|
versioned-operation evidence; a class-level reference does not justify a route absent from the table.
|
|
338
|
+
The body of the route is typed by an alias, which leaves no class at run time, so the global pipe validates nothing there.
|
|
339
|
+
The route therefore binds the body to `OperationValidationService` of `platform/http-security`, given a request class that
|
|
340
|
+
mirrors the envelope (`@IsIn` on `operation`, a bounded `requestId`, a `@ValidateNested()` input class with the bounds of
|
|
341
|
+
BE-AUTH-5), and the owning domain service still refuses an input outside its own bounds.
|
|
336
342
|
rationale: |-
|
|
337
343
|
The operation set of an app is a contract the front end (`fe/`, through the root codegen) and other consumers generate a client from. A name-to-string registry with `unknown`
|
|
338
344
|
bodies and results forces every client to hand-type the wire; one typed table gives the client generator, the route and the
|
|
@@ -341,14 +347,18 @@ rules:
|
|
|
341
347
|
- id: case-1
|
|
342
348
|
when: A query with two refusals and a mutation with none
|
|
343
349
|
write: |-
|
|
344
|
-
//
|
|
350
|
+
// modules/domain/sales/sales-operations.contracts.ts
|
|
345
351
|
export const OPERATIONS = defineOperations({
|
|
346
352
|
"sales.policy@1": query<SalesPolicyAsk, SalesPolicyView, "DENIED" | "INVALID">(),
|
|
347
353
|
"sales.close@1": mutation<SalesCloseAsk, SalesOpportunityView>(),
|
|
348
354
|
})
|
|
355
|
+
// be/apps/core/src/operations.ts
|
|
356
|
+
export { OPERATIONS } from "@modules/domain/sales"
|
|
349
357
|
// features/api/sales/transport/http/operations.controller.ts
|
|
350
358
|
@Post("operations")
|
|
351
|
-
async invoke(
|
|
359
|
+
async invoke(
|
|
360
|
+
@Body(new OperationValidationService(SalesPolicyRequest)) request: OperationRequest<typeof OPERATIONS>,
|
|
361
|
+
): Promise<OperationReply<typeof OPERATIONS>> { ... }
|
|
352
362
|
verification:
|
|
353
363
|
automated:
|
|
354
364
|
- BE_OPERATION_CONTRACT
|
|
@@ -358,7 +368,8 @@ rules:
|
|
|
358
368
|
- BE_REST_ROUTE_UNDECLARED
|
|
359
369
|
manual:
|
|
360
370
|
- Confirm each refusal code is one the operation's owner can actually answer.
|
|
361
|
-
|
|
371
|
+
- Confirm that the request class mirrors the envelope and the input bounds of the table.
|
|
372
|
+
relatedExamples: [operations]
|
|
362
373
|
files:
|
|
363
374
|
- path: src/features/api/<feature>/index.ts
|
|
364
375
|
slot: be.feature
|
|
@@ -3,10 +3,15 @@ id: be.feature.realtime
|
|
|
3
3
|
title: Realtime channels
|
|
4
4
|
purpose: |-
|
|
5
5
|
Given a state change a connected client must see without asking, how is it pushed? Realtime is a feature KIND:
|
|
6
|
-
`be/src/features/realtime/<channel>/` holds one door per channel, a
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
the
|
|
6
|
+
`be/src/features/realtime/<channel>/` holds one door per channel, a server-sent-events controller
|
|
7
|
+
(`transport/sse/<channel>.controller.ts`), a socket gateway (`<channel>.gateway.ts`) or a GraphQL subscription
|
|
8
|
+
(`<x>.subscription.ts`), that READS and PUSHES only. It never writes. The push is fed by a reactor: an event consumer in
|
|
9
|
+
`features/reactors/` receives the domain event and publishes to a topic of the `RealtimeHub` of `platform/realtime` (an app
|
|
10
|
+
with no event bus lets the domain service that commits the change publish, after its commit); the door only subscribes the
|
|
11
|
+
client to a topic of that hub, scoped to the client's principal. SSE is the default door for one-way server-to-client push
|
|
12
|
+
(job progress, a status that changes, streamed tokens), in lite and in full (owner decision 2026-10-24): it is plain HTTP
|
|
13
|
+
behind the ingress, reconnects by itself and resumes from `Last-Event-ID`. The socket.io gateway stays for a channel that also
|
|
14
|
+
needs client-to-server messages, and the GraphQL subscription stays full-only.
|
|
10
15
|
appliesTo:
|
|
11
16
|
- backend
|
|
12
17
|
family: be
|
|
@@ -19,27 +24,38 @@ provenance:
|
|
|
19
24
|
- knowledge/patterns/be/transport.yaml
|
|
20
25
|
- knowledge/patterns/be/api-auth.yaml
|
|
21
26
|
- owner decision 2026-10-02 (feature kinds: webhooks, realtime, reactors, saga, jobs, cli; no kind imports another)
|
|
27
|
+
- owner decision 2026-10-24 (the lite edition may serve SSE, and SSE is the default door for one-way push; ruling:R212/case-5)
|
|
22
28
|
limitations: |-
|
|
23
29
|
Static checks prove the door's role, that the hub is the only thing injected and that a subscribed topic is built from the
|
|
24
30
|
principal. They do not prove the topic naming is collision free across channels, that an initial snapshot is consistent with
|
|
25
31
|
the stream, or the behaviour of the socket under reconnects; the three `realtime/*` scenarios prove delivery, isolation and
|
|
26
|
-
cleanup on the real stack.
|
|
32
|
+
cleanup on the real stack. Nothing proves the ingress settings of an SSE channel (no compression, no buffering, HTTP/2, an
|
|
33
|
+
idle timeout above the heartbeat): they are reviewed against the stack files and a real stream. The hub is single-replica:
|
|
34
|
+
its subscriptions and its replay buffer live in one process, so a second replica of the api does not see the pushes of the
|
|
35
|
+
first.
|
|
27
36
|
rules:
|
|
28
37
|
- id: BE-REALTIME-1
|
|
29
38
|
title: A channel is a kind folder with one door, its module and its own spec
|
|
30
39
|
kind: mandatory
|
|
31
|
-
hfsRules: [R01, R02, R47, R184]
|
|
40
|
+
hfsRules: [R01, R02, R47, R184, R278]
|
|
32
41
|
requirement: |-
|
|
33
42
|
A push channel lives in `be/src/features/realtime/<channel>/` (slot `be.feature.realtime`), enabled by declaring the pattern
|
|
34
43
|
`realtime` in `hfs.json` `sides.be.patterns`. The folder holds `index.ts` (the module class an app composes), `transport/<protocol>/<channel>-<protocol>.module.ts`, ONE door and its spec: either
|
|
44
|
+
`transport/sse/<channel>.controller.ts` (one `@Controller` class with ONE `@Sse` method, the default for one-way push) with
|
|
45
|
+
`<channel>.controller.spec.ts`, or
|
|
35
46
|
`transport/websocket/<channel>.gateway.ts` (one `@WebSocketGateway` class whose handlers are `@SubscribeMessage`) with
|
|
36
47
|
`<channel>.gateway.spec.ts`, or `transport/graphql/<channel>.subscription.ts` (one `@Resolver` class whose operations are `@Subscription`) with
|
|
37
48
|
`<channel>.subscription.spec.ts`, plus optional `dto/` (`<name>.{input,type,args}.ts`). A door carries no query, mutation, field
|
|
38
|
-
resolver or REST route
|
|
49
|
+
resolver or plain REST route (`@Get`, `@Post` ...; the `@Sse` stream is the event-stream evidence of BE_REST_DOOR_EVIDENCE only in the
|
|
50
|
+
sse slot). The kind imports only `modules/*`, never another kind, and only an api-kind app composes its module. SSE and the
|
|
51
|
+
socket.io gateway run in lite and in full; the GraphQL subscription is full only.
|
|
39
52
|
rationale: |-
|
|
40
53
|
One file per door keeps a protocol's machinery in one place, and keeping queries and mutations out guarantees that what a
|
|
41
54
|
client can do through a realtime door is listen.
|
|
42
55
|
cases:
|
|
56
|
+
- id: case-3
|
|
57
|
+
when: A one-way push of a status (job progress, a booking that gets confirmed), in either edition
|
|
58
|
+
write: 'features/realtime/booking-status/index.ts, transport/sse/booking-status-sse.module.ts, transport/sse/booking-status.controller.ts and transport/sse/booking-status.controller.spec.ts.'
|
|
43
59
|
- id: case-1
|
|
44
60
|
when: A GraphQL subscription of order status
|
|
45
61
|
write: 'features/realtime/order-status/index.ts, transport/graphql/order-status-graphql.module.ts, transport/graphql/order-status.subscription.ts and transport/graphql/order-status.subscription.spec.ts.'
|
|
@@ -78,6 +94,8 @@ rules:
|
|
|
78
94
|
- BE_CONTRACT_UNGUARDED
|
|
79
95
|
- BE_UNIT_SPEC_NOT_STAGED
|
|
80
96
|
- BE_REALTIME_SHAPE
|
|
97
|
+
- BE_REST_DOOR_EVIDENCE
|
|
98
|
+
- BE_REST_ROUTE_UNDECLARED
|
|
81
99
|
manual:
|
|
82
100
|
- Confirm no query or mutation lives in a subscription door; it lives in an api feature.
|
|
83
101
|
relatedExamples: [realtime]
|
|
@@ -87,12 +105,17 @@ rules:
|
|
|
87
105
|
hfsRules: [R185, R90, R203]
|
|
88
106
|
requirement: |-
|
|
89
107
|
A realtime door is thin (R203 `BE_FEATURE_THIN`): it maps the subscription or the message and makes one delegating call
|
|
90
|
-
to the hub. The only value it injects is the `RealtimeHub` of `platform/realtime` (`subscribe(topic)` returns an
|
|
91
|
-
iterable of frames, `publish(topic, frame)` pushes one; a topic is a typed `RealtimeTopic<T>` built by the owning domain
|
|
92
|
-
contract, never a string written in a door).
|
|
108
|
+
to the hub. The only value it injects is the `RealtimeHub` of `platform/realtime` (`subscribe(topic, options?)` returns an
|
|
109
|
+
async iterable of frames, `publish(topic, frame)` pushes one; a topic is a typed `RealtimeTopic<T>` built by the owning domain
|
|
110
|
+
contract, never a string written in a door). An SSE controller calls `hub.stream(topic, { after })` instead, which returns the
|
|
111
|
+
`Observable<MessageEvent>` Nest needs (the hub maps its own subscription with `eventStream` of `platform/realtime`): the
|
|
112
|
+
opening frame, one event per frame with its `id`, and the comment heartbeat. No `EntityManager`, `CommandBus`, `QueryBus`, event bus, queue or
|
|
93
113
|
service of `modules/domain` is injected: a domain service may write, so it is not provably read-only. The data a client
|
|
94
|
-
needs is pushed by a reactor
|
|
95
|
-
are imported only by `platform/realtime
|
|
114
|
+
needs is pushed by a reactor (or by the domain service that commits the change, in an app with no event bus); the socket
|
|
115
|
+
library and the pub/sub engine (`socket.io`, `graphql-subscriptions`, `graphql-ws`) are imported only by `platform/realtime`,
|
|
116
|
+
and so is the heartbeat timer (`setInterval` is owned by `platform/realtime`, `infraOwners`). Because the door reads only the
|
|
117
|
+
hub, a snapshot cannot be sent on connect from the door: the client reads the current state through a query operation first
|
|
118
|
+
(or again on every open), and the stream carries the changes.
|
|
96
119
|
rationale: |-
|
|
97
120
|
A door that can only touch the hub cannot change business state, so the biggest risk of a long-lived unauthenticated-looking
|
|
98
121
|
connection, a write through it, is removed by type.
|
|
@@ -109,11 +132,15 @@ rules:
|
|
|
109
132
|
kind: mandatory
|
|
110
133
|
hfsRules: [R186, R41]
|
|
111
134
|
requirement: |-
|
|
112
|
-
Every `@SubscribeMessage` and `@
|
|
135
|
+
Every `@SubscribeMessage`, `@Subscription` and `@Sse` handler takes `@CurrentPrincipal()` of `domain/identity` and builds the topic it
|
|
113
136
|
passes to `hub.subscribe` from the principal's id (plus a client-supplied id only inside that prefix) with the topic
|
|
114
137
|
builder of the owning domain contract, so one client cannot
|
|
115
138
|
listen to another's channel. The reactor that pushes uses the same topic builder from the owning domain contract, never a
|
|
116
|
-
hand-written string. The default-deny guards apply to the door like to any other
|
|
139
|
+
hand-written string. The default-deny guards apply to the door like to any other; an SSE stream is never `@Public`. The
|
|
140
|
+
browser `EventSource` cannot send an `Authorization` header, so the front end reads the stream with `fetch` and the bearer
|
|
141
|
+
access token (modules/api/sse read by a use<Channel>Stream hook); a token in the URL is refused because it ends up in logs. The `Last-Event-ID` header is client
|
|
142
|
+
input like any other: it is passed as `{ after }` to `hub.subscribe` and only picks where the replay resumes inside the
|
|
143
|
+
principal's own topic, it never names a topic.
|
|
117
144
|
rationale: |-
|
|
118
145
|
A topic taken from client input alone is an authorization hole: knowing an order id would be enough to watch it.
|
|
119
146
|
cases:
|
|
@@ -136,8 +163,9 @@ rules:
|
|
|
136
163
|
`subscriber-receives-push` (a connected client receives the frame a reactor pushes after the domain event),
|
|
137
164
|
`foreign-topic-is-isolated` (another principal subscribed to the same channel receives nothing) and
|
|
138
165
|
`disconnect-releases-subscription` (after the client disconnects the hub holds no subscription for it). The client is a real
|
|
139
|
-
socket
|
|
140
|
-
|
|
166
|
+
socket, GraphQL-over-websocket or HTTP event-stream client of the test world (an SSE channel is read with a real `fetch` of
|
|
167
|
+
`text/event-stream` against the running api, with the principal's bearer token). The unit spec of the door covers its wiring
|
|
168
|
+
with the Nest testing module and a recording hub double.
|
|
141
169
|
rationale: |-
|
|
142
170
|
The door has no logic, so the risk is delivery, isolation and leaks on a real connection, which only a client against the
|
|
143
171
|
real app shows.
|
|
@@ -151,6 +179,42 @@ rules:
|
|
|
151
179
|
manual:
|
|
152
180
|
- Confirm the e2e client waits with the world's waitFor and never sleeps.
|
|
153
181
|
relatedExamples: [realtime]
|
|
182
|
+
- id: BE-REALTIME-5
|
|
183
|
+
title: An SSE stream stays open, resumes and is bounded
|
|
184
|
+
kind: mandatory
|
|
185
|
+
hfsRules: [R184, R278, R90]
|
|
186
|
+
requirement: |-
|
|
187
|
+
An SSE channel returns `hub.stream(topic, { after: lastEventId })` of `platform/realtime` and nothing else:
|
|
188
|
+
the stream writes an opening frame with the reconnect delay (`retry`), one event per frame whose `id` is the domain event id
|
|
189
|
+
of the frame (unique within the topic, for example `<booking>:<status>`), and a comment heartbeat (`: heartbeat`) every 15 to 25
|
|
190
|
+
seconds while the channel is idle, below the idle timeout of the ingress. The heartbeat timer belongs to `platform/realtime`
|
|
191
|
+
alone (`infraOwners` `setInterval`). The hub retains the last frames of a topic that carry a string `id` in a bounded
|
|
192
|
+
buffer (a fixed number of frames per topic and of topics, the oldest dropped first), so a client that reconnects with
|
|
193
|
+
`Last-Event-ID` receives what it missed; an id the buffer no longer holds replays the whole buffer, and the client refetches
|
|
194
|
+
its snapshot through the query operation on every open. The hub is single-replica: the api that composes an SSE channel
|
|
195
|
+
runs one replica (STACKS_REALTIME_SCALED in lite); a shared hub across replicas is a later decision. At the ingress, the
|
|
196
|
+
stream route is never compressed or buffered (Nest sends `X-Accel-Buffering: no` and `Cache-Control: no-cache, no-transform`;
|
|
197
|
+
Traefik's compress middleware excludes `text/event-stream`, a Caddy route sets no `encode` and `flush_interval -1`), the edge
|
|
198
|
+
speaks HTTP/2 to the browser so the six-connection limit of HTTP/1.1 per origin does not starve tabs, and an Express
|
|
199
|
+
`compression()` is never put in front of the app.
|
|
200
|
+
rationale: |-
|
|
201
|
+
A quiet stream is cut by every proxy that sees no bytes, a buffered one delivers nothing until it fills, and a stream with no
|
|
202
|
+
ids loses every push made while the client reconnects. Bounding the buffer keeps one process from growing with its history.
|
|
203
|
+
cases:
|
|
204
|
+
- id: case-1
|
|
205
|
+
when: A client on a flaky network reconnects after missing two pushes
|
|
206
|
+
write: 'The browser reconnects with Last-Event-ID: b-1:confirmed, hub.stream(topic, { after: "b-1:confirmed" }) replays the retained frames after it, then goes live; the front end also refetches the status once on open.'
|
|
207
|
+
verification:
|
|
208
|
+
automated:
|
|
209
|
+
- BE_REALTIME_SHAPE
|
|
210
|
+
- BE_REST_DOOR_EVIDENCE
|
|
211
|
+
- BE_REST_ROUTE_UNDECLARED
|
|
212
|
+
- BE_INFRA_OWNER
|
|
213
|
+
manual:
|
|
214
|
+
- Confirm the ingress route of the api sets no compression and no response buffering for text/event-stream, and that its idle timeout is above the heartbeat.
|
|
215
|
+
- Confirm the lite stack pins the api to one replica (STACKS_REALTIME_SCALED refuses a scaled api that composes realtime).
|
|
216
|
+
- Confirm the replay bounds fit the channel (frames per topic, number of topics).
|
|
217
|
+
relatedExamples: [realtime]
|
|
154
218
|
files:
|
|
155
219
|
- path: src/features/realtime/<channel>/index.ts
|
|
156
220
|
slot: be.feature.realtime
|
|
@@ -214,11 +278,43 @@ files:
|
|
|
214
278
|
template: realtime/channel.input.ts.tpl
|
|
215
279
|
role: The typed, bounded arguments of the door.
|
|
216
280
|
rules: [BE_INPUT_BOUNDED]
|
|
281
|
+
- path: src/features/realtime/<channel>/transport/sse/<channel>-sse.options.ts
|
|
282
|
+
slot: be.feature.realtime.sse
|
|
283
|
+
template: realtime/channel.sse.options.ts.tpl
|
|
284
|
+
role: The options of the SSE door's module; empty, the app's default-deny guards authenticate the stream.
|
|
285
|
+
optional: true
|
|
286
|
+
- path: src/features/realtime/<channel>/transport/sse/<channel>-sse.module-definition.ts
|
|
287
|
+
slot: be.feature.realtime.sse
|
|
288
|
+
template: realtime/channel.sse.module-definition.ts.tpl
|
|
289
|
+
role: The configurable-module base of the SSE door's module, registered as register.
|
|
290
|
+
optional: true
|
|
291
|
+
- path: src/features/realtime/<channel>/transport/sse/<channel>-sse.module.ts
|
|
292
|
+
slot: be.feature.realtime.sse
|
|
293
|
+
template: realtime/channel.sse.module.ts.tpl
|
|
294
|
+
role: Nest module of the SSE door, composed only by an api-kind app.
|
|
295
|
+
optional: true
|
|
296
|
+
- path: src/features/realtime/<channel>/transport/sse/<channel>.controller.ts
|
|
297
|
+
slot: be.feature.realtime.sse
|
|
298
|
+
template: realtime/channel.controller.ts.tpl
|
|
299
|
+
role: A server-sent-events door (one Controller class with one Sse method); the default door for one-way push.
|
|
300
|
+
rules: [BE_REALTIME_SHAPE, BE_REALTIME_WRITES, BE_REALTIME_TOPIC_SCOPE, BE_REST_DOOR_EVIDENCE]
|
|
301
|
+
spec: src/features/realtime/<channel>/transport/sse/<channel>.controller.spec.ts
|
|
302
|
+
optional: true
|
|
303
|
+
- path: src/features/realtime/<channel>/transport/sse/<channel>.controller.spec.ts
|
|
304
|
+
slot: be.feature.realtime.sse
|
|
305
|
+
template: realtime/channel.controller.spec.ts.tpl
|
|
306
|
+
role: Unit spec of the SSE controller beside it.
|
|
307
|
+
optional: true
|
|
217
308
|
- path: src/modules/platform/realtime/realtime.hub.ts
|
|
218
309
|
slot: be.platform
|
|
219
|
-
role: RealtimeHub, the one owner of the socket and subscription engine (publish and subscribe).
|
|
310
|
+
role: RealtimeHub, the one owner of the socket and subscription engine (publish and subscribe, with the bounded replay buffer an SSE reconnect resumes from).
|
|
220
311
|
spec: src/modules/platform/realtime/realtime.service.spec.ts
|
|
221
312
|
optional: true
|
|
313
|
+
- path: src/modules/platform/realtime/event-stream.mapper.ts
|
|
314
|
+
slot: be.platform
|
|
315
|
+
role: eventStream, the one place that turns hub frames into server-sent events (opening frame, ids, comment heartbeat) and owns the heartbeat timer.
|
|
316
|
+
spec: src/modules/platform/realtime/event-stream.mapper.spec.ts
|
|
317
|
+
optional: true
|
|
222
318
|
- path: src/features/reactors/<name>/transport/message/<event>.consumer.ts
|
|
223
319
|
slot: be.transport.message
|
|
224
320
|
role: The reactor consumer that receives the domain event and pushes the frame through the hub.
|
|
@@ -229,21 +325,24 @@ files:
|
|
|
229
325
|
spec: realtime/subscriber-receives-push, realtime/foreign-topic-is-isolated, realtime/disconnect-releases-subscription
|
|
230
326
|
rules: [BE_PATTERN_SPEC_MISSING]
|
|
231
327
|
optional: true
|
|
232
|
-
# Edition lite (R212, owner ruling 2026-10-14): the same ports, door and rules; one instance behind them.
|
|
328
|
+
# Edition lite (R212, owner ruling 2026-10-14; the SSE door by owner decision 2026-10-24, ruling:R212/case-5): the same ports, door and rules; one instance behind them.
|
|
233
329
|
liteAdapter:
|
|
234
330
|
summary: >-
|
|
235
|
-
Under lite the realtime pattern is the full one: the
|
|
236
|
-
(transport/
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
331
|
+
Under lite the realtime pattern is the full one with its lite doors: the SSE controller of be.feature.realtime.sse
|
|
332
|
+
(transport/sse/<channel>.controller.ts, the default for one-way push) or the socket.io gateway of be.feature.realtime.websocket
|
|
333
|
+
(transport/websocket/<channel>.gateway.ts) runs inside the be api process, one instance (the memory hub, its bounded replay
|
|
334
|
+
buffer and the socket.io default in-memory adapter, no Redis adapter). A publisher (a reactor, or the domain service that
|
|
335
|
+
commits the change in an app with no event bus) publishes to the RealtimeHub of platform/realtime; the SSE door streams the
|
|
336
|
+
principal-scoped topic as text/event-stream and the gateway emits to the principal-scoped room of the namespace. The front end
|
|
337
|
+
listens through a use<Channel>Stream hook over modules/api/sse (a fetch reader with the bearer token) or hooks/socketio (knowledge/patterns/fe/realtime.yaml).
|
|
338
|
+
graphql-ws subscriptions are not used in lite.
|
|
240
339
|
ports:
|
|
241
|
-
- {port: RealtimeHub.publish (platform/realtime), adapter: "the
|
|
242
|
-
- {port: RealtimeHub.subscribe, adapter: "the gateway's
|
|
243
|
-
- {port: topic scope, adapter: "the room name built from @CurrentPrincipal (BE_REALTIME_TOPIC_SCOPE), as full"}
|
|
340
|
+
- {port: RealtimeHub.publish (platform/realtime), adapter: "the memory hub of the api process retains the frame (bounded) and hands it to the open SSE streams; the socket.io server emits to the namespace room of the topic (no Redis adapter: one instance)"}
|
|
341
|
+
- {port: RealtimeHub.subscribe, adapter: "the SSE controller streams hub.stream(topic, { after }) (the subscription through eventStream); the gateway's stream joins the room built from the principal, as full"}
|
|
342
|
+
- {port: topic scope, adapter: "the topic and the room name built from @CurrentPrincipal (BE_REALTIME_TOPIC_SCOPE), as full"}
|
|
244
343
|
forbids: [redis, ioredis, "@socket.io/redis-adapter", graphql-ws, graphql-subscriptions]
|
|
245
344
|
proof: >-
|
|
246
|
-
The
|
|
247
|
-
integration specs titled `realtime/<scenario>: ...` for every declared scenario on
|
|
248
|
-
real socket.io client. Lite has no e2e world.
|
|
345
|
+
The door's own unit spec (<channel>.controller.spec.ts or <channel>.gateway.spec.ts) and the hub adapter's spec, as full;
|
|
346
|
+
declaring realtime requires integration specs titled `realtime/<scenario>: ...` for every declared scenario on
|
|
347
|
+
`starci supabase start` (R284), using a real client (an HTTP event-stream read or a socket.io client). Lite has no e2e world.
|
|
249
348
|
see: [knowledge/patterns/be/supabase-queues.yaml, knowledge/patterns/fe/realtime.yaml]
|
|
@@ -1027,12 +1027,13 @@ BE_MODULE_SHAPE:
|
|
|
1027
1027
|
BE_OPERATION_CONTRACT:
|
|
1028
1028
|
title: "A versioned operation has no typed contract"
|
|
1029
1029
|
title_vi: "Thao tác có phiên bản không có hợp đồng kiểu"
|
|
1030
|
-
meaning_vi: "Mỗi thao tác `<tên>@<phiên bản>` của một app được khai báo đúng một lần trong bảng thao tác có kiểu `apps/<app>/src/operations.ts` (`OperationContract<Input, Output, RefusalCode>` đăng ký bằng `defineOperations`). Bộ kiểm tra thấy đầu vào hoặc đầu ra là `any`, `unknown`, `Record<string, unknown>`, kiểu generic chưa gán, hàm hoặc `Date`; mã từ chối không phải hợp các chuỗi literal đóng; hoặc route phục vụ bảng không nhận `OperationRequest<Table>` và trả `Promise<OperationReply<Table>>` của cùng một bảng."
|
|
1030
|
+
meaning_vi: "Mỗi thao tác `<tên>@<phiên bản>` của một app được khai báo đúng một lần trong bảng thao tác có kiểu `apps/<app>/src/operations.ts` (`OperationContract<Input, Output, RefusalCode>` đăng ký bằng `defineOperations`). Bộ kiểm tra thấy đầu vào hoặc đầu ra là `any`, `unknown`, `Record<string, unknown>`, kiểu generic chưa gán, hàm hoặc `Date`; mã từ chối không phải hợp các chuỗi literal đóng; hoặc route phục vụ bảng không nhận `OperationRequest<Table>` và trả `Promise<OperationReply<Table>>` của cùng một bảng, hoặc body của route chưa gắn `OperationValidationService` nên pipe toàn cục không có lớp nào để kiểm."
|
|
1031
1031
|
causes_vi:
|
|
1032
1032
|
- "Đầu vào hoặc kết quả của thao tác khai báo là `unknown` nên client phải tự gõ kiểu tay"
|
|
1033
1033
|
- "Mã từ chối khai báo là `string` thay vì hợp các literal như `\"DENIED\" | \"INVALID\"`"
|
|
1034
1034
|
- "Route nhận `@Body() body: unknown` hoặc chỉ dùng một nửa `OperationRequest` / `OperationReply`"
|
|
1035
|
-
|
|
1035
|
+
- "Body của route chỉ gõ bằng `OperationRequest<Table>` mà không có `@Body(new OperationValidationService(RequestClass))`, nên không ai kiểm giá trị lúc chạy"
|
|
1036
|
+
nextStep_vi: "Khai báo kiểu có tên, đóng cho đầu vào và đầu ra của từng thao tác, liệt kê mã từ chối bằng hợp literal, và cho route nhận `@Body(new OperationValidationService(RequestClass)) request: OperationRequest<typeof OPERATIONS>` và trả `Promise<OperationReply<typeof OPERATIONS>>`; chạy `starci app emit` để sinh `contracts/<app>/openapi.json`. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra và mục BE-OPERATIONS-1; không thêm ngoại lệ, không tắt luật."
|
|
1036
1037
|
owner: op-retry
|
|
1037
1038
|
kind: check-finding
|
|
1038
1039
|
|
|
@@ -1318,6 +1319,17 @@ BE_SCHEMA_AUTHORITY:
|
|
|
1318
1319
|
owner: op-retry
|
|
1319
1320
|
kind: check-finding
|
|
1320
1321
|
|
|
1322
|
+
BE_SCHEMA_BOUNDED:
|
|
1323
|
+
title: "A zod schema has an unbounded string, collection or open value"
|
|
1324
|
+
title_vi: "Schema zod có chuỗi, tập hợp hoặc giá trị mở không giới hạn"
|
|
1325
|
+
meaning_vi: "Schema ở `<file>` có `z.string()` (hoặc `z.email()`, `z.url()`) không có `.max(n)`/`.length(n)`, `z.array()`/`z.set()` không có `.max(n)`/`.length(n)`/`.size(n)`, `z.record()`/`z.map()` không giới hạn số phần tử bằng `.refine`/`.check`, hoặc dùng `z.any()`, `z.unknown()`, `z.json()`; hoặc giới hạn vô nghĩa như `Infinity`, `0`, `Number.MAX_*`."
|
|
1326
|
+
causes_vi:
|
|
1327
|
+
- "Vi phạm luật R305: mảng không giới hạn mà mọi phần tử đều lỗi sẽ cấp phát một issue cho từng phần tử (GHSA-mrhx-c7xc-m3w5), còn chuỗi không giới hạn là chi phí không giới hạn ở mọi phiên bản zod"
|
|
1328
|
+
- "Schema mô tả dữ liệu ngoài bằng `z.any()` hoặc `z.unknown()` nên thực chất không kiểm gì"
|
|
1329
|
+
nextStep_vi: "Thêm giới hạn hữu hạn dương cho từng chuỗi và tập hợp (`z.string().max(200)`, `z.array(item).max(100)`), giới hạn số phần tử của record bằng `.refine`, và mô tả hình dạng thật thay cho `z.any()`. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra."
|
|
1330
|
+
owner: op-retry
|
|
1331
|
+
kind: check-finding
|
|
1332
|
+
|
|
1321
1333
|
BE_SCHEMA_OWNER:
|
|
1322
1334
|
title: "Entities and migrations live in the owning capability's `persistence/`"
|
|
1323
1335
|
title_vi: "Entity hoặc migration sai chủ"
|
|
@@ -1328,6 +1340,17 @@ BE_SCHEMA_OWNER:
|
|
|
1328
1340
|
owner: op-retry
|
|
1329
1341
|
kind: check-finding
|
|
1330
1342
|
|
|
1343
|
+
BE_SCHEMA_PLACE:
|
|
1344
|
+
title: "A zod schema is outside its zone"
|
|
1345
|
+
title_vi: "Schema zod đặt sai vùng"
|
|
1346
|
+
meaning_vi: "Tệp `<file>` import `zod` ngoài tệp `<name>.schema.ts` của một chủ sở hữu domain, platform hoặc integrations; hoặc import `zod` trong feature, cửa transport hay thư mục `dto/`; hoặc tệp schema còn import `class-validator`, `class-transformer` hay `@nestjs/graphql`."
|
|
1347
|
+
causes_vi:
|
|
1348
|
+
- "Vi phạm luật R304: đầu vào của cửa transport được class-validator kiểm (R42), còn dữ liệu không đi qua DTO (phản hồi của nhà cung cấp, đầu ra có cấu trúc của mô hình, payload hàng đợi hoặc outbox đọc lại) mới đi qua schema zod của chủ sở hữu"
|
|
1349
|
+
- "Schema zod viết thẳng trong service, mapper hay lớp DTO thay vì tệp `<name>.schema.ts`"
|
|
1350
|
+
nextStep_vi: "Chuyển schema sang `<name>.schema.ts` cạnh bộ đọc trong chủ sở hữu domain, platform hoặc integrations, để bộ đọc import schema (và kiểu suy ra) từ đó và ánh xạ lỗi parse sang họ lỗi của chủ sở hữu; đầu vào của cửa transport dùng class-validator. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra."
|
|
1351
|
+
owner: op-retry
|
|
1352
|
+
kind: check-finding
|
|
1353
|
+
|
|
1331
1354
|
BE_SECRET_DEFAULT:
|
|
1332
1355
|
title: "Literal default for a secret or URL"
|
|
1333
1356
|
title_vi: "Giá trị mặc định cố định cho bí mật hoặc URL"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* HFS_UNUSED_EXPORT an export of an owner's public entry that no production file outside the owner imports is dead.
|
|
4
4
|
* HFS_UNUSED_FILE a production source file that no root reaches is dead. Roots: the files an app slot requires
|
|
5
5
|
* (main.ts, app.module.ts), every route file (slot tier `route`), the public entry of every package,
|
|
6
|
+
* and an api app operation table `operations.ts` (the contract emit reads it by path),
|
|
6
7
|
* and a source file a framework config of the app names by a relative string literal
|
|
7
8
|
* (next.config.ts pointing next-intl at its request config). Specs and tests are not in the graph,
|
|
8
9
|
* so a file only a spec imports is dead, on purpose. Type-only imports reach a file.
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
* Specs and tests are not in the graph, so an export used only by a spec is dead, on purpose, with these exceptions (unit test
|
|
25
26
|
* standard): the unit spec of every unit role (ruleParams.be.unitRoles: `<name>.service.spec.ts`, `<name>.cli.spec.ts`) and of every logic role
|
|
26
27
|
* (ruleParams.be.logicRoles: `<name>.policy.spec.ts`, `<name>.client.spec.ts`, ...), a
|
|
27
|
-
* unit spec of a webhook, gateway or
|
|
28
|
+
* unit spec of a webhook, gateway, subscription or SSE controller door (`.webhook.spec.ts`, `.gateway.spec.ts`, `.subscription.spec.ts`, `.controller.spec.ts`: a door has no service of its own), a
|
|
28
29
|
* `*.builder.ts` under src/tests/fixtures/builders, and a file of the test world (slots be.tests.world and be.tests.world.kit, which
|
|
29
30
|
* open and migrate the real databases of the e2e run) read from disk or staged in the git index count as consumers, because a spec can only provide an
|
|
30
31
|
* Inject*() token or a param type the entry exports, and the world wires the same capabilities the apps do. An integration
|
|
@@ -47,6 +48,8 @@ export const DEAD_EXPORT_RULE_IDS = ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'];
|
|
|
47
48
|
const PACKAGE_ENTRIES = ['src/index.ts', 'src/index.tsx', 'index.ts', 'index.tsx'];
|
|
48
49
|
const OWNER_ENTRIES = ['index.ts', 'index.tsx'];
|
|
49
50
|
const CHAIN_DEPTH = 6;
|
|
51
|
+
/** The file of an api app that the contract emit reads by path for the typed operation table (BE-OPERATIONS-1): no import reaches it, the emit does. */
|
|
52
|
+
const OPERATION_TABLE_FILE = "operations.ts";
|
|
50
53
|
/** Tiers whose files are not production source that an app serves: judged by no dead-file rule. */
|
|
51
54
|
const UNJUDGED_TIERS = new Set(['none', 'e2e', 'fixtures']);
|
|
52
55
|
const CONFIG_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx', '/index.js'];
|
|
@@ -144,7 +147,7 @@ function rootFiles(graph, config) {
|
|
|
144
147
|
if (!node.owner || node.tier !== 'app') continue;
|
|
145
148
|
const slot = graph.resolver.slot(node.owner.slot);
|
|
146
149
|
const inside = rel.slice(withoutSlash(node.owner.root).length + 1);
|
|
147
|
-
if ((slot?.requires ?? []).includes(inside)) roots.add(rel);
|
|
150
|
+
if ((slot?.requires ?? []).includes(inside) || inside === OPERATION_TABLE_FILE) roots.add(rel);
|
|
148
151
|
}
|
|
149
152
|
for (const [key, owner] of graph.ownerRoots) {
|
|
150
153
|
if (owner.tier !== 'package') continue;
|
|
@@ -177,8 +180,8 @@ function deadFiles(graph, config) {
|
|
|
177
180
|
return { violations, judged: judgedFiles.length, roots: roots.size };
|
|
178
181
|
}
|
|
179
182
|
|
|
180
|
-
/** The unit spec of a webhook, gateway or
|
|
181
|
-
const DOOR_SPEC = /^src\/features\/(?:webhooks|realtime)\/.+\.(?:webhook|gateway|subscription)\.spec\.ts$/u;
|
|
183
|
+
/** The unit spec of a webhook, gateway, subscription or SSE controller door: a door has no service of its own, so its spec is the only unit spec of its kind. */
|
|
184
|
+
const DOOR_SPEC = /^src\/features\/(?:webhooks|realtime)\/.+\.(?:webhook|gateway|subscription|controller)\.spec\.ts$/u;
|
|
182
185
|
const BUILDER_CONSUMER = /^src\/tests\/fixtures\/builders\/.+\.builder\.ts$/u;
|
|
183
186
|
/** The test world slots (knowledge/hfs/slots.yaml): their files wire the real capabilities of the e2e run. */
|
|
184
187
|
const WORLD_SLOTS = new Set(['be.tests.world', 'be.tests.world.kit']);
|
|
@@ -18,6 +18,9 @@ const DOCUMENT = /(?:^|\/)README\.md$|(?:^|\/)docs\/.+\.mdx?$/i;
|
|
|
18
18
|
const KEY = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
|
|
19
19
|
const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
20
20
|
const PLACEHOLDER = /[<>{}*$]/;
|
|
21
|
+
const ERROR_CODE_FILE = /(?:^|\/)errors\/[^/]+\.[cm]?[jt]s$|\.error\.[cm]?[jt]s$|\.messages\.[cm]?[jt]s$/;
|
|
22
|
+
const ERROR_CODE_PROSE = /\b(?:error|failure|exception)s?\s+codes?\b/i;
|
|
23
|
+
const ENV_PROSE = /\benv(?:ironment)?\b|\bvariable\b/i;
|
|
21
24
|
const PATH_TOKEN = /^(?:\.\.?\/)?[\w@.-]+(?:\/[\w@.()[\]-]+)*\/?$/;
|
|
22
25
|
let runtimeCatalogs;
|
|
23
26
|
|
|
@@ -225,8 +228,18 @@ function symbolCall(ts, node) {
|
|
|
225
228
|
return ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === 'Symbol' && callee.name.text === 'for';
|
|
226
229
|
}
|
|
227
230
|
|
|
228
|
-
|
|
231
|
+
/** A code the app declares: a string literal of an error file (enum member, kind map) or an `errors.<CODE>` message key. */
|
|
232
|
+
function declaredErrorCode(ts, node) {
|
|
233
|
+
if (!ts.isStringLiteralLike(node)) return undefined;
|
|
234
|
+
const key = /^(?:errors\.)?([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)$/.exec(node.text);
|
|
235
|
+
return key?.[1];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function declaredTokens(ts, source, tokens, file = '') {
|
|
239
|
+
const errorFile = ERROR_CODE_FILE.test(file);
|
|
229
240
|
const visit = (node) => {
|
|
241
|
+
const code = errorFile ? declaredErrorCode(ts, node) : undefined;
|
|
242
|
+
if (code) tokens.add(code);
|
|
230
243
|
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && KEY.test(node.name.text) && symbolCall(ts, node.initializer)) tokens.add(node.name.text);
|
|
231
244
|
ts.forEachChild(node, visit);
|
|
232
245
|
};
|
|
@@ -240,14 +253,15 @@ function injectionTokens(repoRoot, tracked) {
|
|
|
240
253
|
for (const file of tracked) {
|
|
241
254
|
if (!/\.[cm]?[jt]sx?$/.test(file) || /(?:(?:^|\/)(?:tests?|__tests__)\/)|(?:(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$)/.test(file)) continue;
|
|
242
255
|
const text = readText(repoRoot, file) ?? '';
|
|
243
|
-
declaredTokens(ts, ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true), tokens);
|
|
256
|
+
declaredTokens(ts, ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true), tokens, file);
|
|
244
257
|
}
|
|
245
258
|
return tokens;
|
|
246
259
|
}
|
|
247
260
|
|
|
248
261
|
function unknownEnvironmentName(name, line, keys, codes, tokens, explicit) {
|
|
249
262
|
if (!KEY.test(name) || /^R\d+$/.test(name) || codes.has(name)) return false;
|
|
250
|
-
if (!explicit && tokens.has(name) &&
|
|
263
|
+
if (!explicit && tokens.has(name) && !ENV_PROSE.test(line)) return false;
|
|
264
|
+
if (!explicit && ERROR_CODE_PROSE.test(line) && !ENV_PROSE.test(line)) return false;
|
|
251
265
|
if (!name.includes('_') && !keys.has(name) && !/\benv(?:ironment)?\b|\b(?:key|variable)s?\b/i.test(line)) return false;
|
|
252
266
|
return !keys.has(name) && !(name.endsWith('_FILE') && keys.has(name.slice(0, -5)));
|
|
253
267
|
}
|
|
@@ -388,7 +388,7 @@ function realtimeIssues(input, add) {
|
|
|
388
388
|
const component = mapping(input.doc.components)[id], app = component?.app ?? id;
|
|
389
389
|
if (!apis.has(app) || service.deploy?.replicas === 1) continue;
|
|
390
390
|
if (appComposesRealtime(input.repoRoot, app, list(input.files)))
|
|
391
|
-
add('STACKS_REALTIME_SCALED', `services.${id}.deploy.replicas`, 'A lite API composing realtime must pin deploy.replicas: 1 because its gateway
|
|
391
|
+
add('STACKS_REALTIME_SCALED', `services.${id}.deploy.replicas`, 'A lite API composing realtime must pin deploy.replicas: 1 because its realtime hub and gateway live in one API process');
|
|
392
392
|
}
|
|
393
393
|
}
|
|
394
394
|
|
|
@@ -25,6 +25,7 @@ export const JSON_FORMATS = Object.freeze([
|
|
|
25
25
|
{ id: 'keycloak-realm-import', tool: 'Keycloak realm import', match: (rel) => /(^|\/)\.starcistacks\/[^/]+\/infra\/compose\/realm-[^/]+\.json$/.test(rel) },
|
|
26
26
|
{ id: 'package-scripts-fragment', tool: 'npm (scripts rendered into package.json)', match: (rel) => /^packages\/hfs\/templates\/app\/package-scripts[^/]*\/[^/]+\.json$/.test(rel) },
|
|
27
27
|
{ id: 'async-event-contract', tool: 'starci app emit', match: (rel) => /(^|\/)be\/contracts\/[^/]+\/events(\.pin)?\.json$/.test(rel) },
|
|
28
|
+
{ id: 'operation-contract', tool: 'starci app emit', match: (rel) => /(^|\/)be\/contracts\/[^/]+\/openapi\.json$/.test(rel) },
|
|
28
29
|
{ id: 'model-scorecard-snapshot', tool: 'scripts/agent/benchmark-snapshot.mjs', match: (rel) => /^benchmark\/snapshots\/[^/]+\.json$/.test(rel) },
|
|
29
30
|
]);
|
|
30
31
|
|
package/scaffold/app.mjs
CHANGED
|
@@ -157,7 +157,7 @@ const STARTER_DEPENDENCIES = Object.freeze({
|
|
|
157
157
|
dependencies: {
|
|
158
158
|
'@apollo/server': null, '@nestjs/apollo': null, '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null, '@nestjs/graphql': null,
|
|
159
159
|
'@nestjs/platform-express': null, '@nestjs/typeorm': null, 'nest-commander': null, pg: null, graphql: null,
|
|
160
|
-
'class-transformer': null, 'class-validator': null, 'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null,
|
|
160
|
+
'class-transformer': null, 'class-validator': null, 'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, zod: null,
|
|
161
161
|
},
|
|
162
162
|
devDependencies: {
|
|
163
163
|
turbo: null, '@nestjs/testing': null, '@starci/cli': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null, '@starci/jest-preset': null,
|
|
@@ -178,7 +178,7 @@ const LITE_STARTER_DEPENDENCIES = Object.freeze({
|
|
|
178
178
|
'@apollo/server': null, '@nestjs/apollo': null, '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null, '@nestjs/graphql': null,
|
|
179
179
|
'@nestjs/platform-express': null, '@nestjs/typeorm': null, '@supabase/supabase-js': null,
|
|
180
180
|
'class-transformer': null, 'class-validator': null, graphql: null, jose: null, pg: null,
|
|
181
|
-
'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null,
|
|
181
|
+
'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, zod: null,
|
|
182
182
|
},
|
|
183
183
|
devDependencies: {
|
|
184
184
|
turbo: null, '@nestjs/testing': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null, '@starci/cli': null,
|
|
@@ -303,10 +303,11 @@ export function peerDependencies(pkg, pins, pairs = parseYaml(fs.readFileSync(PE
|
|
|
303
303
|
/**
|
|
304
304
|
* The fe-stack core packages one version of which the whole install resolves (npm `overrides`, at their canon pins): a
|
|
305
305
|
* transitive dependency otherwise brings its own (zustand 4 under @xyflow/react, dayjs under typeorm, a hoisted newer zod
|
|
306
|
-
* that breaks the zodResolver types), and @xyflow/react itself stays the grammar's one version.
|
|
307
|
-
*
|
|
306
|
+
* that breaks the zodResolver types), and @xyflow/react itself stays the grammar's one version. Two are security overrides, each dropped when
|
|
307
|
+
* upstream ships the fix (canon-pins.yaml says why): @graphql-tools/utils (@nestjs/graphql 13.x pulls <=12.0.0, GHSA-7mx3-vvmw-hjmv) and multer
|
|
308
|
+
* (@nestjs/platform-express depends on exactly 2.4.0, GHSA-q7qp-rjg7-q26m).
|
|
308
309
|
*/
|
|
309
|
-
const ROOT_OVERRIDES = Object.freeze(['@graphql-tools/utils', '@xyflow/react', 'dayjs', 'zod', 'zustand']);
|
|
310
|
+
const ROOT_OVERRIDES = Object.freeze(['@graphql-tools/utils', '@xyflow/react', 'dayjs', 'multer', 'zod', 'zustand']);
|
|
310
311
|
|
|
311
312
|
/**
|
|
312
313
|
* The root package.json of a new app, the monorepo root: name, workspaces, npm, its edition's dependencies with their runtime
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { METHOD_METADATA, PATH_METADATA, SSE_METADATA } from "@nestjs/common/constants"
|
|
2
|
+
import type { MessageEvent } from "@nestjs/common"
|
|
3
|
+
import { RequestMethod } from "@nestjs/common"
|
|
4
|
+
import { Test } from "@nestjs/testing"
|
|
5
|
+
import { Observable } from "rxjs"
|
|
6
|
+
import { @@service@@ } from "@@serviceModule@@"
|
|
7
|
+
import type { Principal } from "@modules/platform/cqrs"
|
|
8
|
+
import { REALTIME_HUB } from "@modules/platform/realtime"
|
|
9
|
+
import type { RealtimeHub, RealtimeSubscribeOptions, RealtimeTopic } from "@modules/platform/realtime"
|
|
10
|
+
import { @@Channel@@Controller } from "./@@channel@@.controller"
|
|
11
|
+
|
|
12
|
+
const principal: Principal = { id: "p-1", role: "authenticated" }
|
|
13
|
+
/** One event of the channel: replace the data with the real frame; its `id` is the domain event id. */
|
|
14
|
+
const EVENT: MessageEvent = { id: "x-1", data: { id: "x-1" } }
|
|
15
|
+
|
|
16
|
+
/** A recording hub whose event stream yields one event and records the topic, the resume point and the release. */
|
|
17
|
+
const hubAnswering = () => {
|
|
18
|
+
const streamed: Array<{ readonly topic: string; readonly after: string | undefined }> = []
|
|
19
|
+
const released: Array<string> = []
|
|
20
|
+
const hub: RealtimeHub = {
|
|
21
|
+
publish: () => undefined,
|
|
22
|
+
subscribe: () => ({ [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ done: true, value: undefined }) }) }),
|
|
23
|
+
subscriberCount: () => 0,
|
|
24
|
+
stream: <T extends { readonly id: string }>(topic: RealtimeTopic<T>, options?: RealtimeSubscribeOptions) => {
|
|
25
|
+
streamed.push({ topic: topic.name, after: options?.after })
|
|
26
|
+
return new Observable<MessageEvent>((subscriber) => {
|
|
27
|
+
subscriber.next(EVENT)
|
|
28
|
+
return () => {
|
|
29
|
+
released.push(topic.name)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
return { hub, streamed, released }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const build = async (hub: RealtimeHub) => {
|
|
38
|
+
const moduleRef = await Test.createTestingModule({
|
|
39
|
+
controllers: [@@Channel@@Controller],
|
|
40
|
+
providers: [{ provide: REALTIME_HUB, useValue: hub }],
|
|
41
|
+
}).compile()
|
|
42
|
+
return moduleRef.get(@@Channel@@Controller)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("@@Channel@@Controller", () => {
|
|
46
|
+
it("streams the topic built only from the authenticated principal", async () => {
|
|
47
|
+
const { hub, streamed } = hubAnswering()
|
|
48
|
+
const door = await build(hub)
|
|
49
|
+
|
|
50
|
+
door.stream(principal, undefined).subscribe().unsubscribe()
|
|
51
|
+
|
|
52
|
+
expect(streamed).toEqual([{ topic: @@service@@(principal.id).name, after: undefined }])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("isolates another authenticated principal's topic", async () => {
|
|
56
|
+
const { hub, streamed } = hubAnswering()
|
|
57
|
+
const door = await build(hub)
|
|
58
|
+
|
|
59
|
+
door.stream({ id: "p-2", role: "authenticated" }, undefined).subscribe().unsubscribe()
|
|
60
|
+
|
|
61
|
+
expect(streamed.map((entry) => entry.topic)).toEqual([@@service@@("p-2").name])
|
|
62
|
+
expect(streamed.map((entry) => entry.topic)).not.toContain(@@service@@(principal.id).name)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("resumes after the Last-Event-ID inside the caller's own topic", async () => {
|
|
66
|
+
const { hub, streamed } = hubAnswering()
|
|
67
|
+
const door = await build(hub)
|
|
68
|
+
|
|
69
|
+
door.stream(principal, "x-0").subscribe().unsubscribe()
|
|
70
|
+
|
|
71
|
+
expect(streamed).toEqual([{ topic: @@service@@(principal.id).name, after: "x-0" }])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("hands the events of the hub to the response as they are", async () => {
|
|
75
|
+
const { hub } = hubAnswering()
|
|
76
|
+
const door = await build(hub)
|
|
77
|
+
const received: Array<MessageEvent> = []
|
|
78
|
+
|
|
79
|
+
door.stream(principal, undefined).subscribe((event) => received.push(event)).unsubscribe()
|
|
80
|
+
|
|
81
|
+
expect(received).toEqual([EVENT])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("releases the hub stream when the client disconnects", async () => {
|
|
85
|
+
const { hub, released } = hubAnswering()
|
|
86
|
+
const door = await build(hub)
|
|
87
|
+
|
|
88
|
+
door.stream(principal, undefined).subscribe().unsubscribe()
|
|
89
|
+
|
|
90
|
+
expect(released).toEqual([@@service@@(principal.id).name])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("is one GET event stream with no other handler", async () => {
|
|
94
|
+
const { hub } = hubAnswering()
|
|
95
|
+
const door = await build(hub)
|
|
96
|
+
|
|
97
|
+
expect(Reflect.getMetadata(SSE_METADATA, door.stream)).toBe(true)
|
|
98
|
+
expect(Reflect.getMetadata(METHOD_METADATA, door.stream)).toBe(RequestMethod.GET)
|
|
99
|
+
expect(Reflect.getMetadata(PATH_METADATA, door.stream)).toBe("stream")
|
|
100
|
+
expect(Reflect.getMetadata(PATH_METADATA, @@Channel@@Controller)).toBe("@@channel@@")
|
|
101
|
+
expect(Object.getOwnPropertyNames(@@Channel@@Controller.prototype)).toEqual(["constructor", "stream"])
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Controller, Headers, Sse } from "@nestjs/common"
|
|
2
|
+
import type { MessageEvent } from "@nestjs/common"
|
|
3
|
+
import type { Observable } from "rxjs"
|
|
4
|
+
import { @@service@@ } from "@@serviceModule@@"
|
|
5
|
+
import { CurrentPrincipal } from "@modules/domain/identity"
|
|
6
|
+
import type { Principal } from "@modules/platform/cqrs"
|
|
7
|
+
import { InjectRealtimeHub } from "@modules/platform/realtime"
|
|
8
|
+
import type { RealtimeHub } from "@modules/platform/realtime"
|
|
9
|
+
|
|
10
|
+
@Controller("@@channel@@")
|
|
11
|
+
/** The push-only @@channel@@ door: the default guards authenticate the stream, and it writes nothing. */
|
|
12
|
+
export class @@Channel@@Controller {
|
|
13
|
+
constructor(@InjectRealtimeHub() private readonly hub: RealtimeHub) {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The changes of the caller's @@channel@@, as server-sent events. The domain contract builds the principal-wide topic and the
|
|
17
|
+
* reactor publishes through the same builder; a `Last-Event-ID` header only resumes after that frame inside the caller's own topic.
|
|
18
|
+
*/
|
|
19
|
+
@Sse("stream")
|
|
20
|
+
stream(@CurrentPrincipal() principal: Principal, @Headers("last-event-id") lastEventId?: string): Observable<MessageEvent> {
|
|
21
|
+
return this.hub.stream(@@service@@(principal.id), { after: lastEventId })
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { ConfigurableModuleBuilder } from "@nestjs/common"
|
|
2
|
+
import type { @@Channel@@SseOptions } from "./@@channel@@-sse.options"
|
|
3
|
+
|
|
4
|
+
/** The configurable-module base of the @@channel@@-sse module. */
|
|
5
|
+
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } =
|
|
6
|
+
new ConfigurableModuleBuilder<@@Channel@@SseOptions>().setClassMethodName("register").build()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Module } from "@nestjs/common"
|
|
2
|
+
import { ConfigurableModuleClass } from "./@@channel@@-sse.module-definition"
|
|
3
|
+
import { @@Channel@@Controller } from "./@@channel@@.controller"
|
|
4
|
+
|
|
5
|
+
@Module({ controllers: [@@Channel@@Controller] })
|
|
6
|
+
/** The server-sent-events transport of the @@channel@@ channel: one authenticated stream per principal. */
|
|
7
|
+
export class @@Channel@@SseModule extends ConfigurableModuleClass {}
|