@starci/hfs 4.7.23 → 4.7.24
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 +12 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/release/candidate-ci.mjs +20 -10
- package/release/gate.mjs +37 -19
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/ci-standard.yaml +5 -5
- package/runtime/knowledge/hfs/rules.yaml +17 -17
- package/runtime/knowledge/hfs/slots.yaml +20 -1
- package/runtime/knowledge/patterns/be/saga.yaml +1 -1
- package/runtime/knowledge/sonar-gate.yaml +12 -12
- package/runtime/modules/kernel/failure-codes.yaml +4 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +0 -3
- package/runtime/packages/cli/src/catalog.generated.mjs +67 -4
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +2 -2
- package/runtime/scripts/hfs/rules/ci-standard.mjs +24 -14
- package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +68 -2
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +21 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/lib/gate-loosening.mjs +248 -0
- package/runtime/scripts/lib/managed-json.mjs +51 -0
- package/runtime/scripts/lib/uat-run-folder.mjs +118 -0
- package/stack/prepare.mjs +10 -3
- package/stack/release-store.mjs +3 -3
- package/stack/remote.mjs +9 -0
- package/sync/ci-commands.mjs +38 -90
- package/sync/index.mjs +10 -5
- package/templates/app/browser-config/browser/tsconfig.json +11 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -3
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -3
- package/templates/app/hooks/husky/pre-push +4 -2
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +0 -1
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +5 -6
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -6
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +5 -6
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +6 -2
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +5 -2
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +1 -1
- package/templates/be/patterns/saga/saga.service.spec.ts.tpl +4 -1
- package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +4 -4
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +6 -2
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +2 -6
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +4 -12
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +13 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +1 -3
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +5 -7
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +5 -2
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +4 -1
- package/templates/be/skeleton/src/modules/platform/http-security/request-validation.service.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +8 -10
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +11 -7
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +2 -2
- package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +6 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +16 -10
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +4 -1
- package/templates/fe/auth/supabase-client.ts.tpl +7 -7
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +1 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +1 -1
- package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +18 -3
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +3 -4
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +11 -1
- package/templates/fe/transport/graphql/clients/clients/auth.ts +2 -2
- package/templates/fe/transport/graphql/clients/clients/no-auth.ts +2 -2
- package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +2 -1
- package/templates/fe/transport/graphql/clients/links/context-headers.ts +9 -0
- package/templates/fe/transport/graphql/clients/links/http.ts +3 -3
- package/templates/fe/transport/graphql/clients/links/locale.ts +2 -1
- package/templates/fe/transport/graphql/clients/response.ts +1 -1
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
}
|
package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts
CHANGED
|
@@ -52,19 +52,13 @@ 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
|
-
|
|
64
|
-
)
|
|
65
|
-
expect(() => edge.validateAllowedOrigins(["http://web.example"], false)).toThrow(
|
|
66
|
-
expect.objectContaining({ code: "HTTP_SECURITY_REQUEST_INVALID" }),
|
|
67
|
-
)
|
|
60
|
+
expect(() => edge.validateAllowedOrigins([], false)).toThrow("HTTP_SECURITY_REQUEST_INVALID")
|
|
61
|
+
expect(() => edge.validateAllowedOrigins(["http://web.example"], false)).toThrow("HTTP_SECURITY_REQUEST_INVALID")
|
|
68
62
|
})
|
|
69
63
|
})
|
|
70
64
|
|
|
@@ -121,9 +115,7 @@ describe("HTTP edge installation", () => {
|
|
|
121
115
|
async (trustProxy) => {
|
|
122
116
|
const edge = await buildEdge()
|
|
123
117
|
const { app } = appWithServer()
|
|
124
|
-
expect(() => edge.applyHttpSecurity(app, options, { trustProxy })).toThrow(
|
|
125
|
-
expect.objectContaining({ code: "HTTP_SECURITY_REQUEST_INVALID" }),
|
|
126
|
-
)
|
|
118
|
+
expect(() => edge.applyHttpSecurity(app, options, { trustProxy })).toThrow("HTTP_SECURITY_REQUEST_INVALID")
|
|
127
119
|
expect(app.enableCors).not.toHaveBeenCalled()
|
|
128
120
|
},
|
|
129
121
|
)
|
|
@@ -3,6 +3,18 @@ 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)) throw new HttpSecurityError({ code: HttpSecurityErrorCode.RequestInvalid, params: { fields: "HTTP_ADAPTER" } })
|
|
15
|
+
return instance
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
/** The declared ingress hop count and the environment in which HTTP origins may be used. */
|
|
7
19
|
export interface HttpEdgeOptions {
|
|
8
20
|
/** Exact trusted proxy hops; the standard API stack has one ingress in front of it. */
|
|
@@ -50,7 +62,7 @@ export const applyHttpSecurity = (app: INestApplication, options: HttpSecurityOp
|
|
|
50
62
|
code: HttpSecurityErrorCode.RequestInvalid,
|
|
51
63
|
params: { fields: "HTTP_SECURITY_TRUST_PROXY" },
|
|
52
64
|
})
|
|
53
|
-
const server
|
|
65
|
+
const server = expressOf(app)
|
|
54
66
|
server.disable("x-powered-by")
|
|
55
67
|
server.set("trust proxy", edge.trustProxy)
|
|
56
68
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
24
|
+
let cancel: () => void = noCancel
|
|
22
25
|
try {
|
|
23
26
|
await Promise.race([
|
|
24
27
|
this.database.probe(),
|
package/templates/be/skeleton/src/modules/platform/http-security/request-validation.service.spec.ts
CHANGED
|
@@ -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" })
|
package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
14
|
-
return
|
|
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,7 @@ 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)).
|
|
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({ name: "CapabilityError", code: "CAPABILITY_REFUSED", params: { resource: "value-1" } })
|
|
64
68
|
})
|
|
65
69
|
})
|
|
66
70
|
})
|
|
@@ -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(
|
|
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(
|
|
57
|
+
await expect(run).rejects.toThrow("STEP_FAILED")
|
|
58
58
|
})
|
|
59
59
|
})
|
package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts
CHANGED
|
@@ -43,12 +43,12 @@ describe("HTTP security environment contract", () => {
|
|
|
43
43
|
HTTP_SECURITY_ALLOWED_ORIGINS: "http://web.example",
|
|
44
44
|
}),
|
|
45
45
|
),
|
|
46
|
-
).toThrow(
|
|
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(
|
|
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
|
|
15
|
-
|
|
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<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
149
|
+
await expect(guard.canActivate(context)).rejects.toThrow(IdentityErrorCode.Unauthenticated)
|
|
144
150
|
|
|
145
151
|
expect(verify).not.toHaveBeenCalled()
|
|
146
152
|
})
|
|
@@ -9,6 +9,9 @@ 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>> => (schema === undefined ? {} : { schema })
|
|
14
|
+
|
|
12
15
|
/** The token each declared connection provides its shared EntityManager under. */
|
|
13
16
|
type EntityManagerToken = typeof PRIMARY_ENTITY_MANAGER
|
|
14
17
|
|
|
@@ -48,7 +51,7 @@ export class DatabaseModule extends ConfigurableModuleClass {
|
|
|
48
51
|
name: connection.name,
|
|
49
52
|
type: "postgres",
|
|
50
53
|
url: connection.url.reveal(),
|
|
51
|
-
|
|
54
|
+
...schemaOption(connection.schema),
|
|
52
55
|
synchronize: false,
|
|
53
56
|
retryAttempts: 2,
|
|
54
57
|
}),
|
|
@@ -4,7 +4,7 @@ const sessionOf = (session: Session | null): AuthSession | undefined =>
|
|
|
4
4
|
: { userId: session.user.id, ...(session.user.email === undefined ? {} : { email: session.user.email }) }
|
|
5
5
|
|
|
6
6
|
/** A Supabase Auth error as the provider reports it: its HTTP status, its code and its message. */
|
|
7
|
-
type ProviderError = { readonly status
|
|
7
|
+
type ProviderError = { readonly status: number | undefined; readonly code: string | undefined; readonly message: string }
|
|
8
8
|
|
|
9
9
|
/** The failure kind of a Supabase Auth error: refused credentials, rejected input, or the provider unavailable. */
|
|
10
10
|
const kindOf = (status: number | undefined): string => {
|
|
@@ -20,15 +20,15 @@ const failureOf = (error: ProviderError | null): AuthFailure | undefined =>
|
|
|
20
20
|
|
|
21
21
|
/** The Supabase Auth adapter shared by browser and cookie-backed PKCE flows. */
|
|
22
22
|
export const authClient: AuthClient = {
|
|
23
|
-
session: async () => sessionOf((await
|
|
24
|
-
accessToken: async () => (await
|
|
25
|
-
refreshAccessToken: async () => (await
|
|
23
|
+
session: async () => sessionOf((await authOf().getSession()).data.session),
|
|
24
|
+
accessToken: async () => (await authOf().getSession()).data.session?.access_token,
|
|
25
|
+
refreshAccessToken: async () => (await authOf().refreshSession()).data.session?.access_token,
|
|
26
26
|
signInWithPassword: async (email, password) =>
|
|
27
|
-
failureOf((await
|
|
28
|
-
signOut: async () => failureOf((await
|
|
27
|
+
failureOf((await authOf().signInWithPassword({ email, password })).error),
|
|
28
|
+
signOut: async () => failureOf((await authOf().signOut()).error),
|
|
29
29
|
// Supabase reports INITIAL_SESSION on subscribe, then every change: the subscription alone keeps the session current.
|
|
30
30
|
onChange: (listener) => {
|
|
31
|
-
const { data } =
|
|
31
|
+
const { data } = authOf().onAuthStateChange((_event, session) => listener(sessionOf(session)))
|
|
32
32
|
return () => data.subscription.unsubscribe()
|
|
33
33
|
},
|
|
34
34
|
}
|
|
@@ -8,7 +8,7 @@ export interface SignInFormBaseProps {
|
|
|
8
8
|
readonly emailLabel: string
|
|
9
9
|
readonly passwordLabel: string
|
|
10
10
|
readonly submitLabel: string
|
|
11
|
-
readonly failure
|
|
11
|
+
readonly failure: string | undefined
|
|
12
12
|
}
|
|
13
13
|
readonly on: { readonly submit: (input: FormData) => void }
|
|
14
14
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { createAppI18n } from "@{{project}}/i18n"
|
|
2
2
|
|
|
3
3
|
/** The product app's i18n: the product's one next-intl stack (the i18n package), with this app's own catalogs under `messages/`. */
|
|
4
|
-
export const appI18n = createAppI18n("app",
|
|
4
|
+
export const appI18n = createAppI18n("app", (locale) => import(`./messages/${locale}.json`))
|
|
@@ -3,5 +3,5 @@ import { createAppI18n } from "@{{project}}/i18n"
|
|
|
3
3
|
/** The landing's i18n: the product's one next-intl stack (the i18n package), with this app's own catalogs under `messages/`. */
|
|
4
4
|
export const landingI18n = createAppI18n(
|
|
5
5
|
"landing",
|
|
6
|
-
|
|
6
|
+
(locale) => import(`./messages/${locale}.json`),
|
|
7
7
|
)
|