@starci/eslint-canon-be 3.4.38 → 3.4.40

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.40 - 2026-10-25
4
+ - Changed: the bundled canon pins select @starci/grammar 0.11.0, @starci/hfs 4.7.32 and @starci/cli 1.7.32.
5
+
6
+ ## 3.4.39 - 2026-10-24
7
+ - Changed: the operation-route rule refuses a body no `OperationValidationService` validates, and the bundled canon text matches the lint (array bounds, shared throttler storage, pagination home, the operation table shape).
8
+ - New: the realtime lint recognises the SSE door (`be.feature.realtime.sse`, a hub-only `@Sse` controller under `transport/sse`); the bundled slot manifest and rules carry the amended R212.
9
+ - New: `schema-place` (R304 `BE_SCHEMA_PLACE`: `zod` is imported only from a `<name>.schema.ts` of a domain, platform or integrations owner; never in a feature, a transport door or a `dto/` folder; a schema file imports no `class-validator`, `class-transformer` or `@nestjs/graphql`) and `schema-bounded` (R305 `BE_SCHEMA_BOUNDED`: every `z.string()`, array, set, record and map has an upper bound, and `z.any()`, `z.unknown()` and `z.json()` are refused).
10
+ - Changed: the bundled slot manifest declares the `schema` role suffix and the `zod` pin is `side: both`.
11
+
3
12
  ## 3.4.38 - 2026-10-24
4
13
  - Changed: the bundled canon pins add the multer 2.5.0 security override and select @starci/hfs 4.7.30 and @starci/cli 1.7.30.
5
14
 
package/README.md CHANGED
@@ -53,13 +53,14 @@ A sample, not the list:
53
53
  | **Log safety** | A call on the `Logger` port (found by the receiver's type) passes no `Secret` or `Pii` value and no value named like a credential or a personal identifier (`no-secret-in-log`); a constructed error (its type derives from `Error`) carries no `Secret` value and no text value named like a credential (`no-secret-in-error`) |
54
54
  | **Async and types** | An `async` function awaits; handlers and public methods declare a return type; no `Function` type, no `eval`; an inline object type takes a name; no `const enum`. Casts, `x!` and `any` are refused in every file, specs included, by the typescript-eslint rules the factory turns on (`async-needs-await`, `explicit-handler-return-type`, `no-function-or-eval`, `no-inline-param-type`, `no-inline-object-type`, `no-const-enum`) |
55
55
  | **Migrations and input** | A migration's `down()` reverses its `up()`; every property of an input class has the `class-validator` decorators its type calls for (string `@MaxLength`, enum `@IsEnum`, array `@ArrayMaxSize`, nested `@ValidateNested()` and `@Type`); reads page by cursor, never by `skip`, `offset` or `OFFSET`; a door opened for `PublicReason.AuthHandshake` or `PublicReason.SignedWebhook` carries `RateTier.Strict` of `platform/http-security` on the method or its class (`migration-down-reversible`, `input-bounded`, `no-offset-pagination`, `auth-door-strict-rate-tier`) |
56
+ | **Boundary schema** (R304, R305) | `zod` reads only boundary data that never reaches a DTO (a provider response, model output, a queue or outbox payload read back): it is imported only from a `<name>.schema.ts` of a domain, platform or integrations owner, never in a feature, a transport door or a `dto/` folder, and a schema file imports no `class-validator`, `class-transformer` or `@nestjs/graphql` (`schema-place`); every string, array, set, record and map of a schema has an upper bound and `z.any()`, `z.unknown()` and `z.json()` do not exist (`schema-bounded`) |
56
57
  | **Spec quality** | A spec does not read repository source with `fs` (`spec-no-source-read`); the borrowed `consistent-type-assertions` (`never`) and `no-non-null-assertion` leave no `as` and no `x!` in a spec, and `no-ambient-clock` and `no-direct-env-read` cover specs, so no `Date.now()`, argless `new Date()` or `process.env`. A unit spec is `<name>.service.spec.ts`: it builds its subject with `Test.createTestingModule({ providers })` from `@nestjs/testing`, `.compile()` and `moduleRef.get(...)` (`spec-builds-with-testing-module`), never `new` on the service (`spec-no-new-subject`), no `imports` key and no `override*` (`spec-module-definition-only-providers`), no `jest.mock`/`doMock`/`unstable_mockModule`/`requireMock` of an own module or a library (`spec-no-module-mock`), no cast-laundering helper in a spec or fixture: a return-only generic, an `unknown` handed back as another type, `JSON.parse(JSON.stringify(x))`, `Object.assign(new X(), y)` returned as another type (`no-return-only-generic`); a provider `{ provide: TOKEN, useValue: X }` takes X from the matching `@starci/jest-preset` double, the token table being `ruleParams.be.specDoubles` of the slot manifest (`spec-infra-double-from-kit`); a unit spec asserts exact values, never `expect.any(String)`, `expect.any(Number)` or `expect.any(Date)` (`expect` being the jest global or the `@jest/globals` import): ids come from `fakeIds()` and dates from `FakeClock` (`spec-exact-values`); the EntityManager double is the kit's `mockEntityManager()`/`fakeTransaction()`, never ad hoc (`spec-typed-entity-manager`); test data goes through builders, see the **Test data builders** row |
57
58
  | **Test data builders** (R98 to R101) | Builders live only in `src/tests/fixtures/builders/<area>.builder.ts`: a module of the test tree that exports a function creating persisted rows through an EntityManager, DataSource or QueryRunner is a builder wherever it sits (`persisting-builder-in-builders-slot`, R98); a spec never runs a raw INSERT/UPDATE/DELETE or a direct write (`save`, `insert`, `upsert`, `update`, `delete`, `remove`) on a typeorm receiver and never builds the same `@Entity` class as an object literal twice (`spec-no-raw-insert`, `spec-no-repeated-row-literal`, R99); a builder never asserts and has deterministic defaults (`builder-arranges-only`, R101); nothing under `src/tests` disables or drops a constraint (`tests-keep-constraints`, R100) |
58
59
  | **CQRS** | A message extends `Command<R>` / `Query<R>` from `@nestjs/cqrs`, carries one `readonly params: ExecuteParams<X>` (no entity reachable from `X`), a handler extends `ICQRSHandler` and overrides `process`; a handler is thin: it injects only `*Service` classes and the Logger, and `process` is one `return this.<service>.<method>(...)` with no branch, loop, EntityManager or second call (`handler-is-thin`); no `*.use-case.ts`, no forwarder service, no `EventBus`, `@nestjs/event-emitter`, `EventEmitter` or RxJS `Subject` constructed or extended, and no class that stores callbacks other code registers (`handler-overrides-process`, `message-carries-params-only`, `message-typed-result`, `execute-params-shape`, `no-use-case`, `no-event-bus`) |
59
60
  | **Module layering** | Another owner is imported through its public `index.ts` (`@modules/domain/plan`, `@features/api/plan`), never through a path into it, and a same-owner import is relative and never through the own index (`import-owner-entry`); an owner has one `index.ts` (a nested one is refused) that holds only named `export { }` lines, with no `export *`, and a bounded number of names (`no-folder-reexport`); self-aliases and relative escapes are refused (`no-self-module-alias`, `no-relative-capability-escape`). Owners come from the HFS slots, aliases from the program's `compilerOptions.paths`. |
60
61
  | **Data access** | The database is reached through the shared `EntityManager` only, received as a constructor parameter with the named injector of a declared connection - a lookalike name, property injection, an injected `DataSource` or `QueryRunner` outside `platform/database` and the cli (`apps/cli`, `src/features/cli/`), `getRepository` and repository types are refused, specs included (`must-inject-entity-manager`, `named-entity-manager-only`, `no-injected-repository`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner` whatever it is called, and no exported function takes an `EntityManager` first (a statement module); `src/tests` is exempt (`no-repository-class`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner`, whatever it is called, and no exported function takes an `EntityManager` first (a statement module); the test tree (`src/tests`) is exempt (`no-repository-class`); inside a transaction callback only the manager it received is used (`no-outer-manager-in-transaction`); no call through an integration, `HttpClient` or `MessagePublisher` typed receiver spans a transaction (`no-external-call-in-transaction`); the manager is injected only in application handlers, domain services and the platform database, inbox, outbox and lease capabilities (`em-injection-slots`) |
61
62
  | **Connections** | One database is one connection and one `Inject<Conn>EntityManager()` injector: `InjectEntityManager(`, `InjectDataSource(` and `getEntityManagerToken(` are called only in `platform/database/<conn>.decorators.ts`, the cli and the test world; an exported EntityManager injector is exactly the declared connection's; `TypeOrmModule.forRoot*` and `new DataSource(` live in the platform database capability and the cli; the connection name is written once, in its `<conn>.connection.ts`; a `<conn>.config.ts` reads only keys of its `envPrefix` (`one-connection-per-database`) |
62
- | **Operations** | A versioned operation is declared once in the app's typed operation table (`query<Input, Output, RefusalCode>()` / `mutation<...>()` registered by `defineOperations`, declared by the platform capability `operations`): its input and output are closed types (no `any`, `unknown`, `Record<string, unknown>`, unbound generic, function or `Date`) and its refusal codes a closed union of string literals; a route that takes or answers the table's types takes `OperationRequest<Table>` and answers `Promise<OperationReply<Table>>` of one table (`operation-contract-decidable`, `operation-route-driven-by-table`) |
63
+ | **Operations** | A versioned operation is declared once in the app's typed operation table (`query<Input, Output, RefusalCode>()` / `mutation<...>()` registered by `defineOperations`, declared by the platform capability `operations`): its input and output are closed types (no `any`, `unknown`, `Record<string, unknown>`, unbound generic, function or `Date`) and its refusal codes a closed union of string literals; a route that takes or answers the table's types takes `OperationRequest<Table>`, binds that body to `new OperationValidationService(RequestClass)` of `platform/http-security` and answers `Promise<OperationReply<Table>>` of one table (`operation-contract-decidable`, `operation-route-driven-by-table`) |
63
64
  | **Webhooks and realtime** | A provider webhook door injects only the `WebhookSignatureService` and domain services, verifies first (awaited when async), and hands the POST delivery to exactly one domain intake that records the inbox receipt and outbox event; a domain method may combine proof and intake. The POST returns nothing or the own-feature exported literal `PROVIDER_ACK`. One optional GET challenge handler returns only its declared domain verifier's result. The door and HTTP module import or inject no `CommandBus`, `QueryBus` or feature command handler, and its resolved domain intake dispatches no command/query bus or feature handler; a reactor or job dispatches after commit (`webhook-shape`, `webhook-verify-first`, `feature-thin`). A realtime door is one gateway or subscription that injects only the `RealtimeHub` and subscribes to a topic built from its `@CurrentPrincipal()` (`realtime-shape`, `realtime-read-only`, `realtime-topic-scope`) |
64
65
  | **Transport** | A door injects only `CommandBus` / `QueryBus` (and `RequestLocale` in REST) and dispatches exactly one message per handler, holding no branch, loop, EntityManager or call other than the bus, and pure `*.mapper.ts` functions and `unwrapOutcome` of `platform/primitives` (`transport-is-thin`); no response envelope, no `GraphQLJSON`, no `unknown` body, no `switch (input.operation)`; a REST door lives in `transport/http` and shows a public reason, a byte stream or a redirect; a capability never imports a feature (`transport-is-thin`, `no-response-envelope`, `no-graphql-json`, `door-lives-in-features`, `rest-door-needs-a-reason`, `no-capability-imports-features`) |
65
66
  | **Feature thin** (R203 BE_FEATURE_THIN) | A feature-tier file of a thin role (`<name>.handler.ts`, `.resolver.ts`, `.controller.ts`, `.consumer.ts`, `.processor.ts`, `.step.ts`, `.saga.ts`, `.saga-step.ts`, `.compensation.ts`, `.webhook.ts`, `.gateway.ts`, `.subscription.ts`, `.cli.ts`, `.mapper.ts`, `.service.ts`) maps its parameters and makes exactly one delegating call into the modules - an injected member, a bus `execute` or a modules function in statement position - with no branch, loop, `try`, `throw` or computation; free calls are pure `*.mapper.ts` functions, the `Logger` port and a webhook's first verification call when followed by one separate intake (a combined domain verification and intake call counts as the delegation). A webhook POST may return its declared literal `PROVIDER_ACK`; its optional GET challenge has one domain verifier call and echoes only that result. Webhook doors and their HTTP modules reach no command/query bus or feature command handler; orchestration (step order, compensation, claim, fencing, retry) belongs to the platform (`feature-thin`) |
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The rules that keep `zod` to the boundary of untrusted data and keep every schema bounded (catalog R304 `BE_SCHEMA_PLACE`,
3
+ * R305 `BE_SCHEMA_BOUNDED`, BE-TYPING-8).
4
+ *
5
+ * The back end validates by zone. A transport door (GraphQL `@InputType`, REST request class) is validated by
6
+ * `class-validator` (R42). Data that never reaches a DTO is read through a `zod` schema: a provider's HTTP response, a tool
7
+ * argument, structured model output, a queue or outbox payload. The two territories are kept disjoint by lint, never by memory.
8
+ *
9
+ * - `schema-place` (R304) holds the territory. `zod` is imported only from a `<name>.schema.ts` file (or its spec) that sits
10
+ * in a `domain`, `platform` or `integrations` owner; never in a feature (a transport door answers to `class-validator`), never
11
+ * in a `dto/` folder, and a schema file does not also carry `class-validator` or a GraphQL type.
12
+ * - `schema-bounded` (R305) holds the cost. Every string, array, set, map and record a schema builds has an upper bound
13
+ * (`.max(n)`, `.length(n)`, `.size(n)`, or for a record and a map a `.refine`/`.check` on its entry count), and `z.any()`,
14
+ * `z.unknown()` and `z.json()` are refused: an unbounded array whose elements all fail validation allocates an issue each
15
+ * (GHSA-mrhx-c7xc-m3w5), and an unbounded string is an unbounded cost whatever the zod version.
16
+ */
17
+ import { hfsOf } from "./lib/hfs.mjs"
18
+ import { normalizePath } from "./lib/path.mjs"
19
+ import { isTransportSlot } from "./lib/transport-slots.mjs"
20
+
21
+ const ZOD = /^zod(?:\/|$)/
22
+ /** The imports of the other validation layer, which never share a file with a schema. */
23
+ const MIXED = new Set(["class-validator", "class-transformer", "@nestjs/graphql"])
24
+ const SCHEMA_FILE = /\.schema(?:\.spec)?\.ts$/
25
+ /** Tiers a schema file may live in: the owners of boundary data. */
26
+ const SCHEMA_TIERS = new Set(["domain", "platform", "integrations"])
27
+
28
+ /** Constructors that build a string-valued schema and owe a `.max(n)` or `.length(n)`. */
29
+ const STRINGY = new Set(["string", "email", "url", "httpUrl", "hostname", "base64", "base64url", "jwt", "emoji", "hex"])
30
+ /** Constructors that build a counted container: bounded by `.max(n)`, `.length(n)` or `.size(n)`. */
31
+ const COUNTED = new Set(["array", "set"])
32
+ /** Constructors whose entry count only a refinement can bound. */
33
+ const REFINED = new Set(["record", "looseRecord", "partialRecord", "map"])
34
+ /** Constructors that accept anything: refused outright. */
35
+ const OPEN = new Set(["any", "unknown", "json"])
36
+ /** The chain members that bound a string or a counted container. */
37
+ const BOUNDING = new Set(["max", "length", "size"])
38
+ const REFINING = new Set(["refine", "superRefine", "check"])
39
+
40
+ /** The member names a `z.coerce.string()` style callee passes through before its constructor. */
41
+ const PASS_THROUGH = new Set(["coerce"])
42
+
43
+ /** True for a bound argument that is not a way to say "no bound": absent, `Infinity`, `NaN`, a non-positive literal, `Number.MAX_*`. */
44
+ const isRealBound = (argument) => {
45
+ if (argument === undefined) return false
46
+ if (argument.type === "Identifier") return argument.name !== "Infinity" && argument.name !== "NaN" && argument.name !== "undefined"
47
+ if (argument.type === "Literal") return typeof argument.value === "number" && Number.isFinite(argument.value) && argument.value > 0
48
+ if (argument.type === "MemberExpression" && !argument.computed && argument.object.type === "Identifier" && argument.object.name === "Number") {
49
+ return !/^(?:MAX_|POSITIVE_INFINITY)/.test(argument.property.name ?? "")
50
+ }
51
+ return true
52
+ }
53
+
54
+ /** The methods chained on `node` (`z.string().trim().max(5)` gives `trim`, `max` with their calls), outermost last. */
55
+ const chainOf = (node) => {
56
+ const calls = []
57
+ let current = node
58
+ for (;;) {
59
+ const member = current.parent
60
+ if (member?.type !== "MemberExpression" || member.object !== current || member.computed || member.property.type !== "Identifier") break
61
+ const call = member.parent
62
+ if (call?.type !== "CallExpression" || call.callee !== member) break
63
+ calls.push({ name: member.property.name, call })
64
+ current = call
65
+ }
66
+ return calls
67
+ }
68
+
69
+ /** Resolves a callee to the zod constructor it names, or null: `z.string`, `z.coerce.string`, or a named import `string`. */
70
+ const constructorOf = (callee, namespaces, named) => {
71
+ if (callee.type === "Identifier") return named.get(callee.name) ?? null
72
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return null
73
+ let object = callee.object
74
+ while (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier" && PASS_THROUGH.has(object.property.name)) object = object.object
75
+ return object.type === "Identifier" && namespaces.has(object.name) ? callee.property.name : null
76
+ }
77
+
78
+ /** Every schema built by this file is bounded and none accepts anything. */
79
+ export const schemaBounded = {
80
+ meta: {
81
+ type: "problem",
82
+ docs: { description: "Every zod string, array, set, map and record has an upper bound; `z.any()`, `z.unknown()` and `z.json()` are refused." },
83
+ schema: [],
84
+ messages: {
85
+ string: "`z.{{name}}()` has no `.max(n)` or `.length(n)`, so a string of any length is accepted. Bound it: `z.{{name}}().max(200)`.",
86
+ counted: "`z.{{name}}(...)` has no `.max(n)`, `.length(n)` or `.size(n)`, so a collection of any count is accepted (and every failing element allocates an issue). Bound it: `z.{{name}}(item).max(100)`.",
87
+ refined: "`z.{{name}}(...)` has no bound on its entry count. Add `.refine((entries) => Object.keys(entries).length <= N)` (a `Map`: `.size`), or key it by a `z.enum` so its size is closed.",
88
+ open: "`z.{{name}}()` accepts any value, so nothing is validated. Describe the shape: an object of bounded fields, or a bounded union.",
89
+ bound: "`.{{name}}()` here is no bound (a missing, non-positive or infinite argument). Give a finite positive count.",
90
+ },
91
+ },
92
+ create(context) {
93
+ const namespaces = new Set()
94
+ const named = new Map()
95
+ return {
96
+ ImportDeclaration(node) {
97
+ if (typeof node.source.value !== "string" || !ZOD.test(node.source.value)) return
98
+ for (const specifier of node.specifiers) {
99
+ if (specifier.type === "ImportNamespaceSpecifier") namespaces.add(specifier.local.name)
100
+ else if (specifier.type === "ImportSpecifier") {
101
+ const imported = specifier.imported.type === "Identifier" ? specifier.imported.name : String(specifier.imported.value)
102
+ if (imported === "z") namespaces.add(specifier.local.name)
103
+ else named.set(specifier.local.name, imported)
104
+ } else if (specifier.type === "ImportDefaultSpecifier") namespaces.add(specifier.local.name)
105
+ }
106
+ },
107
+ CallExpression(node) {
108
+ const name = constructorOf(node.callee, namespaces, named)
109
+ if (name === null) return
110
+ if (OPEN.has(name)) {
111
+ context.report({ node, messageId: "open", data: { name } })
112
+ return
113
+ }
114
+ const kind = STRINGY.has(name) ? "string" : COUNTED.has(name) ? "counted" : REFINED.has(name) ? "refined" : null
115
+ if (kind === null) return
116
+ const chain = chainOf(node)
117
+ if (kind === "refined") {
118
+ if (!chain.some((entry) => REFINING.has(entry.name))) context.report({ node, messageId: "refined", data: { name } })
119
+ return
120
+ }
121
+ const bounds = chain.filter((entry) => BOUNDING.has(entry.name))
122
+ if (bounds.length === 0) {
123
+ context.report({ node, messageId: kind, data: { name } })
124
+ return
125
+ }
126
+ if (!bounds.some((entry) => isRealBound(entry.call.arguments[0]))) context.report({ node: bounds[0].call, messageId: "bound", data: { name: bounds[0].name } })
127
+ },
128
+ }
129
+ },
130
+ }
131
+
132
+ /** The module specifiers a file references, with the node that names each (imports, re-exports, `import()` and `require()`). */
133
+ const referenceNodes = (node) => {
134
+ switch (node.type) {
135
+ case "ImportDeclaration":
136
+ case "ExportAllDeclaration":
137
+ case "ExportNamedDeclaration":
138
+ return node.source ? [node.source] : []
139
+ case "ImportExpression":
140
+ return [node.source]
141
+ case "CallExpression":
142
+ return node.callee.type === "Identifier" && node.callee.name === "require" && node.arguments[0] ? [node.arguments[0]] : []
143
+ default:
144
+ return []
145
+ }
146
+ }
147
+
148
+ const referencesZod = (node) => referenceNodes(node).filter((source) => source.type === "Literal" && typeof source.value === "string" && ZOD.test(source.value))
149
+
150
+ /** Where a schema may live, and what shares a file with it. */
151
+ export const schemaPlace = {
152
+ meta: {
153
+ type: "problem",
154
+ docs: { description: "`zod` is imported only from a `*.schema.ts` file of a domain, platform or integrations owner, never in a transport door or beside a DTO decorator." },
155
+ schema: [],
156
+ messages: {
157
+ door: "`zod` is imported in a transport door or a `dto/` folder. A door's input is validated by `class-validator` (R42); read untrusted data that is not a DTO through a `<name>.schema.ts` of the owner instead.",
158
+ tier: "`zod` is imported in a {{tier}} file. Schemas belong to the owners of boundary data: `modules/domain`, `modules/platform` or `modules/integrations`. A feature calls the owner, which parses.",
159
+ file: "`zod` is imported outside a schema file. Declare the schema in `<name>.schema.ts` beside its reader and import the schema (and its inferred type) from there.",
160
+ mixed: "This schema file also imports `{{source}}`. A schema file holds zod only; the two validation layers never share a file.",
161
+ },
162
+ },
163
+ create(context) {
164
+ const hfs = hfsOf(context)
165
+ const filename = normalizePath(context.filename)
166
+ const slot = hfs.slotOf(filename) ?? ""
167
+ const tier = hfs.tierOf(filename)
168
+ const inDoor = isTransportSlot(slot) || slot.startsWith("be.transport.") || hfs.relative(filename).split("/").slice(0, -1).includes("dto")
169
+ const isSchemaFile = SCHEMA_FILE.test(filename)
170
+ return {
171
+ "*"(node) {
172
+ const found = referencesZod(node)
173
+ if (found.length === 0) return
174
+ if (inDoor) context.report({ node: found[0], messageId: "door" })
175
+ else if (tier === "feature") context.report({ node: found[0], messageId: "tier", data: { tier } })
176
+ else if (!isSchemaFile) context.report({ node: found[0], messageId: "file" })
177
+ else if (!SCHEMA_TIERS.has(tier)) context.report({ node: found[0], messageId: "tier", data: { tier: tier ?? "non-owner" } })
178
+ },
179
+ ImportDeclaration(node) {
180
+ if (!isSchemaFile || inDoor || !MIXED.has(node.source.value)) return
181
+ context.report({ node: node.source, messageId: "mixed", data: { source: node.source.value } })
182
+ },
183
+ }
184
+ },
185
+ }
186
+
187
+ /** The rules this law contributes to the plugin. */
188
+ export const rules = {
189
+ "schema-bounded": schemaBounded,
190
+ "schema-place": schemaPlace,
191
+ }
192
+
193
+ /** Both start at error: no baseline exists. */
194
+ export const recommended = {
195
+ "starci-be/schema-bounded": "error",
196
+ "starci-be/schema-place": "error",
197
+ }
package/index.mjs CHANGED
@@ -38,6 +38,7 @@ import { recommended as errorHandlingRecommended, rules as errorHandlingRules }
38
38
  import { recommended as idempotencyRecommended, rules as idempotencyRules } from "./idempotency.mjs"
39
39
  import { recommended as infraOwnerRecommended, rules as infraOwnerRules } from "./infra-owner.mjs"
40
40
  import { recommended as injectionRecommended, rules as injectionRules } from "./injection.mjs"
41
+ import { recommended as boundarySchemaRecommended, rules as boundarySchemaRules } from "./boundary-schema.mjs"
41
42
  import { recommended as inputBoundsRecommended, rules as inputBoundsRules } from "./input-bounds.mjs"
42
43
  import { recommended as logSafetyRecommended, rules as logSafetyRules } from "./log-safety.mjs"
43
44
  import { recommended as moduleLayeringRecommended, rules as moduleLayeringRules } from "./module-layering.mjs"
@@ -92,6 +93,7 @@ const CONTRIBUTIONS = [
92
93
  { law: "idempotency", rules: idempotencyRules, recommended: idempotencyRecommended },
93
94
  { law: "infra-owner", rules: infraOwnerRules, recommended: infraOwnerRecommended },
94
95
  { law: "injection", rules: injectionRules, recommended: injectionRecommended },
96
+ { law: "boundary-schema", rules: boundarySchemaRules, recommended: boundarySchemaRecommended },
95
97
  { law: "input-bounds", rules: inputBoundsRules, recommended: inputBoundsRecommended },
96
98
  { law: "log-safety", rules: logSafetyRules, recommended: logSafetyRecommended },
97
99
  { law: "module-layering", rules: moduleLayeringRules, recommended: moduleLayeringRecommended },
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * A slot id is manifest vocabulary, not a path: every rule that judges "a transport file" asks this instead of spelling
5
5
  * the ids itself. A cli command is no transport: it is an action runner of the cli feature root (slot `be.cli`). The
6
- * webhook and realtime kinds hold their doors in slots of their own (`be.feature.webhooks.http`, `be.feature.realtime.graphql`, `be.feature.realtime.websocket`): a provider webhook and a
6
+ * webhook and realtime kinds hold their doors in slots of their own (`be.feature.webhooks.http`, `be.feature.realtime.graphql`, `be.feature.realtime.websocket`, `be.feature.realtime.sse`): a provider webhook and a
7
7
  * push channel are doors like any other and answer to the door laws (default deny, bounded input, no envelope).
8
8
  */
9
9
 
10
10
  /** The slots of the feature kinds that are doors but are not an api's `transport/<protocol>/` folders. */
11
- export const KIND_DOOR_SLOTS = Object.freeze(["be.feature.webhooks.http", "be.feature.realtime.graphql", "be.feature.realtime.websocket"])
11
+ export const KIND_DOOR_SLOTS = Object.freeze(["be.feature.webhooks.http", "be.feature.realtime.graphql", "be.feature.realtime.websocket", "be.feature.realtime.sse"])
12
12
 
13
13
  /**
14
14
  * True for a slot that holds transport code (`be.transport.<protocol>` and the door slots of the webhook and realtime kinds).
package/lib/unit-spec.mjs CHANGED
@@ -81,11 +81,11 @@ export const unitRoleOfSubject = (hfs, filename) => {
81
81
  }
82
82
 
83
83
  /** The slots of the feature kinds whose door carries its own unit spec beside it. */
84
- const DOOR_SPEC_SLOTS = new Set(["be.feature.webhooks.http", "be.feature.realtime.graphql", "be.feature.realtime.websocket"])
84
+ const DOOR_SPEC_SLOTS = new Set(["be.feature.webhooks.http", "be.feature.realtime.graphql", "be.feature.realtime.websocket", "be.feature.realtime.sse"])
85
85
 
86
86
  /** The door a `<name>.<role>.spec.ts` of a feature kind tests, or null: `payment.webhook.spec.ts` gives `payment.webhook.ts`. */
87
87
  export const doorOfSpec = (hfs, filename) => {
88
88
  if (!isUnitSpecFile(hfs, filename) || !DOOR_SPEC_SLOTS.has(hfs.slotOf(filename) ?? "")) return null
89
- const match = /^(.+.(?:webhook|gateway|subscription)).spec.ts$/.exec(baseOf(filename))
89
+ const match = /^(.+.(?:webhook|gateway|subscription|controller)).spec.ts$/.exec(baseOf(filename))
90
90
  return match ? `${match[1]}.ts` : null
91
91
  }
package/operations.mjs CHANGED
@@ -17,8 +17,11 @@ import { decoratorArguments, decoratorCallee, isImportedFrom } from "./lib/impor
17
17
  import { isTransportSlot } from "./lib/transport-slots.mjs"
18
18
  import { typed } from "./lib/types.mjs"
19
19
 
20
- /** The HTTP route decorators of `@nestjs/common`. */
21
- const HTTP_ROUTES = ["Get", "Post", "Put", "Patch", "Delete", "All", "Options", "Head"]
20
+ /** The HTTP route decorators of `@nestjs/common` (`Sse` is a route too: a `text/event-stream` GET that needs its own evidence). */
21
+ const HTTP_ROUTES = ["Get", "Post", "Put", "Patch", "Delete", "All", "Options", "Head", "Sse"]
22
+
23
+ /** The slot of server-sent-events doors: the one place an `@Sse` route is evidence of itself. */
24
+ const SSE_SLOT = "be.feature.realtime.sse"
22
25
 
23
26
  /** True when a declaration file is owned by the capability `operations` of a platform slot. */
24
27
  const declaredByOperations = (hfs, file) => hfs.slotOf(file) === "be.platform" && hfs.ownerOf(file)?.split("/").pop() === "operations"
@@ -275,9 +278,17 @@ const credentialGuardEvidence = (context, method) => {
275
278
  return false
276
279
  }
277
280
 
278
- /** Route-level evidence for probes, auth handshakes, signed webhooks, redirects, byte transfers and credential-guarded routes. */
281
+ /**
282
+ * Event-stream evidence (BE_REST_DOOR_EVIDENCE, owner:lite-sse-door): the route is an `@Sse` method of `@nestjs/common` inside the sse slot of a
283
+ * realtime channel. It is structural, not a name list: the same `@Sse` anywhere else, or any other route inside the sse slot, proves nothing.
284
+ */
285
+ const eventStreamEvidence = (context, method) =>
286
+ hfsOf(context).slotOf(context.filename) === SSE_SLOT
287
+ && (method.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", "Sse"))
288
+
289
+ /** Route-level evidence for probes, auth handshakes, signed webhooks, redirects, byte transfers, event streams and credential-guarded routes. */
279
290
  export const restExceptionEvidence = (context, method) => {
280
- if (credentialGuardEvidence(context, method)) return true
291
+ if (credentialGuardEvidence(context, method) || eventStreamEvidence(context, method)) return true
281
292
  for (const decorator of method.decorators ?? []) {
282
293
  const callee = decoratorCallee(decorator)
283
294
  if (isImportedFrom(context, callee, "@nestjs/common", "Redirect")) return true
@@ -317,7 +328,18 @@ export const restRouteDeclared = {
317
328
  },
318
329
  }
319
330
 
320
- /** An operation route takes the table's `OperationRequest` and answers its `OperationReply`, of one table. */
331
+ /** True when a pipe argument is `new OperationValidationService(...)`, the class declared by the platform capability `http-security` (resolved by symbol). */
332
+ const isOperationPipe = (hfs, checker, toTs, argument) => {
333
+ if (argument?.type !== "NewExpression") return false
334
+ const target = toTs(argument.callee)
335
+ if (!target) return false
336
+ return declarationsOfSymbol(checker, checker.getSymbolAtLocation(target)).some((declaration) => {
337
+ const file = String(declaration.getSourceFile().fileName).replace(/\\/g, "/")
338
+ return declaration.name?.text === "OperationValidationService" && hfs.slotOf(file) === "be.platform" && hfs.ownerOf(file)?.split("/").pop() === "http-security"
339
+ })
340
+ }
341
+
342
+ /** An operation route takes the table's `OperationRequest` and answers its `OperationReply`, of one table, and validates the body it takes. */
321
343
  export const operationRouteDrivenByTable = {
322
344
  meta: {
323
345
  type: "problem",
@@ -326,6 +348,8 @@ export const operationRouteDrivenByTable = {
326
348
  messages: {
327
349
  half: "This route takes or answers `{{one}}` but not `{{other}}`. An operation route is driven by the table on both sides: `@Body() request: OperationRequest<Table>` and `Promise<OperationReply<Table>>`.",
328
350
  table: "The request and the reply of this route name different operation tables. One route serves one table.",
351
+ unvalidated:
352
+ "The body of this operation route is typed by an alias, which leaves no class at run time, so the global pipe validates nothing. Bind it to the request class with `@Body(new OperationValidationService(RequestClass))` of `platform/http-security`.",
329
353
  },
330
354
  },
331
355
  create(context) {
@@ -337,14 +361,21 @@ export const operationRouteDrivenByTable = {
337
361
  if (node.kind !== "method" || node.static) return
338
362
  if (!(node.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", HTTP_ROUTES))) return
339
363
  const params = node.value.params.filter((param) => (param.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", "Body")))
340
- const request = params.map((param) => canonReference(hfs, checker, toTs, param.typeAnnotation ?? param.parameter?.typeAnnotation)).find(Boolean) ?? null
364
+ const references = params.map((param) => canonReference(hfs, checker, toTs, param.typeAnnotation ?? param.parameter?.typeAnnotation))
365
+ const request = references.find(Boolean) ?? null
341
366
  const reply = canonReference(hfs, checker, toTs, unwrapPromise(node.value.returnType))
342
367
  if (!request && !reply) return
343
368
  if (!request || !reply) {
344
369
  context.report({ node: node.key, messageId: "half", data: request ? { one: "OperationRequest", other: "OperationReply" } : { one: "OperationReply", other: "OperationRequest" } })
345
370
  return
346
371
  }
347
- if (request.table !== reply.table) context.report({ node: node.key, messageId: "table" })
372
+ if (request.table !== reply.table) {
373
+ context.report({ node: node.key, messageId: "table" })
374
+ return
375
+ }
376
+ const body = params[references.indexOf(request)]
377
+ const pipes = (body.decorators ?? []).filter((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", "Body")).flatMap(decoratorArguments)
378
+ if (!pipes.some((pipe) => isOperationPipe(hfs, checker, toTs, pipe))) context.report({ node: body, messageId: "unvalidated" })
348
379
  },
349
380
  }
350
381
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-be",
3
- "version": "3.4.38",
3
+ "version": "3.4.40",
4
4
  "description": "The StarCi back-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/realtime.mjs CHANGED
@@ -2,18 +2,19 @@
2
2
  * The rules that hold the realtime kind (R184 `BE_REALTIME_SHAPE`, R185 `BE_REALTIME_WRITES`, R186 `BE_REALTIME_TOPIC_SCOPE`).
3
3
  *
4
4
  * A realtime door (`src/features/realtime/<channel>/transport/websocket/<channel>.gateway.ts` for a socket, `transport/graphql/<x>.subscription.ts` for a GraphQL
5
- * subscription, slot `be.feature.realtime`) READS and PUSHES. It never writes: the push is fed by a reactor that calls the `RealtimeHub`
5
+ * subscription, `transport/sse/<channel>.controller.ts` for server-sent events, slot `be.feature.realtime`) READS and PUSHES. It never writes: the push is fed by a reactor that calls the `RealtimeHub`
6
6
  * of `platform/realtime`, and the door only subscribes a client to a topic of that hub.
7
7
  *
8
8
  * - `realtime-shape` (R184): a `.gateway.ts` holds one `@WebSocketGateway` class whose handlers are `@SubscribeMessage`, or a
9
9
  * push-only gateway whose `@WebSocketServer` streams take the server's typed principal; a
10
- * `.subscription.ts` holds one `@Resolver` class whose operations are `@Subscription`. Each is the door its file role names, and
11
- * neither carries a query, a mutation or a REST route.
10
+ * `.subscription.ts` holds one `@Resolver` class whose operations are `@Subscription`, and an SSE `.controller.ts` holds one
11
+ * `@Controller` class whose one route is `@Sse`. Each is the door its file role names, and none carries a query, a mutation or a
12
+ * plain REST route.
12
13
  * - `realtime-read-only` (R185): the only value injected into a realtime door is the `RealtimeHub` of `platform/realtime`. No
13
14
  * `EntityManager`, bus, queue, event bus or domain service reaches it.
14
- * - `realtime-topic-scope` (R186): the topic passed to `hub.subscribe` is built from the principal parameter of the handler
15
+ * - `realtime-topic-scope` (R186): the topic passed to `hub.subscribe` (or `hub.stream`, the SSE form) is built from the principal parameter of the handler
15
16
  * (`@CurrentPrincipal()` of `domain/identity`, or `Principal` of `platform/cqrs` for a push-only stream), so a client
16
- * can only listen to what the principal owns.
17
+ * can only listen to what the principal owns. A `Last-Event-ID` header only picks where the replay resumes inside that topic.
17
18
  *
18
19
  * What a receiver is comes from its TYPE, what a decorator is from the import that binds it, and where a parameter comes
19
20
  * from from the declaration its name resolves to. No rule here tests a path pattern or a variable name.
@@ -25,19 +26,23 @@ import { decoratorCallee } from "./lib/import-source.mjs"
25
26
  import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
26
27
 
27
28
  /** The slot of realtime doors. */
28
- const DOOR_SLOTS = new Set(["be.feature.realtime.graphql", "be.feature.realtime.websocket"])
29
+ const DOOR_SLOTS = new Set(["be.feature.realtime.graphql", "be.feature.realtime.websocket", "be.feature.realtime.sse"])
29
30
 
30
31
  /** The `RealtimeHub` port of `platform/realtime`. */
31
32
  const isHubType = (context, node) => isOwnedType(context, node, { name: "RealtimeHub", capability: "realtime", tier: "platform" })
32
33
 
34
+ /** The plain HTTP route decorators of `@nestjs/common`: an SSE controller carries none of them, its one route is `@Sse`. */
35
+ const HTTP_ROUTES = ["Get", "Post", "Put", "Patch", "Delete", "All", "Options", "Head"]
36
+
33
37
  /** The decorators that make a class a door of one kind of realtime file, by file role. */
34
38
  const ROLES = {
35
39
  gateway: { classDecorator: ["@nestjs/websockets", "WebSocketGateway"], operation: ["@nestjs/websockets", "SubscribeMessage"], foreign: [["@nestjs/graphql", ["Query", "Mutation", "ResolveField", "Subscription"]], ["@nestjs/common", ["Controller"]]] },
36
40
  subscription: { classDecorator: ["@nestjs/graphql", "Resolver"], operation: ["@nestjs/graphql", "Subscription"], foreign: [["@nestjs/graphql", ["Query", "Mutation", "ResolveField"]], ["@nestjs/websockets", ["WebSocketGateway", "SubscribeMessage"]], ["@nestjs/common", ["Controller"]]] },
41
+ controller: { classDecorator: ["@nestjs/common", "Controller"], operation: ["@nestjs/common", "Sse"], foreign: [["@nestjs/graphql", ["Query", "Mutation", "ResolveField", "Subscription", "Resolver"]], ["@nestjs/websockets", ["WebSocketGateway", "SubscribeMessage"]], ["@nestjs/common", HTTP_ROUTES]] },
37
42
  }
38
43
 
39
- /** The role (`gateway` or `subscription`) a realtime file name carries, or null. */
40
- const roleOf = (filename) => /\.(gateway|subscription)\.ts$/.exec(baseName(filename))?.[1] ?? null
44
+ /** The role (`gateway`, `subscription` or `controller`) a realtime file name carries, or null. */
45
+ const roleOf = (filename) => /\.(gateway|subscription|controller)\.ts$/.exec(baseName(filename))?.[1] ?? null
41
46
 
42
47
  /** The door classes of a file: every class with a class-level door decorator of the role. */
43
48
  const doorClasses = (context, program, role) => {
@@ -55,9 +60,10 @@ const hasSocketServer = (context, node) => node.body.body.some((member) =>
55
60
  /** The authenticated principal port the socket middleware passes to a push-only stream. */
56
61
  const isStreamPrincipal = (context, param) => isOwnedType(context, param, { name: "Principal", capability: "cqrs", tier: "platform" })
57
62
 
58
- /** Subscriptions on the platform's hub, including those inside closures. */
63
+ /** The hub calls that open a client's stream: `subscribe` (an async iterable of frames) and `stream` (server-sent events), including those inside closures. */
64
+ const OPENING_CALLS = new Set(["subscribe", "stream"])
59
65
  const hubSubscriptions = (context, method) => callsOf(method).filter(({ receiver, method: called }) =>
60
- called === "subscribe" && receiver !== null && isHubType(context, receiver))
66
+ OPENING_CALLS.has(called) && receiver !== null && isHubType(context, receiver))
61
67
 
62
68
  /** The nonstatic methods a push-only gateway uses to feed its socket server. */
63
69
  const streamMethods = (context, node) => {
@@ -79,6 +85,7 @@ export const realtimeShape = {
79
85
  messages: {
80
86
  door: "A `<channel>.{{role}}.ts` file declares exactly one `{{decorator}}` class (this one declares {{count}}). One file is one door.",
81
87
  operations: "This {{role}} door has no `@{{operation}}` handler or principal-typed push stream through `@WebSocketServer`. A realtime door subscribes the client to a topic of the `RealtimeHub`.",
88
+ single: "This SSE controller declares {{count}} `@Sse` methods. A `<channel>.controller.ts` of the sse slot is one door with exactly one `@Sse` stream; a second stream is a second channel in its own folder.",
82
89
  foreign: "`@{{name}}` has no place in a realtime {{role}} door. A realtime door only subscribes and pushes; a query, a mutation, a field resolver or a REST route belongs in an api feature, and the other realtime protocol in its own file.",
83
90
  },
84
91
  },
@@ -95,6 +102,8 @@ export const realtimeShape = {
95
102
  return
96
103
  }
97
104
  if (!hasOperation(context, doors[0], role)) context.report({ node: doors[0], messageId: "operations", data: { role, operation: spec.operation[1] } })
105
+ const streams = role === "controller" ? methodsDecoratedBy(context, doors[0], ...spec.operation).length : 1
106
+ if (streams > 1) context.report({ node: doors[0], messageId: "single", data: { count: String(streams) } })
98
107
  walk(program, (node) => {
99
108
  if (node.type !== "Decorator") return
100
109
  for (const [source, names] of spec.foreign) {
@@ -22,21 +22,21 @@ pins:
22
22
  source: packages/heroicons/package.json
23
23
  why: 'StarCi custom Heroicons-compatible cuts; eslint-canon-fe (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
24
24
  '@starci/grammar':
25
- version: 0.10.0
25
+ version: 0.11.0
26
26
  group: starci
27
27
  install: registry
28
28
  side: fe
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.40
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.39
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.32
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.32
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
@@ -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']);
package/transport.mjs CHANGED
@@ -17,8 +17,8 @@ import { isTransportSlot, KIND_DOOR_SLOTS } from "./lib/transport-slots.mjs"
17
17
  import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
18
18
  import { operationRouteEvidence, restExceptionEvidence } from "./operations.mjs"
19
19
 
20
- /** The HTTP route decorators of `@nestjs/common`. */
21
- const HTTP_ROUTES = ["Get", "Post", "Put", "Patch", "Delete", "All", "Options", "Head"]
20
+ /** The HTTP route decorators of `@nestjs/common` (`Sse` is a route too: a `text/event-stream` GET that needs its own evidence). */
21
+ const HTTP_ROUTES = ["Get", "Post", "Put", "Patch", "Delete", "All", "Options", "Head", "Sse"]
22
22
 
23
23
  /** The GraphQL operation decorators of `@nestjs/graphql`. */
24
24
  const GRAPHQL_OPERATIONS = ["Query", "Mutation", "Subscription", "ResolveField"]
@@ -29,6 +29,9 @@ const HTTP_SLOT = "be.transport.http"
29
29
  /** The slot of provider webhook doors (the REST doors of the webhook kind). */
30
30
  const WEBHOOK_SLOT = "be.feature.webhooks.http"
31
31
 
32
+ /** The slot of server-sent-events doors (the event-stream REST doors of the realtime kind). */
33
+ const SSE_SLOT = "be.feature.realtime.sse"
34
+
32
35
  /** Slots whose classes are entered through a method the framework calls on a queue. */
33
36
  const PLAIN_ENTRY_SLOTS = new Set(["be.transport.message"])
34
37
 
@@ -356,12 +359,12 @@ export const doorLivesInFeatures = {
356
359
  docs: { description: "A `@Controller` is declared in the `transport/http` slot of a feature." },
357
360
  schema: [],
358
361
  messages: {
359
- wrongSlot: "A `@Controller` belongs in `src/features/api/<feature>/transport/http/` (slot `be.transport.http`) or, for a provider webhook, in `src/features/webhooks/<provider>/transport/http/` (slot `be.feature.webhooks.http`); this file is in slot `{{slot}}`. A door parked among the capabilities it calls reads as one and gets imported like one.",
362
+ wrongSlot: "A `@Controller` belongs in `src/features/api/<feature>/transport/http/` (slot `be.transport.http`), for a provider webhook in `src/features/webhooks/<provider>/transport/http/` (slot `be.feature.webhooks.http`) or, for a server-sent-events stream, in `src/features/realtime/<channel>/transport/sse/` (slot `be.feature.realtime.sse`); this file is in slot `{{slot}}`. A door parked among the capabilities it calls reads as one and gets imported like one.",
360
363
  },
361
364
  },
362
365
  create(context) {
363
366
  const slot = hfsOf(context).slotOf(context.filename)
364
- if (slot === HTTP_SLOT || slot === WEBHOOK_SLOT) return {}
367
+ if (slot === HTTP_SLOT || slot === WEBHOOK_SLOT || slot === SSE_SLOT) return {}
365
368
  return {
366
369
  Decorator(node) {
367
370
  if (isController(context, node)) context.report({ node, messageId: "wrongSlot", data: { slot: slot ?? "none" } })
@@ -403,7 +406,7 @@ export const restDoorNeedsAReason = {
403
406
  docs: { description: "Every REST route shows its own public reason (Health, AuthHandshake, SignedWebhook), byte transfer, redirect or declared operation table." },
404
407
  schema: [],
405
408
  messages: {
406
- unjustified: "[BE_REST_DOOR_EVIDENCE] This REST route shows none of the reasons a door may not be GraphQL: a route-level probe, auth handshake or signed webhook (`@Public({ reason: PublicReason.X })`), a byte transfer, an OAuth redirect or a declared public v1 operation table. A plain JSON read belongs in the GraphQL schema, where the client already is.",
409
+ unjustified: "[BE_REST_DOOR_EVIDENCE] This REST route shows none of the reasons a door may not be GraphQL: a route-level probe, auth handshake or signed webhook (`@Public({ reason: PublicReason.X })`), a byte transfer, an OAuth redirect, an event stream (`@Sse` in the sse slot of a realtime channel) or a declared public v1 operation table. A plain JSON read belongs in the GraphQL schema, where the client already is.",
407
410
  },
408
411
  },
409
412
  create(context) {