@starci/eslint-canon-be 3.4.37 → 3.4.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +2 -1
- package/boundary-schema.mjs +197 -0
- package/index.mjs +2 -0
- package/lib/transport-slots.mjs +2 -2
- package/lib/unit-spec.mjs +2 -2
- package/operations.mjs +38 -7
- package/package.json +1 -1
- package/realtime.mjs +19 -10
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -6
- package/runtime/knowledge/hfs/fe-stack.yaml +1 -1
- package/runtime/knowledge/hfs/rules.yaml +55 -7
- package/runtime/knowledge/hfs/slots.yaml +38 -18
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +7 -4
- package/transport.mjs +8 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.4.39 - 2026-10-24
|
|
4
|
+
- 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).
|
|
5
|
+
- 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.
|
|
6
|
+
- 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).
|
|
7
|
+
- Changed: the bundled slot manifest declares the `schema` role suffix and the `zod` pin is `side: both`.
|
|
8
|
+
|
|
9
|
+
## 3.4.38 - 2026-10-24
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
3
12
|
## 3.4.37 - 2026-10-24
|
|
4
13
|
- Changed: the bundled canon pins select zod 4.6.7, @starci/hfs 4.7.29 and @starci/cli 1.7.29.
|
|
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
|
|
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 },
|
package/lib/transport-slots.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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)
|
|
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
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
|
|
11
|
-
*
|
|
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 `
|
|
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
|
-
/**
|
|
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
|
|
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) {
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.39
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.38
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.7.
|
|
79
|
+
version: 1.7.31
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.7.
|
|
86
|
+
version: 4.7.31
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -338,8 +338,8 @@ pins:
|
|
|
338
338
|
'zod':
|
|
339
339
|
version: 4.6.7
|
|
340
340
|
group: framework
|
|
341
|
-
side:
|
|
342
|
-
why: 'the schema library of front-end forms and boundary
|
|
341
|
+
side: both
|
|
342
|
+
why: 'the schema library of front-end forms (fe-stack forms) and, on the back end, of the boundary parsers (R304 and R305: provider responses, model output and queue payloads, never a transport DTO), also an npm override of every app root; 4.6.7 (the registry latest on 2026-10-24) closes GHSA-mrhx-c7xc-m3w5 (high, fixed in 4.6.6), which npm audit --audit-level=high reported against 4.4.3.'
|
|
343
343
|
'@reduxjs/toolkit':
|
|
344
344
|
version: 2.11.2
|
|
345
345
|
group: framework
|
|
@@ -471,6 +471,11 @@ pins:
|
|
|
471
471
|
group: framework
|
|
472
472
|
side: be
|
|
473
473
|
why: 'an npm override of every app root (and of the examples that serve GraphQL), never a declared dependency: @nestjs/graphql 13.x pulls @graphql-tools/utils <=12.0.0, which npm audit --audit-level=high reports (GHSA-7mx3-vvmw-hjmv, prototype pollution in mergeDeep). 12.1.0 is a fixed release on the same major and the lowest one every declared range accepts (@graphql-tools/schema and /merge, used at runtime, require ^12.1.0; NIVO v0.2.1 6a1d8e80 overrides 12.0.4, below that range), so the advisory closes without the breaking @nestjs/graphql 14. DROP this pin and the override when @nestjs/graphql 14 is adopted (owner ruling graphql-tools-utils-override, 2026-10-18).'
|
|
474
|
+
'multer':
|
|
475
|
+
version: 2.5.0
|
|
476
|
+
group: framework
|
|
477
|
+
side: be
|
|
478
|
+
why: 'an npm override of every app root and of the examples, never a declared dependency: @nestjs/platform-express 11.2.x (and 12.x) depends on exactly multer 2.4.0, which npm audit --audit-level=high reports (GHSA-q7qp-rjg7-q26m, a denial of service through a pending asynchronous fileFilter after request teardown, published 2026-10-24). 2.5.0 is the fixed release on the same major. DROP this pin and the override when a @nestjs/platform-express release depends on multer 2.5.0 or later.'
|
|
474
479
|
'@nestjs/apollo':
|
|
475
480
|
version: 13.4.5
|
|
476
481
|
group: framework
|
|
@@ -75,7 +75,7 @@ concerns:
|
|
|
75
75
|
home: [fe.hooks.socketio]
|
|
76
76
|
pattern: FE-REALTIME
|
|
77
77
|
forbids: [graphql-ws, subscriptions-transport-ws, '@supabase/realtime-js', pusher-js, ably]
|
|
78
|
-
why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; lite runs the gateway in the be api process.
|
|
78
|
+
why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; lite runs the gateway in the be api process. A one-way channel served as server-sent events needs no package, only a fetch reader in modules/api/sse and a use<Channel>Stream hook (FE-REALTIME).
|
|
79
79
|
- id: auth
|
|
80
80
|
tier: core
|
|
81
81
|
packages: []
|
|
@@ -1918,7 +1918,7 @@ rules:
|
|
|
1918
1918
|
- {path: packages/hfs/src/r94-r96-r97-coverage.spec.mjs, editions: [lite, full], codes: [FE_SLOT_FILE_ROLE]}
|
|
1919
1919
|
- id: R95
|
|
1920
1920
|
code: "BE_OPERATION_CONTRACT"
|
|
1921
|
-
law: "A versioned operation is declared once in the app's typed operation table (`OperationContract<Input, Output, RefusalCode>` registered by `defineOperations`): its input and output are closed types (never `any`, `unknown` or `Record<string, unknown>`), its refusal codes a closed union of string literals, and the route that serves the table takes `OperationRequest<Table>` and answers `Promise<OperationReply<Table>>` of that one table."
|
|
1921
|
+
law: "A versioned operation is declared once in the app's typed operation table (`OperationContract<Input, Output, RefusalCode>` registered by `defineOperations`): its input and output are closed types (never `any`, `unknown` or `Record<string, unknown>`), its refusal codes a closed union of string literals, and the route that serves the table takes `OperationRequest<Table>` and answers `Promise<OperationReply<Table>>` of that one table, binding its body to the `OperationValidationService` of `platform/http-security` because the alias leaves no class for the global pipe to validate."
|
|
1922
1922
|
kinds: [lint]
|
|
1923
1923
|
failureCodes: ["BE_OPERATION_CONTRACT"]
|
|
1924
1924
|
enforcers:
|
|
@@ -3193,11 +3193,12 @@ rules:
|
|
|
3193
3193
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_SHAPE]}
|
|
3194
3194
|
exemplifiedBy:
|
|
3195
3195
|
- {path: packages/hfs/templates/be/patterns/realtime/channel.gateway.ts.tpl, editions: [lite, full]}
|
|
3196
|
+
- {path: packages/hfs/templates/be/patterns/realtime/channel.controller.ts.tpl, editions: [lite, full]}
|
|
3196
3197
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_SHAPE]}
|
|
3197
3198
|
witness:
|
|
3198
3199
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: lite, codes: [BE_REALTIME_SHAPE]}
|
|
3199
3200
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: full, codes: [BE_REALTIME_SHAPE]}
|
|
3200
|
-
law: "A realtime file is the one door its role names: `<channel>.gateway.ts` holds one `@WebSocketGateway` class with `@SubscribeMessage` handlers
|
|
3201
|
+
law: "A realtime file is the one door its role names: `<channel>.gateway.ts` holds one `@WebSocketGateway` class with `@SubscribeMessage` handlers, `<x>.subscription.ts` holds one `@Resolver` class with `@Subscription` operations and `<channel>.controller.ts` of the sse slot holds one `@Controller` class whose one route is `@Sse`, and none carries a query, a mutation, a field resolver, a plain REST route or another protocol. A lite app serves SSE (the default door for one-way push) or the socket.io gateway from its api process and no graphql-ws subscription (R212 case-2 (owner:lite-production-swarm), with the Work boundary amended by ruling:R212/case-4 and the realtime adapter amended by ruling:R212/case-5)."
|
|
3201
3202
|
kinds: [lint]
|
|
3202
3203
|
gates: [pre-commit, settle, land, ci]
|
|
3203
3204
|
failureCodes: ["BE_REALTIME_SHAPE"]
|
|
@@ -3211,11 +3212,12 @@ rules:
|
|
|
3211
3212
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_WRITES]}
|
|
3212
3213
|
exemplifiedBy:
|
|
3213
3214
|
- {path: packages/hfs/templates/be/patterns/realtime/channel.gateway.ts.tpl, editions: [lite, full]}
|
|
3215
|
+
- {path: packages/hfs/templates/be/patterns/realtime/channel.controller.ts.tpl, editions: [lite, full]}
|
|
3214
3216
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_WRITES]}
|
|
3215
3217
|
witness:
|
|
3216
3218
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: lite, codes: [BE_REALTIME_WRITES]}
|
|
3217
3219
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: full, codes: [BE_REALTIME_WRITES]}
|
|
3218
|
-
law: "A realtime door reads and pushes only: the one value injected into it is the `RealtimeHub` of `platform/realtime`; no `EntityManager`, bus, queue, event bus or domain service reaches it."
|
|
3220
|
+
law: "A realtime door (gateway, subscription or SSE controller) reads and pushes only: the one value injected into it is the `RealtimeHub` of `platform/realtime`; no `EntityManager`, bus, queue, event bus or domain service reaches it."
|
|
3219
3221
|
kinds: [lint]
|
|
3220
3222
|
gates: [pre-commit, settle, land, ci]
|
|
3221
3223
|
failureCodes: ["BE_REALTIME_WRITES"]
|
|
@@ -3229,11 +3231,12 @@ rules:
|
|
|
3229
3231
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_TOPIC_SCOPE]}
|
|
3230
3232
|
exemplifiedBy:
|
|
3231
3233
|
- {path: packages/hfs/templates/be/patterns/realtime/channel.gateway.ts.tpl, editions: [lite, full]}
|
|
3234
|
+
- {path: packages/hfs/templates/be/patterns/realtime/channel.controller.ts.tpl, editions: [lite, full]}
|
|
3232
3235
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full], codes: [BE_REALTIME_TOPIC_SCOPE]}
|
|
3233
3236
|
witness:
|
|
3234
3237
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: lite, codes: [BE_REALTIME_TOPIC_SCOPE]}
|
|
3235
3238
|
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, edition: full, codes: [BE_REALTIME_TOPIC_SCOPE]}
|
|
3236
|
-
law: "The topic a realtime handler passes to `RealtimeHub.subscribe` is built from the handler's `@CurrentPrincipal()` parameter of `domain/identity`, so a client can only listen to channels its principal owns."
|
|
3239
|
+
law: "The topic a realtime handler (including the `@Sse` method of an SSE controller) passes to `RealtimeHub.subscribe` is built from the handler's `@CurrentPrincipal()` parameter of `domain/identity`, so a client can only listen to channels its principal owns; a `Last-Event-ID` header only chooses where the replay inside that topic resumes."
|
|
3237
3240
|
kinds: [lint]
|
|
3238
3241
|
gates: [pre-commit, settle, land, ci]
|
|
3239
3242
|
failureCodes: ["BE_REALTIME_TOPIC_SCOPE"]
|
|
@@ -3568,7 +3571,7 @@ rules:
|
|
|
3568
3571
|
docs: [docs/lite-edition.md#edition-validation]
|
|
3569
3572
|
templateNa: "The hfs.json edition declaration is generated programmatically by packages/hfs/scaffold/app.mjs; no managed declaration template exists. Forbidden paths are rejected rather than scaffolded."
|
|
3570
3573
|
code: "HFS_EDITION_INVALID"
|
|
3571
|
-
law: "hfs.json names an edition the manifest knows (full, or lite; absent means full). Lite follows the 2026-10-13 ruling: the same monorepo, code structure, slots, patterns and back-end unit specs, with only backing infrastructure swapped for managed Supabase (Postgres, Auth, Storage, Supabase Queues/pgmq for queue and event bus, Postgres locks, pg_cron); the api and sidecars run on docker-swarm on a VPS. The realtime pattern
|
|
3574
|
+
law: "hfs.json names an edition the manifest knows (full, or lite; absent means full). Lite follows the 2026-10-13 ruling: the same monorepo, code structure, slots, patterns and back-end unit specs, with only backing infrastructure swapped for managed Supabase (Postgres, Auth, Storage, Supabase Queues/pgmq for queue and event bus, Postgres locks, pg_cron); the api and sidecars run on docker-swarm on a VPS. The realtime pattern serves SSE (the default for one-way push) or the same socket.io gateway inside the be api process (one instance, no Redis adapter; R212 case-5), and both editions share knowledge/hfs/fe-stack.yaml. Lite requires application-stacks.yaml and permits environment metadata, runbooks, infra/swarm and encrypted-secret custody, while refusing infra/compose, infra/k8s, infra/terraform and components duplicating Supabase backing services (runtime/{config,env}, seeds and the sealed secrets of its environments are allowed). Contract specs and `test:contract` are allowed (owner 2026-10-14); it has no e2e spec, `test:e2e`, `test:stack` or `test:browser` script, e2e workflow, browser journey, Work catalog, shell, features, resources, UAT or br records, test container, toxiproxy, browser-runner, BullMQ, Kafka, Redis or graphql-ws dependency, or supabase/functions. Its .starciwork is optional and admits only .gitignore, workspace.yaml and brand/**; workspace.yaml is optional and carries project identity and repo roles for the Work tools."
|
|
3572
3575
|
kinds: [check]
|
|
3573
3576
|
gates: [settle, land, ci]
|
|
3574
3577
|
failureCodes: ["HFS_EDITION_INVALID", "HFS_EDITION_FORBIDDEN_PRESENT", "HFS_STARCIWORK_LITE_FORBIDDEN"]
|
|
@@ -3598,6 +3601,11 @@ rules:
|
|
|
3598
3601
|
source: owner
|
|
3599
3602
|
quote: "In a lite app, .starciwork keeps only the brand records."
|
|
3600
3603
|
ruling: "Amended by the owner on 2026-10-14: under lite, .starciwork is optional when the app has no brand records and admits only .gitignore, workspace.yaml and brand/** in the existing brand tooling layout, including drawing records, references and assets. workspace.yaml is optional and keeps the project id and repo roles so Work root detection and render-proof role mapping can tie brand records to the front end. index.yaml, shell/, features/, _resources/, uat and br remain forbidden. Lite readers read workspace.yaml normally and skip the full-edition catalog, shell and feature metadata; lite scaffolding creates .gitignore and workspace.yaml, with brand/ only when present. Full edition is unchanged."
|
|
3604
|
+
- id: case-5
|
|
3605
|
+
date: "2026-10-24"
|
|
3606
|
+
source: owner
|
|
3607
|
+
quote: "Lite may serve SSE, and SSE is the default for one-way push. (alpha.30 BE patterns research, owner decisions 2026-10-24, relayed by the alpha.30 lead)"
|
|
3608
|
+
ruling: "Server-sent events are the third door of the realtime pattern (be.feature.realtime.sse), legal in lite and in full, and the default door for one-way server-to-client push; this amends the lite realtime adapter clause of case-2, which named the socket.io gateway as lite's only push. The socket.io gateway stays for a bidirectional channel, graphql-ws and the GraphQL subscription door stay full-only, and the hub is single-replica (the lite api pins replicas: 1, STACKS_REALTIME_SCALED)."
|
|
3601
3609
|
proves: [packages/hfs/src/hfs-edition-invalid.spec.mjs, packages/hfs/src/hfs-edition-forbidden-present.spec.mjs, packages/hfs/src/hfs-starciwork-lite-forbidden.spec.mjs]
|
|
3602
3610
|
exemplifiedBy:
|
|
3603
3611
|
- {path: packages/hfs/src/hfs-edition-invalid.spec.mjs, editions: [lite, full]}
|
|
@@ -4750,8 +4758,8 @@ rules:
|
|
|
4750
4758
|
- {path: packages/hfs/src/hfs-secret-custody.spec.mjs, editions: [full], codes: [HFS_SECRET_OUTSIDE_ENC]}
|
|
4751
4759
|
- id: R278
|
|
4752
4760
|
code: "BE_REST_DOOR_EVIDENCE"
|
|
4753
|
-
amendedBy: [owner:credential-guarded-rest-door]
|
|
4754
|
-
law: "GraphQL is the default door in lite and full. Each REST route carries its own evidence: a probe, auth handshake, signed webhook, actual byte stream, a public versioned operation served from the app's nonempty defineOperations table, or a credential-guarded route (owner:credential-guarded-rest-door): the route or its controller carries @Credential({ kind: \"<literal>\" }) imported from the domain identity capability, which also declares the CredentialGuard class implementing CanActivate (resolved by TypeScript symbol, never by name); a local decorator of the same name, a @UseGuards of any other guard and a computed kind prove nothing. A class-level reference or an unused StreamableFile import proves no route, and a public REST route absent from the operation table is refused."
|
|
4761
|
+
amendedBy: [owner:credential-guarded-rest-door, owner:lite-sse-door]
|
|
4762
|
+
law: "GraphQL is the default door in lite and full. Each REST route carries its own evidence: a probe, auth handshake, signed webhook, actual byte stream, an event stream (a text/event-stream route: an `@Sse` method of `@nestjs/common` inside the be.feature.realtime.sse slot, owner:lite-sse-door; an `@Sse` anywhere else proves nothing), a public versioned operation served from the app's nonempty defineOperations table, or a credential-guarded route (owner:credential-guarded-rest-door): the route or its controller carries @Credential({ kind: \"<literal>\" }) imported from the domain identity capability, which also declares the CredentialGuard class implementing CanActivate (resolved by TypeScript symbol, never by name); a local decorator of the same name, a @UseGuards of any other guard and a computed kind prove nothing. A class-level reference or an unused StreamableFile import proves no route, and a public REST route absent from the operation table is refused."
|
|
4755
4763
|
kinds: [lint]
|
|
4756
4764
|
gates: [pre-commit, settle, land, ci]
|
|
4757
4765
|
failureCodes: ["BE_REST_DOOR_EVIDENCE", "BE_REST_ROUTE_UNDECLARED"]
|
|
@@ -5279,3 +5287,43 @@ rules:
|
|
|
5279
5287
|
- {path: packages/hfs/src/bypass-detector.spec.mjs, editions: [lite, full], codes: [HFS_BYPASS_MARKER]}
|
|
5280
5288
|
witness:
|
|
5281
5289
|
- {path: packages/hfs/src/bypass-detector.spec.mjs, editions: [lite, full], codes: [HFS_BYPASS_MARKER]}
|
|
5290
|
+
- id: R304
|
|
5291
|
+
code: "BE_SCHEMA_PLACE"
|
|
5292
|
+
law: "The back end validates by zone. A transport door (GraphQL `@InputType`, REST request class) is validated by `class-validator` (R42); untrusted data that never reaches a DTO (a provider's HTTP response, structured model output, a queue or outbox payload read back, a tool argument) is read through a `zod` schema. `zod` is imported only from a `<name>.schema.ts` file (or its spec) of a `modules/domain`, `modules/platform` or `modules/integrations` owner, never in a feature, a transport door or a `dto/` folder, and a schema file imports neither `class-validator`, `class-transformer` nor `@nestjs/graphql`. The reader that calls the schema maps a failed parse to its owner's error family. No schema is shared between the front end and the back end: the shared contract is the emitted `schema.graphql` and `openapi.json`."
|
|
5293
|
+
kinds: [lint, design]
|
|
5294
|
+
gates: [pre-commit, settle, land, ci]
|
|
5295
|
+
failureCodes: ["BE_SCHEMA_PLACE"]
|
|
5296
|
+
enforcers:
|
|
5297
|
+
- {kind: eslint-be, id: schema-place}
|
|
5298
|
+
editions: [lite, full]
|
|
5299
|
+
docs: [docs/quality-bar.md#r304]
|
|
5300
|
+
proves:
|
|
5301
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, editions: [lite, full], codes: [BE_SCHEMA_PLACE]}
|
|
5302
|
+
exemplifiedBy:
|
|
5303
|
+
- {path: examples/full-app/be/src/modules/integrations/sepay/sepay-transactions.schema.ts, edition: full}
|
|
5304
|
+
- {path: examples/lite-app/be/src/modules/domain/calendar-inbox/calendar-delivery.schema.ts, edition: lite}
|
|
5305
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, editions: [lite, full], codes: [BE_SCHEMA_PLACE]}
|
|
5306
|
+
witness:
|
|
5307
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, edition: lite, codes: [BE_SCHEMA_PLACE]}
|
|
5308
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, edition: full, codes: [BE_SCHEMA_PLACE]}
|
|
5309
|
+
templateNa: "A schema is declared per boundary by its owner; the canon scaffolds none, and the two example schemas are the reference."
|
|
5310
|
+
- id: R305
|
|
5311
|
+
code: "BE_SCHEMA_BOUNDED"
|
|
5312
|
+
law: "Every `zod` schema is bounded: each `z.string()` (and the string formats `z.email()`, `z.url()` and kin) has `.max(n)` or `.length(n)`, each `z.array(...)` and `z.set(...)` has `.max(n)`, `.length(n)` or `.size(n)`, each `z.record(...)` and `z.map(...)` bounds its entry count by a `.refine`/`.check`, and `z.any()`, `z.unknown()` and `z.json()` are refused. A bound is a finite positive number: `Infinity`, `0` and `Number.MAX_*` are no bound. An unbounded collection whose elements all fail validation allocates an issue per element (GHSA-mrhx-c7xc-m3w5), and an unbounded string is an unbounded cost on any zod version."
|
|
5313
|
+
kinds: [lint, design]
|
|
5314
|
+
gates: [pre-commit, settle, land, ci]
|
|
5315
|
+
failureCodes: ["BE_SCHEMA_BOUNDED"]
|
|
5316
|
+
enforcers:
|
|
5317
|
+
- {kind: eslint-be, id: schema-bounded}
|
|
5318
|
+
editions: [lite, full]
|
|
5319
|
+
docs: [docs/quality-bar.md#r305]
|
|
5320
|
+
proves:
|
|
5321
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, editions: [lite, full], codes: [BE_SCHEMA_BOUNDED]}
|
|
5322
|
+
exemplifiedBy:
|
|
5323
|
+
- {path: examples/full-app/be/src/modules/integrations/sepay/sepay-transactions.schema.ts, edition: full}
|
|
5324
|
+
- {path: examples/lite-app/be/src/modules/domain/calendar-inbox/calendar-delivery.schema.ts, edition: lite}
|
|
5325
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, editions: [lite, full], codes: [BE_SCHEMA_BOUNDED]}
|
|
5326
|
+
witness:
|
|
5327
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, edition: lite, codes: [BE_SCHEMA_BOUNDED]}
|
|
5328
|
+
- {path: packages/eslint/be/boundary-schema.spec.mjs, edition: full, codes: [BE_SCHEMA_BOUNDED]}
|
|
5329
|
+
templateNa: "A schema is declared per boundary by its owner; the canon scaffolds none, and the two example schemas are the reference."
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# catalog READ this file through scripts/hfs/slots.mjs; none of them hardcodes a path.
|
|
8
8
|
# Shape: modules/schemas/hfs-slots.schema.yaml. The repository side is hfs.json: modules/schemas/hfs-repo.schema.yaml.
|
|
9
9
|
schema: starci/hfs-slots@3
|
|
10
|
-
version: 3.
|
|
10
|
+
version: 3.6.0
|
|
11
11
|
|
|
12
12
|
versioning:
|
|
13
13
|
# MAJOR.MINOR.PATCH of this manifest. A repository pins only the MAJOR in hfs.json ("hfs": 3).
|
|
@@ -41,6 +41,8 @@ versioning:
|
|
|
41
41
|
# 3.4.0 is a minor (alpha.25): it ADDS the opt-in be.contract.mcp-tools slot, the committed MCP tool-catalog snapshot of an app.
|
|
42
42
|
# 3.5.0 is a minor (alpha.27): it ADDS the opt-in app.browser-config slot, the managed browser/tsconfig.json that type-checks the browser
|
|
43
43
|
# journey (enabled with app.browser by `browser: true`).
|
|
44
|
+
# 3.6.0 is a minor (alpha.30): it ADDS the opt-in be.feature.realtime.sse slot, the server-sent-events door of the realtime kind (owner decision
|
|
45
|
+
# 2026-10-24: lite may serve SSE, and SSE is the default door for one-way push), and lets fe.modules.api hold the SSE stream readers (sse/).
|
|
44
46
|
|
|
45
47
|
# Field meaning, per slot.
|
|
46
48
|
# id stable name, never changes meaning inside a major.
|
|
@@ -217,7 +219,7 @@ ruleParams:
|
|
|
217
219
|
kafkajs: [platform/event-bus] # pattern event-bus: Kafka is the only event transport
|
|
218
220
|
"@nestjs/schedule": [] # no @Cron / @Interval / @Timeout anywhere: a schedule is a BullMQ job scheduler of a queue
|
|
219
221
|
eventemitter2: []
|
|
220
|
-
setInterval: [platform/retry, platform/event-bus, platform/queue, platform/http]
|
|
222
|
+
setInterval: [platform/retry, platform/event-bus, platform/queue, platform/http, platform/realtime]
|
|
221
223
|
setTimeout: [platform/retry, platform/event-bus, platform/queue, platform/http]
|
|
222
224
|
"node:timers": [platform/retry, platform/event-bus, platform/queue, platform/http]
|
|
223
225
|
"node:timers/promises": [platform/retry, platform/event-bus, platform/queue, platform/http]
|
|
@@ -279,7 +281,7 @@ ruleParams:
|
|
|
279
281
|
# R89 BE_SOURCE_FORM: the closed role-suffix vocabulary of a source file name (<kebab-name>.<suffix>.ts; BE-CONVENTION 1.15)
|
|
280
282
|
# and the suffixes that are never a role. index.ts, main.ts, app.module.ts and the migrations of be.persistence are the
|
|
281
283
|
# only names outside it.
|
|
282
|
-
suffixes: [module, module-definition, decorators, options, config, connection, service, command, query, handler, templates, compensation, saga-state, saga-step, saga, contracts, port, resolver, controller, gateway, webhook, subscription, consumer, cli, event, queue, projection, projection-entity, processor, step, input, type, args, request, response, mapper, policy, entity, sql, rows, error, messages, log-events, cache-keys, guard, interceptor, filter, client, spec, integration-spec, e2e-spec, contract-spec, global-setup, builder]
|
|
284
|
+
suffixes: [module, module-definition, decorators, options, config, connection, service, schema, command, query, handler, templates, compensation, saga-state, saga-step, saga, contracts, port, resolver, controller, gateway, webhook, subscription, consumer, cli, event, queue, projection, projection-entity, processor, step, input, type, args, request, response, mapper, policy, entity, sql, rows, error, messages, log-events, cache-keys, guard, interceptor, filter, client, spec, integration-spec, e2e-spec, contract-spec, global-setup, builder]
|
|
283
285
|
# R47 BE_CONTRACT_UNGUARDED: the world's shape vocabulary. `helper` names the function a contract spec imports to erase the values of a JSON
|
|
284
286
|
# payload (`shapeOf`); a contract spec proves a fake's fixtures by asserting helper(real) equals helper(fixture).
|
|
285
287
|
contractShape: {helper: shapeOf}
|
|
@@ -298,7 +300,7 @@ ruleParams:
|
|
|
298
300
|
# options, config, command, query, contracts, port, event, queue, entity, sql, rows, messages, log-events, error, decorators, cache-keys,
|
|
299
301
|
# projection-entity, connection, and every file with no role: index.ts, main.ts, migrations) is declaration or composition and is never measured.
|
|
300
302
|
# A unit spec beside such a file (`<name>.<role>.spec.ts`) is legal but not required: the file owes 100, not a spec of its own.
|
|
301
|
-
logicRoles: [service, policy, projection, guard, mapper, client, filter, interceptor, processor, step, saga, saga-step, compensation, consumer, webhook]
|
|
303
|
+
logicRoles: [service, schema, policy, projection, guard, mapper, client, filter, interceptor, processor, step, saga, saga-step, compensation, consumer, webhook]
|
|
302
304
|
# R203 BE_FEATURE_THIN. THE roles a feature-tier file is judged thin by: a file `<name>.<role>.ts` of one of these roles inside a slot of tier
|
|
303
305
|
# feature maps parameters and makes ONE delegating call into modules/ (a service, the command or query bus, the event bus, a queue producer): no
|
|
304
306
|
# branch, loop, ternary, try or throw, no computation; orchestration (step order, compensation, claim, fencing, retry) lives in platform/*.
|
|
@@ -1434,9 +1436,9 @@ slots:
|
|
|
1434
1436
|
since: 1.0.0
|
|
1435
1437
|
- id: be.feature.realtime
|
|
1436
1438
|
profiles: [be]
|
|
1437
|
-
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite runs socket.io in the api process, one instance without Redis; full may scale its gateway."
|
|
1439
|
+
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4) and ruling:R212/case-5 (owner decision 2026-10-24): lite serves server-sent events (the default door for one-way push) or runs socket.io in the api process, one instance without Redis; full may scale its gateway."
|
|
1438
1440
|
path: "src/features/realtime/<channel>/"
|
|
1439
|
-
presence: opt-in # a push channel: reads and pushes only (a socket gateway or a GraphQL subscription), no domain write
|
|
1441
|
+
presence: opt-in # a push channel: reads and pushes only (an SSE controller, a socket gateway or a GraphQL subscription), no domain write
|
|
1440
1442
|
pattern: realtime # declaring the pattern (hfs.json sides.be.patterns) enables the kind (knowledge/patterns/be/realtime.yaml)
|
|
1441
1443
|
trigger: realtime
|
|
1442
1444
|
tracked: tracked
|
|
@@ -1447,9 +1449,9 @@ slots:
|
|
|
1447
1449
|
tests: none
|
|
1448
1450
|
lite:
|
|
1449
1451
|
why: >-
|
|
1450
|
-
a push channel
|
|
1451
|
-
|
|
1452
|
-
(knowledge/patterns/be/realtime.yaml, liteAdapter)
|
|
1452
|
+
a push channel: the server-sent-events door (be.feature.realtime.sse, the default for one-way push, owner decision 2026-10-24)
|
|
1453
|
+
or the same socket.io gateway as full (owner ruling 2026-10-13), both run inside the be api process, one instance and no
|
|
1454
|
+
Redis adapter; the front end listens through a use<Channel>Stream hook over modules/api/sse, or hooks/socketio (knowledge/patterns/be/realtime.yaml, liteAdapter)
|
|
1453
1455
|
coverage: none
|
|
1454
1456
|
rules: [BE_KIND_ISOLATION, BE_FEATURE_IMPORTS_FEATURE, BE_FEATURE_NOT_COMPOSED]
|
|
1455
1457
|
since: 1.0.0
|
|
@@ -1457,14 +1459,14 @@ slots:
|
|
|
1457
1459
|
- {path: packages/hfs/src/hfs-stacks-swarm.spec.mjs, edition: lite}
|
|
1458
1460
|
- id: be.feature.realtime.graphql
|
|
1459
1461
|
profiles: [be]
|
|
1460
|
-
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite
|
|
1462
|
+
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite pushes through the SSE door or the in-process socket.io gateway; the GraphQL subscription door remains full-only."
|
|
1461
1463
|
path: "src/features/realtime/<channel>/transport/graphql/"
|
|
1462
1464
|
presence: opt-in # a GraphQL subscription over graphql-ws
|
|
1463
|
-
litePresence: forbidden # ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite pushes through the api's in-process socket.io gateway
|
|
1465
|
+
litePresence: forbidden # ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite pushes through the api's SSE door or its in-process socket.io gateway
|
|
1464
1466
|
pattern: realtime
|
|
1465
1467
|
tracked: tracked
|
|
1466
1468
|
tier: feature
|
|
1467
|
-
goesTo: "src/features/realtime/<channel>/transport/
|
|
1469
|
+
goesTo: "src/features/realtime/<channel>/transport/sse/ (be.feature.realtime.sse, the default for one-way push) or transport/websocket/ (be.feature.realtime.websocket): lite runs either door in the api process"
|
|
1468
1470
|
requires: ["<channel>-graphql.module.ts", "<channel>.subscription.ts", "<channel>.subscription.spec.ts"]
|
|
1469
1471
|
allows: ["<channel>-graphql.module.ts", "<channel>-graphql.module-definition.ts", "<channel>-graphql.options.ts", "<channel>.subscription.ts", "<channel>.subscription.spec.ts", dto/]
|
|
1470
1472
|
tests: unit-beside # the door's own spec beside the door (a subscription has no service of its own)
|
|
@@ -1474,14 +1476,14 @@ slots:
|
|
|
1474
1476
|
since: 1.0.0
|
|
1475
1477
|
- id: be.feature.realtime.dto
|
|
1476
1478
|
profiles: [be]
|
|
1477
|
-
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): GraphQL subscription DTOs exist only with the full subscription door; lite uses socket.io contracts."
|
|
1479
|
+
editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): GraphQL subscription DTOs exist only with the full subscription door; lite uses SSE or socket.io contracts."
|
|
1478
1480
|
path: "src/features/realtime/<channel>/transport/graphql/dto/"
|
|
1479
1481
|
presence: opt-in
|
|
1480
1482
|
litePresence: forbidden # the dto of the graphql door, which lite does not serve
|
|
1481
1483
|
pattern: realtime
|
|
1482
1484
|
tracked: tracked
|
|
1483
1485
|
tier: feature
|
|
1484
|
-
goesTo: "src/features/realtime/<channel>/transport/
|
|
1486
|
+
goesTo: "src/features/realtime/<channel>/transport/sse/ (be.feature.realtime.sse, the default for one-way push) or transport/websocket/ (be.feature.realtime.websocket): lite runs either door in the api process"
|
|
1485
1487
|
allows: ["<name>.{input,type,args}.ts"]
|
|
1486
1488
|
tests: none
|
|
1487
1489
|
coverage: none
|
|
@@ -1502,6 +1504,22 @@ slots:
|
|
|
1502
1504
|
composedBy: [api]
|
|
1503
1505
|
rules: [BE_REALTIME_SHAPE, BE_REALTIME_WRITES, BE_REALTIME_TOPIC_SCOPE, BE_DEFAULT_DENY]
|
|
1504
1506
|
since: 1.0.0
|
|
1507
|
+
- id: be.feature.realtime.sse
|
|
1508
|
+
since: 3.6.0
|
|
1509
|
+
profiles: [be]
|
|
1510
|
+
path: "src/features/realtime/<channel>/transport/sse/"
|
|
1511
|
+
presence: opt-in # a server-sent-events door: one @Sse method, the default door for one-way push (owner decision 2026-10-24), served in both editions
|
|
1512
|
+
pattern: realtime
|
|
1513
|
+
tracked: tracked
|
|
1514
|
+
tier: feature
|
|
1515
|
+
requires: ["<channel>-sse.module.ts", "<channel>.controller.ts", "<channel>.controller.spec.ts"]
|
|
1516
|
+
allows: ["<channel>-sse.module.ts", "<channel>-sse.module-definition.ts", "<channel>-sse.options.ts", "<channel>.controller.ts", "<channel>.controller.spec.ts"]
|
|
1517
|
+
tests: unit-beside # the door's own spec beside the door (an SSE controller has no service of its own)
|
|
1518
|
+
coverage: none
|
|
1519
|
+
composedBy: [api]
|
|
1520
|
+
rules: [BE_REALTIME_SHAPE, BE_REALTIME_WRITES, BE_REALTIME_TOPIC_SCOPE, BE_DEFAULT_DENY, BE_REST_DOOR_EVIDENCE]
|
|
1521
|
+
exemplifiedBy:
|
|
1522
|
+
- {path: packages/eslint/be/webhooks-realtime-coverage.spec.mjs, editions: [lite, full]}
|
|
1505
1523
|
- id: be.cli
|
|
1506
1524
|
since: 3.1.0
|
|
1507
1525
|
profiles: [be]
|
|
@@ -1552,7 +1570,7 @@ slots:
|
|
|
1552
1570
|
requires: [index.ts]
|
|
1553
1571
|
# <name>.templates.ts holds the capability's static templates, copy and presets as TS constants (owner ruling 2026-10-13);
|
|
1554
1572
|
# user-editable ones are seed rows, large or user media live in object storage.
|
|
1555
|
-
allows: ["<capability>.module.ts", "<capability>.module-definition.ts", "<capability>.config.ts", "<capability>.options.ts", errors/, messages/, persistence/, policies/, "*.service.ts", "*.service.spec.ts", "*.contracts.ts", "*.templates.ts"]
|
|
1573
|
+
allows: ["<capability>.module.ts", "<capability>.module-definition.ts", "<capability>.config.ts", "<capability>.options.ts", errors/, messages/, persistence/, policies/, "*.service.ts", "*.service.spec.ts", "*.contracts.ts", "*.templates.ts", "*.schema.ts", "*.schema.spec.ts"]
|
|
1556
1574
|
forbids: [testing/, exceptions/, utils/, helpers/, shared/]
|
|
1557
1575
|
tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts, nothing else
|
|
1558
1576
|
coverage: required
|
|
@@ -2254,15 +2272,17 @@ slots:
|
|
|
2254
2272
|
# The browser's calls to the back end (owner ruling 2026-10-13, fe-stack graphql/rest/codegen, FE-API-GRAPHQL): the auth and
|
|
2255
2273
|
# no-auth Apollo clients with their link chain (graphql/clients), one file per operation (graphql/queries/query-<x>.ts,
|
|
2256
2274
|
# graphql/mutations/mutation-<x>.ts with its gql document map and typed request/response in types/), the generated wire types
|
|
2257
|
-
# (graphql/__generated__)
|
|
2275
|
+
# (graphql/__generated__), the axios calls (rest/<area>/*.ts) for uploads, presigned URLs and auth endpoints and the fetch readers of
|
|
2276
|
+
# server-sent-events streams (sse/<area>/*.ts, one file per stream, the bearer token from modules/auth). A repository has
|
|
2258
2277
|
# exactly ONE client folder and ONE GraphQLResponse union: the app's (fe.transport.client/outcome) when one app calls the back end,
|
|
2259
2278
|
# or the api package's (fe.package.api.client/outcome) when several do (FE_TRANSPORT_OWNER, machine).
|
|
2260
2279
|
requires: [index.ts]
|
|
2261
2280
|
allows: [index.ts, graphql/index.ts, graphql/types.ts, "graphql/clients/**", "graphql/queries/query-*.ts", "graphql/queries/types/*.ts",
|
|
2262
|
-
"graphql/mutations/mutation-*.ts", "graphql/mutations/types/*.ts", "graphql/__generated__/**", "rest/index.ts", "rest/<area>/*.ts"
|
|
2281
|
+
"graphql/mutations/mutation-*.ts", "graphql/mutations/types/*.ts", "graphql/__generated__/**", "rest/index.ts", "rest/<area>/*.ts",
|
|
2282
|
+
"sse/index.ts", "sse/<area>/*.ts"]
|
|
2263
2283
|
roles: {entry: index.ts, query: "query-*.ts", mutation: "mutation-*.ts"}
|
|
2264
2284
|
# A hook imports its one operation file directly (one file per operation, FE-API-GRAPHQL), never through a barrel of every operation.
|
|
2265
|
-
entries: ["graphql/queries/query-*.ts", "graphql/queries/types/*.ts", "graphql/mutations/mutation-*.ts", "graphql/mutations/types/*.ts"]
|
|
2285
|
+
entries: ["graphql/queries/query-*.ts", "graphql/queries/types/*.ts", "graphql/mutations/mutation-*.ts", "graphql/mutations/types/*.ts", "sse/<area>/*.ts"]
|
|
2266
2286
|
tests: none
|
|
2267
2287
|
rules: [FE_TRANSPORT_OWNER, FE_HTTP_STATUS_COLLAPSE, FE_WIRE_GENERATED]
|
|
2268
2288
|
- id: fe.transport.generated
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* HFS_UNUSED_EXPORT an export of an owner's public entry that no production file outside the owner imports is dead.
|
|
4
4
|
* HFS_UNUSED_FILE a production source file that no root reaches is dead. Roots: the files an app slot requires
|
|
5
5
|
* (main.ts, app.module.ts), every route file (slot tier `route`), the public entry of every package,
|
|
6
|
+
* and an api app operation table `operations.ts` (the contract emit reads it by path),
|
|
6
7
|
* and a source file a framework config of the app names by a relative string literal
|
|
7
8
|
* (next.config.ts pointing next-intl at its request config). Specs and tests are not in the graph,
|
|
8
9
|
* so a file only a spec imports is dead, on purpose. Type-only imports reach a file.
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
* Specs and tests are not in the graph, so an export used only by a spec is dead, on purpose, with these exceptions (unit test
|
|
25
26
|
* standard): the unit spec of every unit role (ruleParams.be.unitRoles: `<name>.service.spec.ts`, `<name>.cli.spec.ts`) and of every logic role
|
|
26
27
|
* (ruleParams.be.logicRoles: `<name>.policy.spec.ts`, `<name>.client.spec.ts`, ...), a
|
|
27
|
-
* unit spec of a webhook, gateway or
|
|
28
|
+
* unit spec of a webhook, gateway, subscription or SSE controller door (`.webhook.spec.ts`, `.gateway.spec.ts`, `.subscription.spec.ts`, `.controller.spec.ts`: a door has no service of its own), a
|
|
28
29
|
* `*.builder.ts` under src/tests/fixtures/builders, and a file of the test world (slots be.tests.world and be.tests.world.kit, which
|
|
29
30
|
* open and migrate the real databases of the e2e run) read from disk or staged in the git index count as consumers, because a spec can only provide an
|
|
30
31
|
* Inject*() token or a param type the entry exports, and the world wires the same capabilities the apps do. An integration
|
|
@@ -47,6 +48,8 @@ export const DEAD_EXPORT_RULE_IDS = ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'];
|
|
|
47
48
|
const PACKAGE_ENTRIES = ['src/index.ts', 'src/index.tsx', 'index.ts', 'index.tsx'];
|
|
48
49
|
const OWNER_ENTRIES = ['index.ts', 'index.tsx'];
|
|
49
50
|
const CHAIN_DEPTH = 6;
|
|
51
|
+
/** The file of an api app that the contract emit reads by path for the typed operation table (BE-OPERATIONS-1): no import reaches it, the emit does. */
|
|
52
|
+
const OPERATION_TABLE_FILE = "operations.ts";
|
|
50
53
|
/** Tiers whose files are not production source that an app serves: judged by no dead-file rule. */
|
|
51
54
|
const UNJUDGED_TIERS = new Set(['none', 'e2e', 'fixtures']);
|
|
52
55
|
const CONFIG_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx', '/index.js'];
|
|
@@ -144,7 +147,7 @@ function rootFiles(graph, config) {
|
|
|
144
147
|
if (!node.owner || node.tier !== 'app') continue;
|
|
145
148
|
const slot = graph.resolver.slot(node.owner.slot);
|
|
146
149
|
const inside = rel.slice(withoutSlash(node.owner.root).length + 1);
|
|
147
|
-
if ((slot?.requires ?? []).includes(inside)) roots.add(rel);
|
|
150
|
+
if ((slot?.requires ?? []).includes(inside) || inside === OPERATION_TABLE_FILE) roots.add(rel);
|
|
148
151
|
}
|
|
149
152
|
for (const [key, owner] of graph.ownerRoots) {
|
|
150
153
|
if (owner.tier !== 'package') continue;
|
|
@@ -177,8 +180,8 @@ function deadFiles(graph, config) {
|
|
|
177
180
|
return { violations, judged: judgedFiles.length, roots: roots.size };
|
|
178
181
|
}
|
|
179
182
|
|
|
180
|
-
/** The unit spec of a webhook, gateway or
|
|
181
|
-
const DOOR_SPEC = /^src\/features\/(?:webhooks|realtime)\/.+\.(?:webhook|gateway|subscription)\.spec\.ts$/u;
|
|
183
|
+
/** The unit spec of a webhook, gateway, subscription or SSE controller door: a door has no service of its own, so its spec is the only unit spec of its kind. */
|
|
184
|
+
const DOOR_SPEC = /^src\/features\/(?:webhooks|realtime)\/.+\.(?:webhook|gateway|subscription|controller)\.spec\.ts$/u;
|
|
182
185
|
const BUILDER_CONSUMER = /^src\/tests\/fixtures\/builders\/.+\.builder\.ts$/u;
|
|
183
186
|
/** The test world slots (knowledge/hfs/slots.yaml): their files wire the real capabilities of the e2e run. */
|
|
184
187
|
const WORLD_SLOTS = new Set(['be.tests.world', 'be.tests.world.kit']);
|
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`)
|
|
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) {
|