@starci/hfs 4.7.30 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 4.7.30 - 2026-10-24
4
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.
5
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.30",
3
+ "version": "4.7.31",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.38
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.37
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.30
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.30
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: fe
342
- why: 'the schema library of front-end forms and boundary parsing (fe-stack forms), 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.'
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
@@ -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 and `<x>.subscription.ts` holds one `@Resolver` class with `@Subscription` operations, and neither carries a query, a mutation, a field resolver, a REST route or the other protocol. A lite app serves the same 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)."
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 uses the same socket.io gateway inside the be api process (one instance, no Redis adapter), 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."
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.5.0
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 on the same socket.io gateway as full (owner ruling 2026-10-13): lite runs the gateway inside the be api
1451
- process, one instance and no Redis adapter; the front end listens through hooks/socketio
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 uses only the in-process socket.io gateway; the GraphQL subscription door remains full-only."
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/websocket/ (be.feature.realtime.websocket): both editions push through the socket.io gateway; lite runs it in the api process"
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/websocket/ (be.feature.realtime.websocket): both editions push through the socket.io gateway; lite runs it in the api process"
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__) and the axios calls (rest/<area>/*.ts) for uploads, presigned URLs and auth endpoints. A repository has
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`, which exports `OPERATIONS`. The table is made by
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
- // be/apps/core/src/operations.ts
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(@Body() request: OperationRequest<typeof OPERATIONS>): Promise<OperationReply<typeof OPERATIONS>> { ... }
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
- relatedExamples: [api]
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 socket gateway (`<channel>.gateway.ts`) or a GraphQL
7
- subscription (`<x>.subscription.ts`), that READS and PUSHES only. It never writes. The push is fed by a reactor: an event
8
- consumer in `features/reactors/` receives the domain event and publishes to a topic of the `RealtimeHub` of `platform/realtime`;
9
- the door only subscribes the client to a topic of that hub, scoped to the client's principal.
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. The kind imports only `modules/*`, never another kind, and only an api-kind app composes its module.
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 async
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). No `EntityManager`, `CommandBus`, `QueryBus`, event bus, queue or
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; the socket library and the pub/sub engine (`socket.io`, `graphql-subscriptions`, `graphql-ws`)
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 `@Subscription` handler takes `@CurrentPrincipal()` of `domain/identity` and builds the topic it
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 or GraphQL-over-websocket client of the test world. The unit spec of the door covers its wiring with the Nest testing
140
- module and a recording hub double.
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 socket.io gateway of be.feature.realtime.websocket
236
- (transport/websocket/<channel>.gateway.ts) runs inside the be api process, one instance with the default in-memory adapter
237
- and no Redis adapter. A reactor publishes to the RealtimeHub of platform/realtime, which emits to the principal-scoped room
238
- of the namespace; the front end listens through hooks/socketio (knowledge/patterns/fe/realtime.yaml). graphql-ws
239
- subscriptions are not used in lite.
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 socket.io server of the api process emits to the namespace room of the topic (no Redis adapter: one instance)"}
242
- - {port: RealtimeHub.subscribe, adapter: "the gateway's @SubscribeMessage handler joins the room built from the principal, as full"}
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 gateway's own unit spec (<channel>.gateway.spec.ts) and the hub adapter's spec, as full; declaring realtime requires
247
- integration specs titled `realtime/<scenario>: ...` for every declared scenario on `starci supabase start` (R284), using a
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
- 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 `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."
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 subscription door (`.webhook.spec.ts`, `.gateway.spec.ts`, `.subscription.spec.ts`: a door has no service of its own), a
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 subscription door: a door has no service of its own, so its spec is the only unit spec of its kind. */
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
- function declaredTokens(ts, source, tokens) {
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) && !/\benv(?:ironment)?\b|\bvariable\b/i.test(line)) return false;
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 runs in one API process');
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,
@@ -7,5 +7,8 @@
7
7
  }
8
8
 
9
9
  api.{{project}}.example.invalid {
10
- reverse_proxy api:3001
10
+ reverse_proxy api:3001 {
11
+ # server-sent events: flush every write at once; never add `encode` to this route (no compression of text/event-stream)
12
+ flush_interval -1
13
+ }
11
14
  }
@@ -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 {}
@@ -0,0 +1,2 @@
1
+ /** The @@channel@@ SSE door takes no options: the app's default-deny guards authenticate the stream and the hub is global. */
2
+ export type @@Channel@@SseOptions = Record<string, never>