@starci/hfs 4.7.23 → 4.7.25

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/release/candidate-ci.mjs +20 -10
  5. package/release/gate.mjs +37 -19
  6. package/release/push.mjs +1 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  8. package/runtime/knowledge/hfs/ci-standard.yaml +5 -5
  9. package/runtime/knowledge/hfs/rules.yaml +17 -17
  10. package/runtime/knowledge/hfs/slots.yaml +20 -1
  11. package/runtime/knowledge/patterns/be/saga.yaml +1 -1
  12. package/runtime/knowledge/sonar-gate.yaml +12 -12
  13. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +0 -3
  15. package/runtime/packages/cli/src/catalog.generated.mjs +67 -4
  16. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +1 -1
  17. package/runtime/scripts/hfs/ci-declaration.mjs +2 -2
  18. package/runtime/scripts/hfs/rules/ci-standard.mjs +24 -14
  19. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +68 -2
  20. package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
  21. package/runtime/scripts/hfs/rules/unused-symbol.mjs +21 -5
  22. package/runtime/scripts/hfs/slots.mjs +3 -2
  23. package/runtime/scripts/lib/gate-loosening.mjs +248 -0
  24. package/runtime/scripts/lib/managed-json.mjs +51 -0
  25. package/runtime/scripts/lib/uat-run-folder.mjs +118 -0
  26. package/stack/prepare.mjs +10 -3
  27. package/stack/release-store.mjs +3 -3
  28. package/stack/remote.mjs +9 -0
  29. package/sync/ci-commands.mjs +38 -90
  30. package/sync/index.mjs +12 -6
  31. package/templates/app/browser-config/browser/tsconfig.json +11 -0
  32. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -3
  33. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -3
  34. package/templates/app/hooks/husky/pre-push +4 -2
  35. package/templates/app/package-scripts/package.json +0 -1
  36. package/templates/app/package-scripts-lite/package.json +0 -1
  37. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +5 -6
  38. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -6
  39. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +5 -6
  40. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +6 -2
  41. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +4 -1
  42. package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +5 -2
  43. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +1 -1
  44. package/templates/be/patterns/saga/saga.service.spec.ts.tpl +4 -1
  45. package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +15 -0
  46. package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +4 -4
  47. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +15 -12
  48. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +6 -2
  49. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +2 -6
  50. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +1 -1
  51. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +1 -1
  52. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +3 -0
  53. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +2 -2
  54. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +35 -9
  55. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +14 -1
  56. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +1 -3
  57. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +5 -7
  58. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +5 -2
  59. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +4 -1
  60. package/templates/be/skeleton/src/modules/platform/http-security/request-validation.service.spec.ts +1 -1
  61. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +8 -10
  62. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +3 -2
  63. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +15 -7
  64. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +2 -2
  65. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +2 -2
  66. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +2 -2
  67. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +6 -2
  68. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +16 -10
  69. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +5 -1
  70. package/templates/fe/auth/supabase-client.ts.tpl +11 -7
  71. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +1 -1
  72. package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +1 -1
  73. package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +1 -4
  74. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +18 -3
  75. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +4 -4
  76. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +11 -1
  77. package/templates/fe/transport/graphql/clients/clients/auth.ts +2 -2
  78. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +2 -2
  79. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +2 -1
  80. package/templates/fe/transport/graphql/clients/links/context-headers.ts +9 -0
  81. package/templates/fe/transport/graphql/clients/links/http.ts +3 -3
  82. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -1
  83. package/templates/fe/transport/graphql/clients/response.ts +1 -1
@@ -8,7 +8,7 @@ import type { GraphqlContext } from "./graphql.contracts"
8
8
  import { localizeError, localizeErrorsPlugin } from "./localize-errors.mapper"
9
9
 
10
10
  const singleResponse = (errors?: ReadonlyArray<GraphQLFormattedError>): GraphQLResponse =>
11
- mock<GraphQLResponse>({ body: { kind: "single", singleResult: { errors } } })
11
+ mock<GraphQLResponse>({ body: { kind: "single", singleResult: errors === undefined ? {} : { errors } } })
12
12
 
13
13
  const requestContext = (
14
14
  response: GraphQLResponse,
@@ -65,17 +65,20 @@ describe("localizeError", () => {
65
65
  expect(errors.text).toHaveBeenCalledWith("ITEM_NOT_FOUND", { id: "i-1", retries: 2 }, "en")
66
66
  })
67
67
 
68
- it.each([undefined, null, ["i-1"], "i-1", 42, false])("interpolates no params when extensions.params is %p", (params) => {
69
- const errors = mock<ErrorsService>()
70
- errors.text.mockReturnValue("The item was not found")
71
- const error: GraphQLFormattedError = {
72
- message: "ITEM_NOT_FOUND",
73
- extensions: { code: "ITEM_NOT_FOUND", params },
74
- }
75
-
76
- expect(localizeError(errors, "en", error).message).toBe("The item was not found")
77
- expect(errors.text).toHaveBeenCalledWith("ITEM_NOT_FOUND", {}, "en")
78
- })
68
+ it.each([undefined, null, ["i-1"], "i-1", 42, false])(
69
+ "interpolates no params when extensions.params is %p",
70
+ (params) => {
71
+ const errors = mock<ErrorsService>()
72
+ errors.text.mockReturnValue("The item was not found")
73
+ const error: GraphQLFormattedError = {
74
+ message: "ITEM_NOT_FOUND",
75
+ extensions: { code: "ITEM_NOT_FOUND", params },
76
+ }
77
+
78
+ expect(localizeError(errors, "en", error).message).toBe("The item was not found")
79
+ expect(errors.text).toHaveBeenCalledWith("ITEM_NOT_FOUND", {}, "en")
80
+ },
81
+ )
79
82
  })
80
83
 
81
84
  describe("localizeErrorsPlugin", () => {
@@ -6,10 +6,14 @@ import { PublicReason } from "./identity.contracts"
6
6
 
7
7
  const ADMISSION: unique symbol = Symbol("identity.admission")
8
8
 
9
+ /** A record is read as metadata whatever its fields: the admission under test is what judges the reason. */
10
+ const isMetadata = (value: unknown): value is PublicMetadata => typeof value === "object" && value !== null
11
+
9
12
  const runtimeMetadata = (source: string): PublicMetadata => {
10
13
  try {
11
- const metadata: PublicMetadata = JSON.parse(source)
12
- return metadata
14
+ const parsed: unknown = JSON.parse(source)
15
+ if (!isMetadata(parsed)) throw new IdentityError({ code: IdentityErrorCode.Unauthenticated })
16
+ return parsed
13
17
  } catch (cause) {
14
18
  throw new IdentityError({ code: IdentityErrorCode.Unauthenticated, cause })
15
19
  }
@@ -50,9 +50,7 @@ describe("AuthGuard", () => {
50
50
  it("refuses every door that is neither public nor credential-marked", async () => {
51
51
  const guard = await build({})
52
52
 
53
- expect(() => guard.canActivate(contextOf())).toThrow(
54
- expect.objectContaining({ code: IdentityErrorCode.Unauthenticated }),
55
- )
53
+ expect(() => guard.canActivate(contextOf())).toThrow(IdentityErrorCode.Unauthenticated)
56
54
  })
57
55
 
58
56
  it("lets a credential door through once the credential principal is set", async () => {
@@ -64,8 +62,6 @@ describe("AuthGuard", () => {
64
62
  it("refuses a credential door without a credential principal", async () => {
65
63
  const guard = await build({ credential: { kind: "api-key" } })
66
64
 
67
- expect(() => guard.canActivate(contextOf())).toThrow(
68
- expect.objectContaining({ code: IdentityErrorCode.Unauthenticated }),
69
- )
65
+ expect(() => guard.canActivate(contextOf())).toThrow(IdentityErrorCode.Unauthenticated)
70
66
  })
71
67
  })
@@ -109,7 +109,7 @@ describe("CredentialGuard", () => {
109
109
  async (params) => {
110
110
  const metadata = { kind: "tool-token", workflow: "checkout" } as const
111
111
  const { guard, authenticator } = await build(metadata, PRINCIPAL)
112
- const request: FakeRequest = { headers: {}, params }
112
+ const request: FakeRequest = { headers: {}, ...(params === undefined ? {} : { params }) }
113
113
 
114
114
  await expect(guard.canActivate(contextOf(request))).resolves.toBe(true)
115
115
  expect(authenticator.authenticate).toHaveBeenCalledWith(metadata, "", undefined)
@@ -27,7 +27,7 @@ export class CredentialGuard implements CanActivate {
27
27
  ])
28
28
  if (metadata === undefined) return true
29
29
  const request = requestOf(context)
30
- request.credentialPrincipal = undefined
30
+ delete request.credentialPrincipal
31
31
  const id: unknown = request.params?.id
32
32
  const principal = await this.authenticator.authenticate(
33
33
  metadata,
@@ -18,3 +18,6 @@ export abstract class DomainError<C extends string> extends Error {
18
18
  this.params = init.params ?? {}
19
19
  }
20
20
  }
21
+
22
+ /** True for a DomainError of any capability. A generic class narrows `unknown` to `DomainError<any>`; this guard narrows to `DomainError<string>`, so its code is a string. */
23
+ export const isDomainError = (value: unknown): value is DomainError<string> => value instanceof DomainError
@@ -3,7 +3,7 @@ import { InjectLogger } from "@modules/platform/logging"
3
3
  import type { Logger } from "@modules/platform/logging"
4
4
  import { InjectMessageCatalog } from "@modules/platform/i18n"
5
5
  import type { Locale, MessageCatalog } from "@modules/platform/i18n"
6
- import { DomainError } from "./domain.error"
6
+ import { isDomainError } from "./domain.error"
7
7
  import { InjectErrorsOptions } from "./errors.decorators"
8
8
  import type { ErrorDescription, ErrorKind, ErrorParams } from "./errors.contracts"
9
9
  import { ERRORS_ERROR_KINDS, ErrorsErrorCode } from "./errors/errors.error"
@@ -26,7 +26,7 @@ export class ErrorsService {
26
26
 
27
27
  /** Describes `error`: a DomainError of a composed capability keeps its code; anything else is logged and masked as internal. */
28
28
  describe(error: unknown): ErrorDescription {
29
- if (error instanceof DomainError) {
29
+ if (isDomainError(error)) {
30
30
  const kind = this.kinds.get(error.code)
31
31
  if (kind !== undefined) return this.build(error.code, kind, error.params)
32
32
  }
@@ -52,18 +52,14 @@ describe("HTTP edge origin validation", () => {
52
52
  "ftp://web.example",
53
53
  ])("rejects wildcard, malformed and non-origin allowlist entry %s at boot", async (origin) => {
54
54
  const edge = await buildEdge()
55
- expect(() => edge.validateAllowedOrigins([origin], true)).toThrow(
56
- expect.objectContaining({ code: "HTTP_SECURITY_REQUEST_INVALID" }),
57
- )
55
+ expect(() => edge.validateAllowedOrigins([origin], true)).toThrow("HTTP_SECURITY_REQUEST_INVALID")
58
56
  })
59
57
 
60
58
  it("rejects an empty allowlist and HTTP origins without development permission", async () => {
61
59
  const edge = await buildEdge()
62
- expect(() => edge.validateAllowedOrigins([], false)).toThrow(
63
- expect.objectContaining({ code: "HTTP_SECURITY_REQUEST_INVALID" }),
64
- )
60
+ expect(() => edge.validateAllowedOrigins([], false)).toThrow("HTTP_SECURITY_REQUEST_INVALID")
65
61
  expect(() => edge.validateAllowedOrigins(["http://web.example"], false)).toThrow(
66
- expect.objectContaining({ code: "HTTP_SECURITY_REQUEST_INVALID" }),
62
+ "HTTP_SECURITY_REQUEST_INVALID",
67
63
  )
68
64
  })
69
65
  })
@@ -121,10 +117,40 @@ describe("HTTP edge installation", () => {
121
117
  async (trustProxy) => {
122
118
  const edge = await buildEdge()
123
119
  const { app } = appWithServer()
124
- expect(() => edge.applyHttpSecurity(app, options, { trustProxy })).toThrow(
125
- expect.objectContaining({ code: "HTTP_SECURITY_REQUEST_INVALID" }),
120
+ expect(() => edge.applyHttpSecurity(app, options, { trustProxy })).toThrow("HTTP_SECURITY_REQUEST_INVALID")
121
+ expect(app.enableCors).not.toHaveBeenCalled()
122
+ },
123
+ )
124
+ })
125
+
126
+ describe("HTTP edge adapter", () => {
127
+ it.each([null, 1, {}, { disable: jest.fn() }])(
128
+ "refuses an HTTP adapter whose instance %p is no express application",
129
+ async (instance) => {
130
+ const edge = await buildEdge()
131
+ const { app } = appWithServer()
132
+ const adapter = mock<ReturnType<INestApplication["getHttpAdapter"]>>()
133
+ adapter.getInstance.mockReturnValue(instance)
134
+ app.getHttpAdapter.mockReturnValue(adapter)
135
+ expect(() => edge.applyHttpSecurity(app, options, { trustProxy: 1 })).toThrow(
136
+ "HTTP_SECURITY_REQUEST_INVALID",
126
137
  )
127
138
  expect(app.enableCors).not.toHaveBeenCalled()
128
139
  },
129
140
  )
141
+
142
+ it("accepts an express application that is a function or a plain object", async () => {
143
+ const edge = await buildEdge()
144
+ const callable = Object.assign(() => undefined, { disable: jest.fn(), set: jest.fn() })
145
+ const plain = { disable: jest.fn(), set: jest.fn() }
146
+ for (const server of [callable, plain]) {
147
+ const { app } = appWithServer()
148
+ const adapter = mock<ReturnType<INestApplication["getHttpAdapter"]>>()
149
+ adapter.getInstance.mockReturnValue(server)
150
+ app.getHttpAdapter.mockReturnValue(adapter)
151
+ edge.applyHttpSecurity(app, options, { trustProxy: 1 })
152
+ expect(server.disable).toHaveBeenCalledWith("x-powered-by")
153
+ expect(server.set).toHaveBeenCalledWith("trust proxy", 1)
154
+ }
155
+ })
130
156
  })
@@ -3,6 +3,19 @@ import type { Express, RequestHandler } from "express"
3
3
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
4
4
  import type { HttpSecurityOptions } from "./http-security.options"
5
5
 
6
+ /** The express application behind the Nest adapter. Nest types the instance `any`; it is read as `unknown` and checked before use. */
7
+ const isExpressApplication = (value: unknown): value is Express =>
8
+ (typeof value === "function" || (typeof value === "object" && value !== null)) &&
9
+ typeof Reflect.get(value, "disable") === "function" &&
10
+ typeof Reflect.get(value, "set") === "function"
11
+
12
+ const expressOf = (app: INestApplication): Express => {
13
+ const instance: unknown = app.getHttpAdapter().getInstance()
14
+ if (!isExpressApplication(instance))
15
+ throw new HttpSecurityError({ code: HttpSecurityErrorCode.RequestInvalid, params: { fields: "HTTP_ADAPTER" } })
16
+ return instance
17
+ }
18
+
6
19
  /** The declared ingress hop count and the environment in which HTTP origins may be used. */
7
20
  export interface HttpEdgeOptions {
8
21
  /** Exact trusted proxy hops; the standard API stack has one ingress in front of it. */
@@ -50,7 +63,7 @@ export const applyHttpSecurity = (app: INestApplication, options: HttpSecurityOp
50
63
  code: HttpSecurityErrorCode.RequestInvalid,
51
64
  params: { fields: "HTTP_SECURITY_TRUST_PROXY" },
52
65
  })
53
- const server: Express = app.getHttpAdapter().getInstance()
66
+ const server = expressOf(app)
54
67
  server.disable("x-powered-by")
55
68
  server.set("trust proxy", edge.trustProxy)
56
69
  app.use(securityHeaders)
@@ -56,8 +56,6 @@ describe("OriginGuard", () => {
56
56
  it("refuses a state-changing request from an origin outside the allowlist", async () => {
57
57
  const { guard, context } = await build("DELETE", { origin: "https://foreign.test" })
58
58
 
59
- expect(() => guard.canActivate(context)).toThrow(
60
- expect.objectContaining({ code: HttpSecurityErrorCode.OriginRejected }),
61
- )
59
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
62
60
  })
63
61
  })
@@ -43,8 +43,6 @@ const contextOf = (ip: string | undefined, handler = () => undefined, door: Type
43
43
  return { context, setIp: (value: string | undefined) => (address = value) }
44
44
  }
45
45
 
46
- const RATE_LIMITED = expect.objectContaining({ code: HttpSecurityErrorCode.RateLimited })
47
-
48
46
  describe("RateLimit", () => {
49
47
  it("marks a door with the tier it declares, and the guard counts it under that tier's limit", async () => {
50
48
  class Door {}
@@ -55,7 +53,7 @@ describe("RateLimit", () => {
55
53
 
56
54
  expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
57
55
  expect(guard.canActivate(context)).toBe(true)
58
- expect(() => guard.canActivate(context)).toThrow(RATE_LIMITED)
56
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
59
57
  expect(reflector.getAllAndOverride).toHaveBeenCalledWith("platform.http-security.rate-tier", [handler, Door])
60
58
  })
61
59
  })
@@ -67,7 +65,7 @@ describe("RateLimitGuard", () => {
67
65
 
68
66
  expect(guard.canActivate(context)).toBe(true)
69
67
  expect(guard.canActivate(context)).toBe(true)
70
- expect(() => guard.canActivate(context)).toThrow(RATE_LIMITED)
68
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
71
69
  })
72
70
 
73
71
  it("opens a fresh window for a caller whose bucket expired", async () => {
@@ -77,7 +75,7 @@ describe("RateLimitGuard", () => {
77
75
 
78
76
  guard.canActivate(context)
79
77
  guard.canActivate(context)
80
- expect(() => guard.canActivate(context)).toThrow(RATE_LIMITED)
78
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
81
79
 
82
80
  clock.advance(60_001)
83
81
 
@@ -90,7 +88,7 @@ describe("RateLimitGuard", () => {
90
88
 
91
89
  guard.canActivate(context)
92
90
  guard.canActivate(context)
93
- expect(() => guard.canActivate(context)).toThrow(RATE_LIMITED)
91
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
94
92
  })
95
93
 
96
94
  it("prunes the expired buckets when the map outgrows the bucket cap", async () => {
@@ -108,6 +106,6 @@ describe("RateLimitGuard", () => {
108
106
 
109
107
  expect(guard.canActivate(context)).toBe(true)
110
108
  expect(guard.canActivate(context)).toBe(true)
111
- expect(() => guard.canActivate(context)).toThrow(RATE_LIMITED)
109
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
112
110
  })
113
111
  })
@@ -7,6 +7,9 @@ import type { Timer } from "@modules/platform/retry"
7
7
  import { HTTP_SECURITY_ERROR_KINDS, HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
8
8
  import { ReadinessService } from "./readiness.service"
9
9
 
10
+ /** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
11
+ const anyOf = (type: unknown): unknown => expect.any(type)
12
+
10
13
  const build = async () => {
11
14
  const database = mock<DatabaseProbeService>()
12
15
  const timer = mock<Timer>()
@@ -30,7 +33,7 @@ describe("ReadinessService", () => {
30
33
  await expect(service.probeRequired()).resolves.toEqual({ ok: true })
31
34
 
32
35
  expect(database.probe).toHaveBeenCalledTimes(1)
33
- expect(timer.schedule).toHaveBeenCalledWith(expect.any(Function), 1000)
36
+ expect(timer.schedule).toHaveBeenCalledWith(anyOf(Function), 1000)
34
37
  })
35
38
 
36
39
  it("preserves the database failure under the unavailable error kind that answers 503", async () => {
@@ -54,7 +57,7 @@ describe("ReadinessService", () => {
54
57
  cause: new Error("Required database readiness timed out"),
55
58
  })
56
59
 
57
- expect(timer.schedule).toHaveBeenCalledWith(expect.any(Function), 1000)
60
+ expect(timer.schedule).toHaveBeenCalledWith(anyOf(Function), 1000)
58
61
  timer.schedule.mock.calls.at(0)?.[0]()
59
62
  await expected
60
63
  expect(cancel).toHaveBeenCalledTimes(1)
@@ -6,6 +6,9 @@ import type { Timer } from "@modules/platform/retry"
6
6
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
7
7
  import type { ReadinessReport } from "./readiness.contracts"
8
8
 
9
+ /** The cancel of a deadline that was never scheduled. */
10
+ const noCancel = (): void => undefined
11
+
9
12
  const READINESS_TIMEOUT_MS = 1000
10
13
 
11
14
  @Injectable()
@@ -18,7 +21,7 @@ export class ReadinessService {
18
21
 
19
22
  /** Returns ready only when the required primary database answers within one second. */
20
23
  async probeRequired(): Promise<ReadinessReport> {
21
- let cancel: () => void = () => undefined
24
+ let cancel: () => void = noCancel
22
25
  try {
23
26
  await Promise.race([
24
27
  this.database.probe(),
@@ -17,7 +17,7 @@ describe("RequestValidationService", () => {
17
17
  const metadata = { type: "body" as const, metatype: SignUpBody }
18
18
 
19
19
  it("returns the body as an instance of its declared class", async () => {
20
- const body = await (await build()).transform({ name: "An Nguyen" }, metadata)
20
+ const body: unknown = await (await build()).transform({ name: "An Nguyen" }, metadata)
21
21
 
22
22
  expect(body).toBeInstanceOf(SignUpBody)
23
23
  expect(body).toEqual({ name: "An Nguyen" })
@@ -34,8 +34,6 @@ const build = async () => {
34
34
  return { service: moduleRef.get(WebhookSignatureService), clock }
35
35
  }
36
36
 
37
- const refusal = (code: HttpSecurityErrorCode) => expect.objectContaining({ code })
38
-
39
37
  const signedPartCases = () => {
40
38
  it("accepts a current delivery signed with the configured secret", async () => {
41
39
  const { service } = await build()
@@ -61,7 +59,7 @@ const signedPartCases = () => {
61
59
  signature: "sha256=x",
62
60
  timestamp: "1",
63
61
  }),
64
- ).toThrow(refusal(HttpSecurityErrorCode.WebhookProviderUnknown))
62
+ ).toThrow(HttpSecurityErrorCode.WebhookProviderUnknown)
65
63
  })
66
64
 
67
65
  it.each([
@@ -77,7 +75,7 @@ const signedPartCases = () => {
77
75
  const { service } = await build()
78
76
 
79
77
  expect(() => service.verify({ provider: "payments", ...part })).toThrow(
80
- refusal(HttpSecurityErrorCode.WebhookSignatureInvalid),
78
+ HttpSecurityErrorCode.WebhookSignatureInvalid,
81
79
  )
82
80
  })
83
81
  }
@@ -94,7 +92,7 @@ const digestCases = () => {
94
92
  signature: sign(timestamp),
95
93
  timestamp,
96
94
  }),
97
- ).toThrow(refusal(HttpSecurityErrorCode.WebhookSignatureInvalid))
95
+ ).toThrow(HttpSecurityErrorCode.WebhookSignatureInvalid)
98
96
  })
99
97
 
100
98
  it("refuses a signature of another length and one made with another secret", async () => {
@@ -108,7 +106,7 @@ const digestCases = () => {
108
106
  signature: "sha256=abc",
109
107
  timestamp,
110
108
  }),
111
- ).toThrow(refusal(HttpSecurityErrorCode.WebhookSignatureInvalid))
109
+ ).toThrow(HttpSecurityErrorCode.WebhookSignatureInvalid)
112
110
  expect(() =>
113
111
  service.verify({
114
112
  provider: "payments",
@@ -116,7 +114,7 @@ const digestCases = () => {
116
114
  signature: `sha256=${sign(timestamp, BODY, "other")}`,
117
115
  timestamp,
118
116
  }),
119
- ).toThrow(refusal(HttpSecurityErrorCode.WebhookSignatureInvalid))
117
+ ).toThrow(HttpSecurityErrorCode.WebhookSignatureInvalid)
120
118
  })
121
119
 
122
120
  it("refuses a delivery whose body was changed after signing", async () => {
@@ -130,7 +128,7 @@ const digestCases = () => {
130
128
  signature: `sha256=${sign(timestamp)}`,
131
129
  timestamp,
132
130
  }),
133
- ).toThrow(refusal(HttpSecurityErrorCode.WebhookSignatureInvalid))
131
+ ).toThrow(HttpSecurityErrorCode.WebhookSignatureInvalid)
134
132
  })
135
133
  }
136
134
 
@@ -147,7 +145,7 @@ const replayWindowCases = () => {
147
145
  signature: `sha256=${sign(stale)}`,
148
146
  timestamp: stale,
149
147
  }),
150
- ).toThrow(refusal(HttpSecurityErrorCode.WebhookReplayed))
148
+ ).toThrow(HttpSecurityErrorCode.WebhookReplayed)
151
149
  expect(() =>
152
150
  service.verify({
153
151
  provider: "payments",
@@ -155,7 +153,7 @@ const replayWindowCases = () => {
155
153
  signature: `sha256=${sign(future)}`,
156
154
  timestamp: future,
157
155
  }),
158
- ).toThrow(refusal(HttpSecurityErrorCode.WebhookReplayed))
156
+ ).toThrow(HttpSecurityErrorCode.WebhookReplayed)
159
157
  })
160
158
 
161
159
  it("accepts a delivery exactly at the edge of the replay window", async () => {
@@ -10,8 +10,9 @@ type WrittenLine = { stream: "out" | "err"; line: string }
10
10
 
11
11
  const readJsonLine = (line: string | undefined): Record<string, unknown> => {
12
12
  try {
13
- const entry: Record<string, unknown> = JSON.parse(line ?? "")
14
- return entry
13
+ const entry: unknown = JSON.parse(line ?? "")
14
+ if (typeof entry !== "object" || entry === null) return {}
15
+ return { ...entry }
15
16
  } catch (error) {
16
17
  throw error
17
18
  }
@@ -2,6 +2,16 @@ import { Test } from "@nestjs/testing"
2
2
  import { DomainError } from "@modules/platform/errors"
3
3
  import { ok, refused, unwrapOutcome } from "./outcome.mapper"
4
4
 
5
+ /** What `run` throws, read as `unknown`; the matcher then judges its fields. */
6
+ const thrownBy = (run: () => unknown): unknown => {
7
+ try {
8
+ run()
9
+ } catch (error) {
10
+ return error
11
+ }
12
+ return undefined
13
+ }
14
+
5
15
  type CapabilityErrorCode = "CAPABILITY_REFUSED"
6
16
 
7
17
  class CapabilityError extends DomainError<CapabilityErrorCode> {}
@@ -54,13 +64,11 @@ describe("outcome mappers", () => {
54
64
  it("turns a refusal into the capability error with its parameters", () => {
55
65
  const outcome = subject.refused<CapabilityErrorCode>("CAPABILITY_REFUSED", { resource: "value-1" })
56
66
 
57
- expect(() => subject.unwrapOutcome(outcome, CapabilityError)).toThrow(
58
- expect.objectContaining({
59
- name: "CapabilityError",
60
- code: "CAPABILITY_REFUSED",
61
- params: { resource: "value-1" },
62
- }),
63
- )
67
+ expect(thrownBy(() => subject.unwrapOutcome(outcome, CapabilityError))).toMatchObject({
68
+ name: "CapabilityError",
69
+ code: "CAPABILITY_REFUSED",
70
+ params: { resource: "value-1" },
71
+ })
64
72
  })
65
73
  })
66
74
  })
@@ -8,7 +8,7 @@ export const ok = <V>(value: V): OutcomeOk<V> => ({ kind: "ok", value })
8
8
  export const refused = <C extends string>(code: C, params?: ErrorParams): OutcomeRefused<C> => ({
9
9
  kind: "refused",
10
10
  code,
11
- params,
11
+ ...(params === undefined ? {} : { params }),
12
12
  })
13
13
 
14
14
  /**
@@ -20,5 +20,5 @@ export const unwrapOutcome = <V, C extends K, K extends string>(
20
20
  ErrorClass: OutcomeErrorClass<K>,
21
21
  ): V => {
22
22
  if (outcome.kind === "ok") return outcome.value
23
- throw new ErrorClass({ code: outcome.code, params: outcome.params })
23
+ throw new ErrorClass({ code: outcome.code, ...(outcome.params === undefined ? {} : { params: outcome.params }) })
24
24
  }
@@ -45,7 +45,7 @@ describe("eachInOrder", () => {
45
45
  if (item === 2) throw new SequenceError({ code: "STEP_FAILED" })
46
46
  })
47
47
 
48
- await expect(run).rejects.toThrow(expect.objectContaining({ code: "STEP_FAILED" }))
48
+ await expect(run).rejects.toThrow("STEP_FAILED")
49
49
  expect(started).toEqual([1, 2])
50
50
  })
51
51
 
@@ -54,6 +54,6 @@ describe("eachInOrder", () => {
54
54
  throw new SequenceError({ code: "STEP_FAILED" })
55
55
  })
56
56
 
57
- await expect(run).rejects.toThrow(expect.objectContaining({ code: "STEP_FAILED" }))
57
+ await expect(run).rejects.toThrow("STEP_FAILED")
58
58
  })
59
59
  })
@@ -43,12 +43,12 @@ describe("HTTP security environment contract", () => {
43
43
  HTTP_SECURITY_ALLOWED_ORIGINS: "http://web.example",
44
44
  }),
45
45
  ),
46
- ).toThrow(expect.objectContaining({ code: "HTTP_SECURITY_REQUEST_INVALID" }))
46
+ ).toThrow("HTTP_SECURITY_REQUEST_INVALID")
47
47
  })
48
48
 
49
49
  it("rejects an empty allowlist entry after a comma", () => {
50
50
  expect(() =>
51
51
  parseHttpSecurityConfig(new EnvSource({ HTTP_SECURITY_ALLOWED_ORIGINS: "https://web.example," })),
52
- ).toThrow(expect.objectContaining({ code: "HTTP_SECURITY_REQUEST_INVALID" }))
52
+ ).toThrow("HTTP_SECURITY_REQUEST_INVALID")
53
53
  })
54
54
  })
@@ -9,10 +9,14 @@ const principal: Principal = { id: "user-1", role: "authenticated" }
9
9
 
10
10
  const ADMISSION: unique symbol = Symbol("identity.admission")
11
11
 
12
+ /** A record is read as metadata whatever its fields: the admission under test is what judges the reason. */
13
+ const isMetadata = (value: unknown): value is PublicMetadata => typeof value === "object" && value !== null
14
+
12
15
  const runtimeMetadata = (source: string): PublicMetadata => {
13
16
  try {
14
- const metadata: PublicMetadata = JSON.parse(source)
15
- return metadata
17
+ const parsed: unknown = JSON.parse(source)
18
+ if (!isMetadata(parsed)) throw new IdentityError({ code: IdentityErrorCode.Unauthenticated })
19
+ return parsed
16
20
  } catch (cause) {
17
21
  throw new IdentityError({ code: IdentityErrorCode.Unauthenticated, cause })
18
22
  }
@@ -18,6 +18,13 @@ const principal: Principal = { id: "user-1", role: "authenticated" }
18
18
  const API_KEY: CredentialPrincipal = { kind: "api-key", subject: "key-1", workspaceId: "ws-1" }
19
19
  const TOOL_TOKEN: CredentialPrincipal = { kind: "tool-token", subject: "tool-1", workspaceId: "ws-1" }
20
20
 
21
+ /** The slice of the request the guard reads; an absent principal is an explicit undefined, never a mock function. */
22
+ interface GuardRequest {
23
+ headers: Request["headers"]
24
+ principal: Principal | undefined
25
+ credentialPrincipal: CredentialPrincipal | undefined
26
+ }
27
+
21
28
  const build = async (metadata: PublicMetadata | undefined, credential?: CredentialMetadata) => {
22
29
  const verifier = mock<{ verify: VerifySupabaseAccessToken }>()
23
30
  const reflector = mock<Reflector>()
@@ -39,10 +46,11 @@ const contextOf = (
39
46
  credentialPrincipal?: CredentialPrincipal,
40
47
  sessionPrincipal?: Principal,
41
48
  ) => {
42
- const request = mock<Request>()
43
- request.headers = authorization === undefined ? {} : { authorization }
44
- request.principal = sessionPrincipal
45
- request.credentialPrincipal = credentialPrincipal
49
+ const request = mock<GuardRequest>({
50
+ headers: authorization === undefined ? {} : { authorization },
51
+ principal: sessionPrincipal,
52
+ credentialPrincipal,
53
+ })
46
54
  const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
47
55
  http.getRequest.mockReturnValue(request)
48
56
  const context = mock<ExecutionContext>()
@@ -53,8 +61,6 @@ const contextOf = (
53
61
  return { context, request }
54
62
  }
55
63
 
56
- const unauthenticated = expect.objectContaining({ code: IdentityErrorCode.Unauthenticated })
57
-
58
64
  describe("AuthGuard", () => {
59
65
  it("lets a public door through without reading a token", async () => {
60
66
  const { guard, verify } = await build({ reason: PublicReason.Health })
@@ -100,7 +106,7 @@ describe("AuthGuard credential doors", () => {
100
106
  verify.mockResolvedValue(ok(principal))
101
107
  const { context } = contextOf("Bearer signed-token", credentialPrincipal, principal)
102
108
 
103
- await expect(guard.canActivate(context)).rejects.toThrow(unauthenticated)
109
+ await expect(guard.canActivate(context)).rejects.toThrow(IdentityErrorCode.Unauthenticated)
104
110
 
105
111
  expect(verify).not.toHaveBeenCalled()
106
112
  },
@@ -115,7 +121,7 @@ describe("AuthGuard credential doors", () => {
115
121
  const { guard, verify } = await build({ reason: PublicReason.Health }, { kind: "api-key" })
116
122
  const { context } = contextOf("Bearer signed-token", credentialPrincipal, principal)
117
123
 
118
- await expect(guard.canActivate(context)).rejects.toThrow(unauthenticated)
124
+ await expect(guard.canActivate(context)).rejects.toThrow(IdentityErrorCode.Unauthenticated)
119
125
 
120
126
  expect(verify).not.toHaveBeenCalled()
121
127
  },
@@ -126,7 +132,7 @@ describe("AuthGuard credential doors", () => {
126
132
  verify.mockResolvedValue(refused(SupabaseErrorCode.AccessTokenInvalid))
127
133
  const { context, request } = contextOf("bearer forged-token")
128
134
 
129
- await expect(guard.canActivate(context)).rejects.toThrow(unauthenticated)
135
+ await expect(guard.canActivate(context)).rejects.toThrow(IdentityErrorCode.Unauthenticated)
130
136
 
131
137
  expect(request.principal).toBeUndefined()
132
138
  })
@@ -140,7 +146,7 @@ describe("AuthGuard credential doors", () => {
140
146
  const { guard, verify } = await build(undefined)
141
147
  const { context } = contextOf(authorization)
142
148
 
143
- await expect(guard.canActivate(context)).rejects.toThrow(unauthenticated)
149
+ await expect(guard.canActivate(context)).rejects.toThrow(IdentityErrorCode.Unauthenticated)
144
150
 
145
151
  expect(verify).not.toHaveBeenCalled()
146
152
  })
@@ -9,6 +9,10 @@ import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
9
9
  import { PRIMARY_CONNECTION } from "./primary.connection"
10
10
  import { DATABASE_PROBE, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
11
11
 
12
+ /** The `schema` option of a connection, left out when the connection names none. */
13
+ const schemaOption = (schema: string | undefined): Partial<Record<"schema", string>> =>
14
+ schema === undefined ? {} : { schema }
15
+
12
16
  /** The token each declared connection provides its shared EntityManager under. */
13
17
  type EntityManagerToken = typeof PRIMARY_ENTITY_MANAGER
14
18
 
@@ -48,7 +52,7 @@ export class DatabaseModule extends ConfigurableModuleClass {
48
52
  name: connection.name,
49
53
  type: "postgres",
50
54
  url: connection.url.reveal(),
51
- schema: connection.schema,
55
+ ...schemaOption(connection.schema),
52
56
  synchronize: false,
53
57
  retryAttempts: 2,
54
58
  }),