@starci/hfs 4.11.15 → 4.11.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.11.17 - 2026-10-30
4
+
5
+ - Fixed: `stemOfEvent` (the event class file name of R151) maps underscores to dashes as well as dots, matching eslint-canon-be's `event-consumer-shape` and the kebab case of `source-suffix`.
6
+ - Fixed: `starci app emit`'s write pass (outDir is the app root) no longer calls `appHasDbTypes`/`writeDbTypes`, whose module (`emit/db-types.mjs`) left with the lite Supabase types in 4.10.0: every write pass aborted after `events.json` and wrote no `schema.graphql` or `openapi.json`. `emitContracts` answers `{ written, skipped, standIns }`; spec: the write pass in `src/input-bounds-emit.spec.mjs`.
7
+ - Changed: the bundled canon pins name ai-router 0.4.0 (mounted keys), hfs 4.11.17, eslint-canon-be 3.8.16, eslint-canon-fe 9.5.18 and the Grammar family 0.15.29. No rule or template changed.
8
+
9
+ ## 4.11.16 - 2026-10-30
10
+
11
+ - Fixed: the back-end skeleton's `OriginGuard` (`platform/http-security`, BE-AUTH-5) requires an actual allowed `Origin` header on a cookie-bearing mutation (`POST`, `PUT`, `PATCH`, `DELETE` with a `Cookie` header) and on a door or handler marked with the new `RequireOrigin()` (`http-security.decorators.ts`, metadata `REQUIRE_ORIGIN_KEY`), which covers the start of a cookie-authenticated flow before the first cookie exists. A missing, empty, opaque (`null`) or foreign `Origin` is refused with `HTTP_SECURITY_ORIGIN_REJECTED` (403), and an allowed `Referer` no longer stands in for it there; `ServerToServer` does not exempt such a request. Safe methods, and mutations without a cookie on unmarked doors, keep their rules (Referer fallback when Origin is absent, the `ServerToServer` exemption). Spec `origin.guard.spec.ts`: class and handler markers, the first sign-in, cookie-bearing requests and the preserved admission paths, split into describe groups within the function budget.
12
+ - Fixed: the GraphQL platform templates' specs (`templates/be/platform/graphql` `graphql-depth.policy.spec.ts` and `localize-errors.mapper.spec.ts`, `templates/be/platform/errors-graphql` `graphql-error.mapper.spec.ts`), which the first GraphQL feature copies into an app, pass the app canon: each builds its collaborators through a Nest testing module (`spec-builds-with-testing-module`: the `ERRORS_SERVICE` and `REQUEST_LOCALE` doubles, the depth limit), optional fields are typed for `exactOptionalPropertyTypes`, and the plugin describe is split under `function-line-budget`. Assertions are unchanged.
13
+ - Changed: R260 `BE_JWT_ALG_PINNED` (machine `jwt-policy`, `runtime/scripts/hfs/architecture/jwt-ast.mjs`, the copy eslint-canon-be 3.8.15 holds) binds the principal by an audience derived from typed configuration (an OIDC verifier such as the Keycloak client of full, or the lite Passport verifier of the app's own tokens) or, for an issuer-wide literal audience (Supabase-style), by rejecting every role other than authenticated; algorithms and the configured issuer are unchanged. The bundled `rules.yaml` states the law. Spec: `src/architecture-jwt-policy.spec.mjs` (a role-free Keycloak verifier with a configured audience is admitted, one with a literal audience refused, in both editions).
14
+ - Changed: the bundled canon pins name hfs 4.11.16, eslint-canon-be 3.8.15, eslint-canon-fe 9.5.17, the Grammar family 0.15.28 and test-world 1.3.2.
15
+
3
16
  ## 4.11.15 - 2026-10-29
4
17
 
5
18
  - Added: `platform/database/returned-rows.policy.ts` in the skeleton of both editions, exported from the database barrel: `rowShape({ column: kind })` builds the guard of the rows a RETURNING clause answers (kinds `string`, `number`, `boolean`, `date`, `json`, and `string?`, `number?`, `boolean?`, `date?` that also admit SQL null; a column the statement does not return is refused), and `returnedRows(raw, isRow)` narrows the result of `manager.query` or the `raw` of a query builder's `execute()` to typed rows with no cast. A result that is not a list of rows (the `[rows, count]` of a bare UPDATE included) or a row the guard refuses throws the new `DATABASE_RETURNING_ROW_REJECTED` (internal) of both editions' `DatabaseErrorCode`. The policy spec proves every kind, null, a missing column and both refusals.
@@ -27,7 +27,6 @@ import { fileURLToPath } from 'node:url';
27
27
  import { emitEvents, eventServices, eventsSnapshotPath } from './events.mjs';
28
28
  import { openapiPath } from './operations.mjs';
29
29
  import { readEnv } from '../runtime/scripts/lib/env.mjs';
30
- import { createSlotResolver, loadSlotManifest, locateDeclaration, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
31
30
 
32
31
  /** The stderr prefix of a dependency the worker could not load and stood in for. */
33
32
  const STAND_IN = 'stand-in ';
@@ -75,37 +74,16 @@ function emitApp(repoRoot, app) {
75
74
  return { graphql: graphql.text, openapi: operations.text, standIns: [...graphql.lines, ...operations.lines] };
76
75
  }
77
76
 
78
- /**
79
- * The app-scope slot view of the repository `repoRoot` belongs to: `{ appRoot, resolver }`, or null when no app declaration
80
- * resolves there (a bare folder is not an app and emits no types). `repoRoot` is the be side folder, so `locateDeclaration`
81
- * answers the app root above it.
82
- */
83
- function appDbScope(repoRoot) {
84
- try {
85
- const { appRoot } = locateDeclaration(repoRoot);
86
- const manifest = loadSlotManifest();
87
- return { appRoot, resolver: createSlotResolver(manifest, readRepoDeclaration(manifest, appRoot)) };
88
- } catch {
89
- return null;
90
- }
91
- }
92
-
93
77
  /**
94
78
  * Writes the snapshots of every api app: `contracts/<app>/schema.graphql` when it serves GraphQL and `contracts/<app>/openapi.json`
95
- * when it has an operation table. Answers `{ written: [paths], skipped: [apps], standIns: { app: [lines] }, types }`, `skipped`
79
+ * when it has an operation table. Answers `{ written: [paths], skipped: [apps], standIns: { app: [lines] } }`, `skipped`
96
80
  * being the apps with neither. `declaration` is the parsed hfs.json. `outDir` (absolute) redirects the snapshots to another
97
81
  * root, keeping their relative paths.
98
- *
99
- * On the real write pass (`outDir` is `repoRoot`) the app's database types are written too when the slot `app.db.types`
100
- * is enabled: `resolver` is the app-scope slot view (built from the declaration at the app root when not passed), `appRoot`
101
- * overrides where the types land, and `run` is the command runner `writeDbTypes` spawns `psql` with. `types` in the answer
102
- * is `writeDbTypes`'s `{ path, changed }`, or null for an app without the slot or a redirected pass.
103
82
  */
104
- export function emitContracts({ repoRoot, declaration, outDir = repoRoot, resolver, appRoot, run } = {}) {
83
+ export function emitContracts({ repoRoot, declaration, outDir = repoRoot } = {}) {
105
84
  const written = [];
106
85
  const skipped = [];
107
86
  const standIns = {};
108
- let types = null;
109
87
  for (const app of apiApps(declaration)) {
110
88
  const emitted = emitApp(repoRoot, app);
111
89
  if (emitted.graphql === null && emitted.openapi === null) {
@@ -130,12 +108,5 @@ export function emitContracts({ repoRoot, declaration, outDir = repoRoot, resolv
130
108
  fs.writeFileSync(target, events);
131
109
  written.push(relative);
132
110
  }
133
- // The generated Database types are an app-level artifact of the write pass only: a redirected outDir is the R23 check
134
- // mode, and L09 judges drift on the same emitted text through the injected emitTypes (emit/db-types.mjs dbTypesEmitter).
135
- if (outDir === repoRoot) {
136
- const scope = resolver === undefined ? appDbScope(repoRoot) : { appRoot: locateDeclaration(repoRoot).appRoot, resolver };
137
- const target = appRoot ?? scope?.appRoot;
138
- if (target !== null && target !== undefined && scope?.resolver != null && appHasDbTypes(scope.resolver)) types = writeDbTypes({ root: target, run });
139
- }
140
- return { written, skipped, standIns, types };
111
+ return { written, skipped, standIns };
141
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.11.15",
3
+ "version": "4.11.17",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.15.27
34
+ version: 0.15.29
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  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.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.8.14
41
+ version: 3.8.16
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  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.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.5.16
48
+ version: 9.5.18
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe 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; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.15.27
55
+ version: 0.15.29
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.15.27
62
+ version: 0.15.29
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -92,21 +92,21 @@ pins:
92
92
  source: packages/jest-preset/package.json
93
93
  why: 'the Jest preset of a back end: the world runner runs up to min(--maxWorkers, slots) world files at once, each in its own process bound to one data slot of the test world (STARCI_TEST_WORLD_SLOT), and reads test-world state protocol 2 only: it pairs exactly with @starci/test-world, and any other state file is JEST_PRESET_WORLD_PAIR_MISMATCH. The unit run writes the lcov Sonar and Codecov import (services only); the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; it ships the unit kit (mockEntityManager, fakeTransaction, fakeQueryBuilder, queueQueryBuilders, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
94
94
  '@starci/test-world':
95
- version: 1.3.1
95
+ version: 1.3.2
96
96
  group: starci
97
97
  install: registry
98
98
  side: be
99
99
  source: packages/test-world/package.json
100
100
  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. A fake declared https (defineHttpFake) serves TLS with the run''s ephemeral in-memory fake CA and publishes its PEM as w.fake.<name>.ca for the app''s own client to trust; verification is never turned off. 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.'
101
101
  '@starci/ai-router':
102
- version: 0.3.0
102
+ version: 0.4.0
103
103
  group: starci
104
104
  install: registry
105
105
  side: be
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.11.15
109
+ version: 4.11.17
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -4346,7 +4346,7 @@ rules:
4346
4346
  - {path: packages/hfs/src/hfs-database-config.spec.mjs, editions: [lite], codes: [DB_EXTENSION_ALLOWED, DB_MIGRATION_DESTRUCTIVE, HFS_SEED_PII, DB_VAULT_WRITE]}
4347
4347
  - id: R260
4348
4348
  code: "BE_JWT_ALG_PINNED"
4349
- law: "A back-end JWT verifier pins a nonempty literal subset of ES256, RS256 and EdDSA and obtains its issuer from configuration. A principal is built only after verification and rejection of every role other than authenticated, in lite and full."
4349
+ law: "Every back-end JWT verifier pins a nonempty literal subset of ES256, RS256 and EdDSA and obtains its issuer from typed configuration. A principal is built only after verification and is bound either by an audience read from typed configuration (an OIDC verifier such as the Keycloak of full, or the app's own Passport access tokens of lite) or, where every token of the issuer shares one audience (Supabase-style), by rejecting every role other than authenticated first, in lite and full."
4350
4350
  kinds: [check, lint]
4351
4351
  gates: ["pre-commit","settle","land","ci"]
4352
4352
  failureCodes: ["BE_JWT_ALG_PINNED"]
@@ -140,7 +140,7 @@ function checkOwnJwks(input) {
140
140
  return { violations, coverage: { status: 'checked', integrationFiles: integrations.length, verifyCalls: counters.verifyCalls, verifiedCalls: counters.verifiedCalls } };
141
141
  }
142
142
 
143
- /** R260 BE_JWT_ALG_PINNED: every jose jwtVerify call pins a literal secure algorithm list, a configured issuer and the authenticated role. */
143
+ /** R260 BE_JWT_ALG_PINNED: every jose jwtVerify call pins a literal secure algorithm list and a configured issuer, and binds the principal by a configured audience or the authenticated role. */
144
144
  export function checkJwtPolicy(input) {
145
145
  const kit = machineKit(input);
146
146
  const violations = [];
@@ -61,28 +61,28 @@ const configType = (checker, node) => {
61
61
  || /(?:Config|Options|Env)$/u.test(symbol.name)));
62
62
  };
63
63
 
64
- /** Trace issuer construction through configuration values and local URL/string transformations. */
65
- const issuerFromConfig = (ts, checker, expression, seen = new Set()) => {
64
+ /** Trace an issuer or audience to typed configuration values through local URL/string transformations. */
65
+ const fromConfig = (ts, checker, expression, seen = new Set()) => {
66
66
  const node = unwrap(ts, expression);
67
67
  if (!node || seen.has(node)) return false;
68
68
  const next = new Set(seen).add(node);
69
69
  if (ts.isIdentifier(node)) {
70
70
  return declarationsOf(ts, checker, node).some(declaration => {
71
- if (ts.isVariableDeclaration(declaration) && declaration.initializer) return issuerFromConfig(ts, checker, declaration.initializer, next);
71
+ if (ts.isVariableDeclaration(declaration) && declaration.initializer) return fromConfig(ts, checker, declaration.initializer, next);
72
72
  if (ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration)) return configType(checker, node);
73
73
  return /(?:\.config|\.env)\.ts$|\/modules\/config\//u.test(String(declaration.getSourceFile().fileName).replaceAll('\\', '/'));
74
74
  });
75
75
  }
76
76
  if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
77
- return issuerFromConfig(ts, checker, node.expression, next) || declarationsOf(ts, checker, node).some(declaration =>
77
+ return fromConfig(ts, checker, node.expression, next) || declarationsOf(ts, checker, node).some(declaration =>
78
78
  (ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration)) && !declaration.initializer && configType(checker, node));
79
79
  }
80
- if (ts.isTemplateExpression(node)) return node.templateSpans.some(span => issuerFromConfig(ts, checker, span.expression, next));
80
+ if (ts.isTemplateExpression(node)) return node.templateSpans.some(span => fromConfig(ts, checker, span.expression, next));
81
81
  if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
82
- return issuerFromConfig(ts, checker, node.left, next) || issuerFromConfig(ts, checker, node.right, next);
82
+ return fromConfig(ts, checker, node.left, next) || fromConfig(ts, checker, node.right, next);
83
83
  }
84
84
  if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
85
- return issuerFromConfig(ts, checker, node.expression, next) || node.arguments?.some(argument => issuerFromConfig(ts, checker, argument, next));
85
+ return fromConfig(ts, checker, node.expression, next) || node.arguments?.some(argument => fromConfig(ts, checker, argument, next));
86
86
  }
87
87
  return false;
88
88
  };
@@ -201,7 +201,12 @@ export const jwtPolicyProblems = (ts, checker, call) => {
201
201
  || options?.properties.some(entry => ts.isSpreadAssignment(entry))) {
202
202
  problems.push('BE_JWT_ALG_PINNED: algorithms must be a non-empty literal subset of ES256, RS256 and EdDSA.');
203
203
  }
204
- if (!issuerFromConfig(ts, checker, value('issuer'))) problems.push('BE_JWT_ALG_PINNED: issuer must derive from typed configuration.');
205
- if (!rejectsOtherRoles(ts, checker, call)) problems.push('BE_JWT_ALG_PINNED: the verified principal must reject every role other than authenticated before returning it.');
204
+ if (!fromConfig(ts, checker, value('issuer'))) problems.push('BE_JWT_ALG_PINNED: issuer must derive from typed configuration.');
205
+ // The principal is bound either by an audience of typed configuration (an OIDC client such as Keycloak, the app's own
206
+ // Passport tokens) or, where the audience is shared by every token of the issuer (Supabase's literal 'authenticated'),
207
+ // by rejecting every role other than authenticated before the principal is returned.
208
+ if (!fromConfig(ts, checker, value('audience')) && !rejectsOtherRoles(ts, checker, call)) {
209
+ problems.push('BE_JWT_ALG_PINNED: an OIDC verifier audience must derive from typed configuration, or the verified principal must reject every role other than authenticated before returning it.');
210
+ }
206
211
  return problems;
207
212
  };
@@ -28,8 +28,9 @@ const TEST_CALLEES = new Set(['it', 'test']);
28
28
  /** The TypeScript compiler of the app (else of the runtime), or null. */
29
29
  const typescriptFor = (repoRoot) => loadTypescript(repoRoot, HERE);
30
30
 
31
- /** The event-bus pattern file name of an event name: dots become dashes (`order.placed` -> `order-placed`). */
32
- export const stemOfEvent = (name) => name.replaceAll('.', '-');
31
+ /** The event-bus pattern file name of an event name: dots and underscores become dashes (`order.placed` -> `order-placed`,
32
+ * `installation_changed` -> `installation-changed`), the kebab case source-suffix gives every file. */
33
+ export const stemOfEvent = (name) => name.replaceAll(/[._]/gu, '-');
33
34
 
34
35
  /** The initializer of a `static` property member with an identifier name, parentheses and `as` casts stripped, else null. */
35
36
  function staticMemberValue(ts, member) {
@@ -1,13 +1,23 @@
1
+ import { Test } from "@nestjs/testing"
1
2
  import { mock } from "@starci/jest-preset"
2
3
  import { GraphQLError } from "graphql"
4
+ import { ERRORS_SERVICE } from "./errors.decorators"
3
5
  import type { ErrorsService } from "./errors.service"
4
6
  import { formatGraphqlError } from "./graphql-error.mapper"
5
7
 
6
8
  const formatted = { message: "raw", path: ["item"], locations: [{ line: 1, column: 3 }] }
7
9
 
10
+ const build = async () => {
11
+ const double = mock<ErrorsService>()
12
+ const moduleRef = await Test.createTestingModule({
13
+ providers: [{ provide: ERRORS_SERVICE, useValue: double }],
14
+ }).compile()
15
+ return moduleRef.get<typeof double>(ERRORS_SERVICE)
16
+ }
17
+
8
18
  describe("formatGraphqlError", () => {
9
- it("keeps the message of a malformed operation and marks it invalid without describing it", () => {
10
- const errors = mock<ErrorsService>()
19
+ it("keeps the message of a malformed operation and marks it invalid without describing it", async () => {
20
+ const errors = await build()
11
21
 
12
22
  expect(formatGraphqlError(errors, formatted, new GraphQLError("GRAPHQL_VALIDATION_FAILED"))).toEqual({
13
23
  message: "raw",
@@ -18,8 +28,8 @@ describe("formatGraphqlError", () => {
18
28
  expect(errors.describe).not.toHaveBeenCalled()
19
29
  })
20
30
 
21
- it("describes the original failure of a resolver and travels its code, kind and params", () => {
22
- const errors = mock<ErrorsService>()
31
+ it("describes the original failure of a resolver and travels its code, kind and params", async () => {
32
+ const errors = await build()
23
33
  const cause = new Error("not found")
24
34
  errors.describe.mockReturnValue({
25
35
  code: "ITEM_NOT_FOUND",
@@ -37,8 +47,8 @@ describe("formatGraphqlError", () => {
37
47
  expect(errors.describe).toHaveBeenCalledWith(cause)
38
48
  })
39
49
 
40
- it("describes a failure that is not a GraphQL error as it is", () => {
41
- const errors = mock<ErrorsService>()
50
+ it("describes a failure that is not a GraphQL error as it is", async () => {
51
+ const errors = await build()
42
52
  const cause = new Error("boom")
43
53
  errors.describe.mockReturnValue({ code: "ERRORS_INTERNAL", kind: "internal", status: 500, params: {} })
44
54
 
@@ -50,9 +60,9 @@ describe("formatGraphqlError", () => {
50
60
  expect(errors.describe).toHaveBeenCalledWith(cause)
51
61
  })
52
62
 
53
- it("leaves out the locations and path a formatted error does not carry", () => {
63
+ it("leaves out the locations and path a formatted error does not carry", async () => {
54
64
  const subject = formatGraphqlError
55
- const errors = mock<ErrorsService>()
65
+ const errors = await build()
56
66
  errors.describe.mockReturnValue({ code: "ERRORS_INTERNAL", kind: "internal", status: 500, params: {} })
57
67
 
58
68
  expect(subject(errors, { message: "raw" }, new GraphQLError("GRAPHQL_PARSE_FAILED"))).toEqual({
@@ -1,3 +1,4 @@
1
+ import { Test } from "@nestjs/testing"
1
2
  import { buildSchema, parse, validate } from "graphql"
2
3
  import { depthLimitRule } from "./graphql-depth.policy"
3
4
 
@@ -12,10 +13,17 @@ const schema = buildSchema(`
12
13
  }
13
14
  `)
14
15
 
15
- const validateDepth = (source: string, maxDepth = 2) => validate(schema, parse(source), [depthLimitRule(maxDepth)])
16
+ const DEPTH_LIMIT = Symbol("spec.depth-limit")
17
+
18
+ const build = async () => {
19
+ const moduleRef = await Test.createTestingModule({ providers: [{ provide: DEPTH_LIMIT, useValue: 2 }] }).compile()
20
+ const maxDepth = moduleRef.get<number>(DEPTH_LIMIT)
21
+ return (source: string) => validate(schema, parse(source), [depthLimitRule(maxDepth)])
22
+ }
16
23
 
17
24
  describe("depthLimitRule", () => {
18
- it("accepts an operation nested exactly to the depth limit", () => {
25
+ it("accepts an operation nested exactly to the depth limit", async () => {
26
+ const validateDepth = await build()
19
27
  const errors = validateDepth(`
20
28
  query {
21
29
  item {
@@ -27,7 +35,8 @@ describe("depthLimitRule", () => {
27
35
  expect(errors).toEqual([])
28
36
  })
29
37
 
30
- it("rejects the first selection set deeper than the limit", () => {
38
+ it("rejects the first selection set deeper than the limit", async () => {
39
+ const validateDepth = await build()
31
40
  const errors = validateDepth(`
32
41
  query {
33
42
  item {
@@ -1,12 +1,36 @@
1
1
  import type { GraphQLRequestContext, GraphQLRequestContextWillSendResponse, GraphQLResponse } from "@apollo/server"
2
+ import { Test } from "@nestjs/testing"
2
3
  import { mock } from "@starci/jest-preset"
3
4
  import type { Request } from "express"
4
5
  import type { GraphQLFormattedError } from "graphql"
6
+ import { ERRORS_SERVICE } from "@modules/platform/errors"
5
7
  import type { ErrorsService } from "@modules/platform/errors"
8
+ import { REQUEST_LOCALE } from "@modules/platform/i18n"
6
9
  import type { RequestLocale } from "@modules/platform/i18n"
7
10
  import type { GraphqlContext } from "./graphql.contracts"
8
11
  import { localizeError, localizeErrorsPlugin } from "./localize-errors.mapper"
9
12
 
13
+ const build = async () => {
14
+ const errorsDouble = mock<ErrorsService>()
15
+ const localeDouble = mock<RequestLocale>()
16
+ const moduleRef = await Test.createTestingModule({
17
+ providers: [
18
+ { provide: ERRORS_SERVICE, useValue: errorsDouble },
19
+ { provide: REQUEST_LOCALE, useValue: localeDouble },
20
+ ],
21
+ }).compile()
22
+ return {
23
+ errors: moduleRef.get<typeof errorsDouble>(ERRORS_SERVICE),
24
+ requestLocale: moduleRef.get<typeof localeDouble>(REQUEST_LOCALE),
25
+ }
26
+ }
27
+
28
+ const errorWith = (extensions?: GraphQLFormattedError["extensions"]): GraphQLFormattedError =>
29
+ extensions === undefined ? { message: "syntax error" } : { message: "syntax error", extensions }
30
+
31
+ const acceptLanguage = (language?: string): Request["headers"] =>
32
+ language === undefined ? {} : { "accept-language": language }
33
+
10
34
  const singleResponse = (errors?: ReadonlyArray<GraphQLFormattedError>): GraphQLResponse =>
11
35
  mock<GraphQLResponse>({ body: { kind: "single", singleResult: errors === undefined ? {} : { errors } } })
12
36
 
@@ -33,17 +57,17 @@ const sendResponse = async (
33
57
  describe("localizeError", () => {
34
58
  it.each([undefined, {}, { code: 400 }, { code: null }, { code: false }])(
35
59
  "returns an error without a string code unchanged (%p)",
36
- (extensions) => {
37
- const errors = mock<ErrorsService>()
38
- const error: GraphQLFormattedError = { message: "syntax error", extensions }
60
+ async (extensions) => {
61
+ const { errors } = await build()
62
+ const error = errorWith(extensions)
39
63
 
40
64
  expect(localizeError(errors, "en", error)).toBe(error)
41
65
  expect(errors.text).not.toHaveBeenCalled()
42
66
  },
43
67
  )
44
68
 
45
- it("replaces a coded error's message with the catalog text and keeps only string and number params", () => {
46
- const errors = mock<ErrorsService>()
69
+ it("replaces a coded error's message with the catalog text and keeps only string and number params", async () => {
70
+ const { errors } = await build()
47
71
  errors.text.mockReturnValue("The item was not found")
48
72
  const error: GraphQLFormattedError = {
49
73
  message: "ITEM_NOT_FOUND",
@@ -67,8 +91,8 @@ describe("localizeError", () => {
67
91
 
68
92
  it.each([undefined, null, ["i-1"], "i-1", 42, false])(
69
93
  "interpolates no params when extensions.params is %p",
70
- (params) => {
71
- const errors = mock<ErrorsService>()
94
+ async (params) => {
95
+ const { errors } = await build()
72
96
  errors.text.mockReturnValue("The item was not found")
73
97
  const error: GraphQLFormattedError = {
74
98
  message: "ITEM_NOT_FOUND",
@@ -83,8 +107,7 @@ describe("localizeError", () => {
83
107
 
84
108
  describe("localizeErrorsPlugin", () => {
85
109
  it("forwards the Accept-Language preferences and localizes every coded error in the resolved locale", async () => {
86
- const errors = mock<ErrorsService>()
87
- const requestLocale = mock<RequestLocale>()
110
+ const { errors, requestLocale } = await build()
88
111
  requestLocale.of.mockReturnValue("vi")
89
112
  errors.text.mockReturnValueOnce("localized: not found").mockReturnValueOnce("localized: denied")
90
113
  const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
@@ -115,15 +138,14 @@ describe("localizeErrorsPlugin", () => {
115
138
  it.each([undefined, ""])(
116
139
  "delegates an absent or empty Accept-Language header to the locale default (%p)",
117
140
  async (language) => {
118
- const errors = mock<ErrorsService>()
119
- const requestLocale = mock<RequestLocale>()
141
+ const { errors, requestLocale } = await build()
120
142
  requestLocale.of.mockReturnValue("vi")
121
143
  errors.text.mockReturnValue("localized: not found")
122
144
  const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
123
145
 
124
146
  const response = singleResponse([coded])
125
147
 
126
- await sendResponse(errors, requestLocale, requestContext(response, { "accept-language": language }))
148
+ await sendResponse(errors, requestLocale, requestContext(response, acceptLanguage(language)))
127
149
 
128
150
  expect(requestLocale.of).toHaveBeenCalledWith(language)
129
151
  expect(errors.text).toHaveBeenCalledWith("ITEM_NOT_FOUND", {}, "vi")
@@ -132,10 +154,11 @@ describe("localizeErrorsPlugin", () => {
132
154
  ])
133
155
  },
134
156
  )
157
+ })
135
158
 
159
+ describe("localizeErrorsPlugin on a response with nothing to localize", () => {
136
160
  it("leaves a response without errors alone", async () => {
137
- const errors = mock<ErrorsService>()
138
- const requestLocale = mock<RequestLocale>()
161
+ const { errors, requestLocale } = await build()
139
162
 
140
163
  const response = singleResponse()
141
164
  const body = response.body
@@ -149,14 +172,13 @@ describe("localizeErrorsPlugin", () => {
149
172
  })
150
173
 
151
174
  it("leaves an incremental response alone", async () => {
152
- const errors = mock<ErrorsService>()
153
- const requestLocale = mock<RequestLocale>()
175
+ const { errors, requestLocale } = await build()
154
176
  const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
155
177
  const response = mock<GraphQLResponse>({
156
178
  body: {
157
179
  kind: "incremental",
158
180
  initialResult: { errors: [coded], hasNext: true },
159
- subsequentResults: mock<AsyncGenerator>(),
181
+ subsequentResults: mock<AsyncIterable<never>>(),
160
182
  },
161
183
  })
162
184
  const body = response.body
@@ -1,8 +1,15 @@
1
+ import { SetMetadata } from "@nestjs/common"
1
2
  import { injector } from "@modules/platform/composition"
2
- import type { TypedParameterDecorator } from "@modules/platform/composition"
3
+ import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
3
4
  import type { HttpSecurityOptions } from "./http-security.options"
4
5
  import type { WebhookSignatureService } from "./webhook-signature.service"
5
6
 
7
+ /** Metadata for doors that require an actual Origin, including the start of a cookie-authenticated flow. */
8
+ export const REQUIRE_ORIGIN_KEY = "platform.http-security.require-origin"
9
+
10
+ /** Requires an allowed Origin on mutations even before the caller has received its first cookie. */
11
+ export const RequireOrigin = (): DoorDecorator => SetMetadata(REQUIRE_ORIGIN_KEY, true)
12
+
6
13
  /** Token of the http-security options, exported so a spec can provide it. */
7
14
  export const HTTP_SECURITY_OPTIONS: unique symbol = Symbol("platform.http-security.options")
8
15
 
@@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core"
5
5
  import { Test } from "@nestjs/testing"
6
6
  import { mock } from "@starci/jest-preset"
7
7
  import { HttpSecurityErrorCode } from "./errors/http-security.error"
8
- import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
8
+ import { HTTP_SECURITY_OPTIONS, RequireOrigin } from "./http-security.decorators"
9
9
  import type { HttpSecurityOptions } from "./http-security.options"
10
10
  import { OriginGuard, ServerToServer } from "./origin.guard"
11
11
 
@@ -17,16 +17,31 @@ const options: HttpSecurityOptions = {
17
17
 
18
18
  class OpenDoor {}
19
19
 
20
+ @RequireOrigin()
21
+ class CookieDoor {}
22
+
23
+ class SignInDoor {
24
+ @RequireOrigin()
25
+ start(): undefined {
26
+ return undefined
27
+ }
28
+ }
29
+
20
30
  class SignedDoor {}
21
31
  ServerToServer()(SignedDoor)
22
32
 
23
- const build = async (method: string, headers: Request["headers"], door: Type<unknown> = OpenDoor) => {
33
+ const build = async (
34
+ method: string,
35
+ headers: Request["headers"],
36
+ door: Type<unknown> = OpenDoor,
37
+ handler: () => void = () => undefined,
38
+ ) => {
24
39
  const request = mock<Request>({ headers, method })
25
40
  const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
26
41
  http.getRequest.mockReturnValue(request)
27
42
  const context = mock<ExecutionContext>()
28
43
  context.switchToHttp.mockReturnValue(http)
29
- context.getHandler.mockReturnValue(() => undefined)
44
+ context.getHandler.mockReturnValue(handler)
30
45
  context.getClass.mockReturnValue(door)
31
46
  const moduleRef = await Test.createTestingModule({
32
47
  providers: [OriginGuard, Reflector, { provide: HTTP_SECURITY_OPTIONS, useValue: options }],
@@ -36,19 +51,23 @@ const build = async (method: string, headers: Request["headers"], door: Type<unk
36
51
 
37
52
  describe("OriginGuard", () => {
38
53
  it.each(["GET", "HEAD", "OPTIONS"])("allows the safe %s method without an origin", async (method) => {
39
- const { guard, context } = await build(method, {})
54
+ const { guard, context } = await build(method, { cookie: "session=opaque" }, CookieDoor)
40
55
 
41
56
  expect(guard.canActivate(context)).toBe(true)
42
57
  })
43
58
 
44
59
  it("allows a state-changing request carrying an allowed Origin header", async () => {
45
- const { guard, context } = await build("POST", { origin: "https://app.test" })
60
+ const { guard, context } = await build("POST", {
61
+ origin: "https://app.test",
62
+ })
46
63
 
47
64
  expect(guard.canActivate(context)).toBe(true)
48
65
  })
49
66
 
50
67
  it("falls back to the origin of an allowed Referer when Origin is absent", async () => {
51
- const { guard, context } = await build("PATCH", { referer: "https://app.test/notes/one" })
68
+ const { guard, context } = await build("PATCH", {
69
+ referer: "https://app.test/notes/one",
70
+ })
52
71
 
53
72
  expect(guard.canActivate(context)).toBe(true)
54
73
  })
@@ -56,12 +75,21 @@ describe("OriginGuard", () => {
56
75
  it.each([
57
76
  { name: "no Origin and no Referer", headers: {} },
58
77
  { name: "a Referer that is not a URL", headers: { referer: "not a URL" } },
59
- { name: "an Origin outside the allowlist", headers: { origin: "https://foreign.test" } },
78
+ {
79
+ name: "an Origin outside the allowlist",
80
+ headers: { origin: "https://foreign.test" },
81
+ },
60
82
  { name: "the opaque null Origin", headers: { origin: "null" } },
61
- { name: "a Referer outside the allowlist", headers: { referer: "https://foreign.test/form" } },
83
+ {
84
+ name: "a Referer outside the allowlist",
85
+ headers: { referer: "https://foreign.test/form" },
86
+ },
62
87
  {
63
88
  name: "a foreign Origin beside an allowed Referer",
64
- headers: { origin: "https://foreign.test", referer: "https://app.test/notes/one" },
89
+ headers: {
90
+ origin: "https://foreign.test",
91
+ referer: "https://app.test/notes/one",
92
+ },
65
93
  },
66
94
  ])("refuses a state-changing request with $name", async ({ headers }) => {
67
95
  const { guard, context } = await build("DELETE", headers)
@@ -75,3 +103,87 @@ describe("OriginGuard", () => {
75
103
  expect(guard.canActivate(context)).toBe(true)
76
104
  })
77
105
  })
106
+
107
+ describe("OriginGuard", () => {
108
+ it.each(["POST", "PUT", "PATCH", "DELETE"])(
109
+ "allows a cookie-bearing %s with an actual allowed Origin",
110
+ async (method) => {
111
+ const { guard, context } = await build(method, {
112
+ origin: "https://app.test",
113
+ cookie: "session=opaque",
114
+ })
115
+
116
+ expect(guard.canActivate(context)).toBe(true)
117
+ },
118
+ )
119
+
120
+ it.each([
121
+ { name: "no Origin", headers: {} },
122
+ {
123
+ name: "an allowed Referer alone",
124
+ headers: { referer: "https://app.test/form" },
125
+ },
126
+ { name: "a malformed Referer alone", headers: { referer: "not a URL" } },
127
+ { name: "a foreign Origin", headers: { origin: "https://foreign.test" } },
128
+ { name: "an opaque Origin", headers: { origin: "null" } },
129
+ { name: "an empty Origin", headers: { origin: "" } },
130
+ {
131
+ name: "a foreign Origin beside an allowed Referer",
132
+ headers: {
133
+ origin: "https://foreign.test",
134
+ referer: "https://app.test/form",
135
+ },
136
+ },
137
+ ])("refuses a cookie-bearing mutation with $name", async ({ headers }) => {
138
+ const { guard, context } = await build("POST", {
139
+ ...headers,
140
+ cookie: "session=opaque",
141
+ })
142
+
143
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
144
+ })
145
+ })
146
+
147
+ describe("OriginGuard", () => {
148
+ it.each([{}, { referer: "https://app.test/sign-in" }])(
149
+ "refuses a marked cookie-flow door without Origin before a cookie exists (%o)",
150
+ async (headers) => {
151
+ const { guard, context } = await build("POST", headers, CookieDoor)
152
+
153
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
154
+ },
155
+ )
156
+
157
+ it("requires Origin on an individually marked handler before a cookie exists", async () => {
158
+ const { guard, context } = await build("POST", {}, SignInDoor, SignInDoor.prototype.start)
159
+
160
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
161
+ })
162
+
163
+ it("admits a marked cookie-flow door with an actual allowed Origin before a cookie exists", async () => {
164
+ const { guard, context } = await build("POST", { origin: "https://app.test" }, CookieDoor)
165
+
166
+ expect(guard.canActivate(context)).toBe(true)
167
+ })
168
+
169
+ it("refuses Referer-only admission when a server-to-server door receives a cookie", async () => {
170
+ const { guard, context } = await build(
171
+ "POST",
172
+ { cookie: "session=opaque", referer: "https://app.test/form" },
173
+ SignedDoor,
174
+ )
175
+
176
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
177
+ })
178
+
179
+ it("refuses Referer-only admission when a server-to-server handler requires Origin", async () => {
180
+ const { guard, context } = await build(
181
+ "POST",
182
+ { referer: "https://app.test/form" },
183
+ SignedDoor,
184
+ SignInDoor.prototype.start,
185
+ )
186
+
187
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
188
+ })
189
+ })
@@ -4,7 +4,7 @@ import type { Reflector } from "@nestjs/core"
4
4
  import { InjectReflector } from "@modules/platform/composition"
5
5
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
6
6
  import { requestOf } from "./execution-request.mapper"
7
- import { InjectHttpSecurityOptions } from "./http-security.decorators"
7
+ import { InjectHttpSecurityOptions, REQUIRE_ORIGIN_KEY } from "./http-security.decorators"
8
8
  import type { HttpSecurityOptions } from "./http-security.options"
9
9
 
10
10
  const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"])
@@ -26,9 +26,8 @@ const originOf = (origin: string | undefined, referer: string | undefined): stri
26
26
 
27
27
  @Injectable()
28
28
  /**
29
- * The second app guard, the CSRF defence: a state-changing request must name an allowed origin, by its Origin header or,
30
- * when Origin is absent, by its Referer. A request that names none is refused, since a session cookie rides on it
31
- * whatever sent it. A door marked `ServerToServer` proves its caller itself and owes no origin.
29
+ * The second app guard, the CSRF defence: cookie-bearing mutations and doors marked RequireOrigin need an actual allowed
30
+ * Origin. Other mutations retain Referer fallback and the ServerToServer exemption for callers proving themselves.
32
31
  */
33
32
  export class OriginGuard implements CanActivate {
34
33
  constructor(
@@ -40,12 +39,19 @@ export class OriginGuard implements CanActivate {
40
39
  canActivate(context: ExecutionContext): boolean {
41
40
  const request = requestOf(context)
42
41
  if (SAFE_METHODS.has(request.method)) return true
42
+ const requiresOrigin =
43
+ this.reflector.getAllAndOverride<boolean | undefined>(REQUIRE_ORIGIN_KEY, [
44
+ context.getHandler(),
45
+ context.getClass(),
46
+ ]) === true || request.headers.cookie !== undefined
43
47
  const exempt = this.reflector.getAllAndOverride<boolean | undefined>(SERVER_TO_SERVER_KEY, [
44
48
  context.getHandler(),
45
49
  context.getClass(),
46
50
  ])
47
- if (exempt === true) return true
48
- const origin = originOf(request.headers.origin, request.headers.referer)
51
+ if (!requiresOrigin && exempt === true) return true
52
+ const origin = requiresOrigin
53
+ ? request.headers.origin
54
+ : originOf(request.headers.origin, request.headers.referer)
49
55
  if (origin !== undefined && this.options.allowedOrigins.includes(origin)) return true
50
56
  throw new HttpSecurityError({ code: HttpSecurityErrorCode.OriginRejected })
51
57
  }