@starci/hfs 4.9.8 → 4.9.11

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 (51) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +14 -0
  3. package/db/drift.mjs +289 -0
  4. package/db/dump.mjs +1 -1
  5. package/db/entity-list.mjs +9 -0
  6. package/db/migrate.mjs +1 -1
  7. package/lint/run.mjs +12 -2
  8. package/package.json +5 -3
  9. package/release/gate.mjs +81 -18
  10. package/release/index.mjs +19 -8
  11. package/release/push.mjs +4 -4
  12. package/release/records.mjs +54 -0
  13. package/release/urgent.mjs +129 -0
  14. package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
  15. package/runtime/knowledge/hfs/rules.yaml +74 -3
  16. package/runtime/knowledge/hfs/slots.yaml +36 -3
  17. package/runtime/modules/kernel/failure-codes.yaml +21 -0
  18. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  19. package/runtime/scripts/hfs/check.mjs +2 -2
  20. package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
  21. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  22. package/scaffold/app.mjs +3 -1
  23. package/src/main.mjs +7 -4
  24. package/stack/cli.mjs +10 -2
  25. package/stack/deploy.mjs +21 -4
  26. package/stack/urgent-floor.mjs +147 -0
  27. package/sync/ci-commands.mjs +3 -1
  28. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  29. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  30. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +41 -0
  31. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +14 -2
  32. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +3 -2
  33. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  34. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  35. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  36. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  37. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  38. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +2 -1
  39. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  40. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  41. package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +8 -0
  42. package/templates/be/skeleton/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
  43. package/templates/be/skeleton/src/modules/platform/http-security/persistence/migrations/1789900000000-create-rate-windows.ts +24 -0
  44. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  45. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +20 -0
  46. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +149 -47
  47. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +57 -34
  48. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
  49. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  50. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +2 -1
  51. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
@@ -0,0 +1,24 @@
1
+ import type { MigrationInterface, QueryRunner } from "typeorm"
2
+
3
+ /** Creates the rate limit windows every api replica counts in. */
4
+ export class CreateRateWindows1789900000000 implements MigrationInterface {
5
+ name = "CreateRateWindows1789900000000"
6
+
7
+ /** Creates the windows table and the index the prune reads. */
8
+ async up(queryRunner: QueryRunner): Promise<void> {
9
+ await queryRunner.query(`CREATE TABLE http_security_rate_windows (
10
+ key varchar(128) PRIMARY KEY,
11
+ hits integer NOT NULL CHECK (hits > 0),
12
+ reset_at timestamptz NOT NULL
13
+ )`)
14
+ await queryRunner.query(
15
+ `CREATE INDEX ix_http_security_rate_windows_reset_at ON http_security_rate_windows (reset_at)`,
16
+ )
17
+ }
18
+
19
+ /** Drops the index and the table. */
20
+ async down(queryRunner: QueryRunner): Promise<void> {
21
+ await queryRunner.query(`DROP INDEX ix_http_security_rate_windows_reset_at`)
22
+ await queryRunner.query(`DROP TABLE http_security_rate_windows`)
23
+ }
24
+ }
@@ -0,0 +1,5 @@
1
+ /** The row HIT_RATE_WINDOW answers. */
2
+ export interface RateWindowRow {
3
+ /** The hits of the caller's current window, this one included. */
4
+ readonly hits: number
5
+ }
@@ -0,0 +1,20 @@
1
+ import { sql } from "@modules/platform/database"
2
+
3
+ /**
4
+ * Counts one hit in the caller's window and answers the hits of that window: a missing or expired window opens afresh at one
5
+ * hit and ends at $3 ($1 tier and address key, $2 instant, $3 end of a fresh window).
6
+ */
7
+ export const HIT_RATE_WINDOW = sql`INSERT INTO http_security_rate_windows (key, hits, reset_at)
8
+ VALUES ($1::varchar, 1, $3::timestamptz)
9
+ ON CONFLICT (key) DO UPDATE SET
10
+ hits = CASE WHEN http_security_rate_windows.reset_at <= $2::timestamptz THEN 1
11
+ ELSE http_security_rate_windows.hits + 1 END,
12
+ reset_at = CASE WHEN http_security_rate_windows.reset_at <= $2::timestamptz THEN EXCLUDED.reset_at
13
+ ELSE http_security_rate_windows.reset_at END
14
+ RETURNING hits`
15
+
16
+ /** Deletes a bounded batch of the windows that ended by $1; a second replica skips the rows this one holds. */
17
+ export const PRUNE_RATE_WINDOWS = sql`DELETE FROM http_security_rate_windows WHERE key IN (
18
+ SELECT key FROM http_security_rate_windows WHERE reset_at <= $1::timestamptz
19
+ LIMIT 100 FOR UPDATE SKIP LOCKED
20
+ )`
@@ -1,111 +1,213 @@
1
1
  import "reflect-metadata"
2
2
  import type { ExecutionContext, Type } from "@nestjs/common"
3
- import type { Request } from "express"
3
+ import type { Request, Response } from "express"
4
4
  import { Reflector } from "@nestjs/core"
5
5
  import { Test } from "@nestjs/testing"
6
- import { FakeClock, mock } from "@starci/jest-preset"
6
+ import { FakeClock, mock, mockEntityManager } from "@starci/jest-preset"
7
+ import type { MockEntityManager } from "@starci/jest-preset"
7
8
  import { CLOCK } from "@modules/platform/clock"
9
+ import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
8
10
  import { HttpSecurityErrorCode } from "./errors/http-security.error"
9
11
  import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
10
12
  import type { HttpSecurityOptions } from "./http-security.options"
13
+ import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
11
14
  import { RateLimit, RateLimitGuard, RateTier } from "./rate-limit.guard"
15
+ import type { RateLimitExceeded } from "./rate-limit.guard"
12
16
 
13
17
  const AT = "2026-02-03T04:05:06.000Z"
18
+ const RESET_AT = "2026-02-03T04:06:06.000Z"
14
19
  const options: HttpSecurityOptions = {
15
20
  allowedOrigins: [],
16
21
  rateLimit: { windowMs: 60_000, defaultLimit: 2, strictLimit: 1 },
17
22
  webhooks: {},
18
23
  }
19
24
 
20
- const build = async (clock: FakeClock, tier?: RateTier) => {
25
+ const build = async (entityManager: MockEntityManager, tier?: RateTier, exceeded?: RateLimitExceeded) => {
21
26
  const reflector = mock<Reflector>()
22
- reflector.getAllAndOverride.mockReturnValue(tier)
27
+ reflector.getAllAndOverride.mockImplementation((key: unknown) =>
28
+ key === "platform.http-security.rate-exceeded" ? exceeded : tier,
29
+ )
23
30
  const moduleRef = await Test.createTestingModule({
24
31
  providers: [
25
32
  RateLimitGuard,
26
33
  { provide: Reflector, useValue: reflector },
27
- { provide: CLOCK, useValue: clock },
34
+ { provide: CLOCK, useValue: new FakeClock(AT) },
28
35
  { provide: HTTP_SECURITY_OPTIONS, useValue: options },
36
+ { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
29
37
  ],
30
38
  }).compile()
31
39
  return { guard: moduleRef.get(RateLimitGuard), reflector }
32
40
  }
33
41
 
34
42
  /** An execution context of one POST from `ip` on the door; the handler and the class carry the tier metadata. */
35
- const contextOf = (ip: string | undefined, handler = () => undefined, door: Type<unknown> = class Door {}) => {
36
- let address = ip
43
+ const contextOf = (
44
+ ip: string | undefined,
45
+ handler = () => undefined,
46
+ door: Type<unknown> = class Door {},
47
+ response = mock<Response>(),
48
+ ) => {
37
49
  const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
38
- http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip: address }))
50
+ http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip }))
51
+ http.getResponse.mockReturnValue(response)
39
52
  const context = mock<ExecutionContext>()
53
+ context.getType.mockReturnValue("http")
40
54
  context.switchToHttp.mockReturnValue(http)
41
55
  context.getHandler.mockReturnValue(handler)
42
56
  context.getClass.mockReturnValue(door)
43
- return { context, setIp: (value: string | undefined) => (address = value) }
57
+ return context
44
58
  }
45
59
 
46
60
  describe("RateLimit", () => {
47
61
  it("marks a door with the tier it declares, and the guard counts it under that tier's limit", async () => {
48
62
  class Door {}
49
63
  RateLimit(RateTier.Strict)(Door)
50
- const { guard, reflector } = await build(new FakeClock(AT), RateTier.Strict)
64
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
65
+ const { guard, reflector } = await build(entityManager, RateTier.Strict)
51
66
  const handler = () => undefined
52
- const { context } = contextOf("127.0.0.1", handler, Door)
53
67
 
54
68
  expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
55
- expect(guard.canActivate(context)).toBe(true)
56
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
69
+ await expect(guard.canActivate(contextOf("127.0.0.1", handler, Door))).rejects.toMatchObject({
70
+ code: HttpSecurityErrorCode.RateLimited,
71
+ })
57
72
  expect(reflector.getAllAndOverride).toHaveBeenCalledWith("platform.http-security.rate-tier", [handler, Door])
73
+ expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
74
+ "strict:127.0.0.1",
75
+ new Date(AT),
76
+ new Date(RESET_AT),
77
+ ])
58
78
  })
79
+
80
+ it("marks a redirect-only door with its exceeded redirect beside the tier", () => {
81
+ class Door {}
82
+ RateLimit(RateTier.Strict, { redirect: "/sign-in?auth=error" })(Door)
83
+
84
+ expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
85
+ expect(Reflect.getMetadata("platform.http-security.rate-exceeded", Door)).toEqual({
86
+ redirect: "/sign-in?auth=error",
87
+ })
88
+ })
89
+
90
+ it.each(["https://foreign.test/sign-in", "//foreign.test/sign-in", "/\\foreign.test", "sign-in"])(
91
+ "refuses an exceeded redirect %s that is not a same-site path",
92
+ (redirect) => {
93
+ expect(() => RateLimit(RateTier.Strict, { redirect })).toThrow(HttpSecurityErrorCode.RequestInvalid)
94
+ },
95
+ )
59
96
  })
60
97
 
61
98
  describe("RateLimitGuard", () => {
62
- it("counts an unmarked door under the default limit", async () => {
63
- const { guard } = await build(new FakeClock(AT))
64
- const { context } = contextOf("127.0.0.1")
99
+ it("admits a caller under the default limit of an unmarked door, counting in the shared window", async () => {
100
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
101
+ const { guard } = await build(entityManager)
102
+
103
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
104
+ expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
105
+ "default:127.0.0.1",
106
+ new Date(AT),
107
+ new Date(RESET_AT),
108
+ ])
109
+ })
110
+
111
+ it("refuses a caller whose shared window is over the default limit", async () => {
112
+ const { guard } = await build(mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 3 }]] }))
113
+
114
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
115
+ code: HttpSecurityErrorCode.RateLimited,
116
+ })
117
+ })
65
118
 
66
- expect(guard.canActivate(context)).toBe(true)
67
- expect(guard.canActivate(context)).toBe(true)
68
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
119
+ it("prunes the expired windows when a hit opens a fresh one", async () => {
120
+ const entityManager = mockEntityManager({
121
+ query: [
122
+ [HIT_RATE_WINDOW, [{ hits: 1 }]],
123
+ [PRUNE_RATE_WINDOWS, []],
124
+ ],
125
+ })
126
+ const { guard } = await build(entityManager)
127
+
128
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
129
+ expect(entityManager.query).toHaveBeenCalledWith(PRUNE_RATE_WINDOWS, [new Date(AT)])
69
130
  })
70
131
 
71
- it("opens a fresh window for a caller whose bucket expired", async () => {
72
- const clock = new FakeClock(AT)
73
- const { guard } = await build(clock)
74
- const { context } = contextOf("127.0.0.1")
132
+ it("counts a request without an address under the unknown key", async () => {
133
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
134
+ const { guard } = await build(entityManager)
135
+
136
+ await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(true)
137
+ expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
138
+ "default:unknown",
139
+ new Date(AT),
140
+ new Date(RESET_AT),
141
+ ])
142
+ })
75
143
 
76
- guard.canActivate(context)
77
- guard.canActivate(context)
78
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
144
+ it("bounds the window key to the length the table stores", async () => {
145
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
146
+ const { guard } = await build(entityManager)
79
147
 
80
- clock.advance(60_001)
148
+ await guard.canActivate(contextOf("f".repeat(300)))
81
149
 
82
- expect(guard.canActivate(context)).toBe(true)
150
+ expect(entityManager.query).toHaveBeenCalledWith(HIT_RATE_WINDOW, [
151
+ `default:${"f".repeat(120)}`,
152
+ new Date(AT),
153
+ new Date(RESET_AT),
154
+ ])
83
155
  })
84
156
 
85
- it("counts a request without an address under the unknown bucket", async () => {
86
- const { guard } = await build(new FakeClock(AT))
87
- const { context } = contextOf(undefined)
157
+ it("refuses the request when the store answers no window", async () => {
158
+ const { guard } = await build(mockEntityManager({ query: [HIT_RATE_WINDOW, []] }))
88
159
 
89
- guard.canActivate(context)
90
- guard.canActivate(context)
91
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
160
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
161
+ code: HttpSecurityErrorCode.RateLimited,
162
+ })
92
163
  })
164
+ })
165
+
166
+ describe("RateLimitGuard on a redirect-only door", () => {
167
+ const EXCEEDED: RateLimitExceeded = { redirect: "/sign-in?auth=error" }
93
168
 
94
- it("prunes the expired buckets when the map outgrows the bucket cap", async () => {
95
- const clock = new FakeClock(AT)
96
- const { guard } = await build(clock)
97
- const { context, setIp } = contextOf("0.0.0.0")
169
+ /** A response whose redirect is a plain double, so the spec reads the call without the deprecated overload. */
170
+ const responseOf = () => {
171
+ const redirect = jest.fn()
172
+ return { response: mock<Response>({ redirect }), redirect }
173
+ }
98
174
 
99
- for (let index = 0; index <= 10_000; index += 1) {
100
- setIp(`10.${(index >> 16) % 256}.${(index >> 8) % 256}.${index % 256}`)
101
- guard.canActivate(context)
102
- }
175
+ it("answers the door's own 303 when the caller is over the limit, and still refuses", async () => {
176
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
177
+ const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
178
+ const { response, redirect } = responseOf()
179
+
180
+ await expect(
181
+ guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response)),
182
+ ).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
183
+ expect(redirect).toHaveBeenCalledWith(303, "/sign-in?auth=error")
184
+ })
185
+
186
+ it("writes no redirect while the caller is under the limit", async () => {
187
+ const entityManager = mockEntityManager({
188
+ query: [
189
+ [HIT_RATE_WINDOW, [{ hits: 1 }]],
190
+ [PRUNE_RATE_WINDOWS, []],
191
+ ],
192
+ })
193
+ const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
194
+ const { response, redirect } = responseOf()
195
+
196
+ await expect(guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response))).resolves.toBe(
197
+ true,
198
+ )
199
+ expect(redirect).not.toHaveBeenCalled()
200
+ })
103
201
 
104
- clock.advance(60_001)
105
- setIp("127.0.0.1")
202
+ it("refuses a GraphQL operation over the limit without writing a redirect", async () => {
203
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
204
+ const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
205
+ const { response, redirect } = responseOf()
206
+ const context = contextOf("127.0.0.1", () => undefined, class Door {}, response)
207
+ context.getType.mockReturnValue("graphql")
208
+ context.getArgs.mockReturnValue([{}, {}, { req: mock<Request>({ headers: {}, ip: "127.0.0.1" }) }, {}])
106
209
 
107
- expect(guard.canActivate(context)).toBe(true)
108
- expect(guard.canActivate(context)).toBe(true)
109
- expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.RateLimited)
210
+ await expect(guard.canActivate(context)).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
211
+ expect(redirect).not.toHaveBeenCalled()
110
212
  })
111
213
  })
@@ -1,13 +1,19 @@
1
- import { Injectable, SetMetadata } from "@nestjs/common"
1
+ import { Injectable, SetMetadata, applyDecorators } from "@nestjs/common"
2
2
  import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
3
  import type { Reflector } from "@nestjs/core"
4
+ import type { Response } from "express"
5
+ import type { EntityManager } from "typeorm"
4
6
  import { InjectClock } from "@modules/platform/clock"
5
7
  import type { Clock } from "@modules/platform/clock"
6
8
  import { InjectReflector } from "@modules/platform/composition"
9
+ import type { DoorDecorator } from "@modules/platform/composition"
10
+ import { InjectPrimaryEntityManager } from "@modules/platform/database"
7
11
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
8
12
  import { requestOf } from "./execution-request.mapper"
9
13
  import { InjectHttpSecurityOptions } from "./http-security.decorators"
10
14
  import type { HttpSecurityOptions } from "./http-security.options"
15
+ import type { RateWindowRow } from "./persistence/rate-limit.rows"
16
+ import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
11
17
 
12
18
  /** The rate limit tiers of a door. */
13
19
  export enum RateTier {
@@ -17,52 +23,69 @@ export enum RateTier {
17
23
  Strict = "strict",
18
24
  }
19
25
 
26
+ /** What a door answers when its caller is over the limit, when that is not the 429 error body. */
27
+ export interface RateLimitExceeded {
28
+ /** A same-site path the door's own contract answers with a 303 (an OAuth callback's error redirect). */
29
+ readonly redirect: string
30
+ }
31
+
20
32
  const RATE_TIER_KEY = "platform.http-security.rate-tier"
21
- const BUCKETS_MAX = 10_000
33
+ const RATE_EXCEEDED_KEY = "platform.http-security.rate-exceeded"
34
+ /** The longest window key the table stores: the tier, a colon and the caller address. */
35
+ const KEY_MAX = 128
22
36
 
23
- interface Bucket {
24
- count: number
25
- resetAt: number
26
- }
37
+ /** A same-site path: one leading slash, never a protocol-relative `//` or `/\` that leaves the site. */
38
+ const isSameSitePath = (location: string): boolean =>
39
+ location.startsWith("/") && !location.startsWith("//") && !location.startsWith("/\\")
27
40
 
28
- /** Marks a door with a rate limit tier other than the default. */
29
- export const RateLimit = (tier: RateTier): ReturnType<typeof SetMetadata> => SetMetadata(RATE_TIER_KEY, tier)
41
+ /**
42
+ * Marks a door with a rate limit tier other than the default. A door whose contract answers only redirects (an OAuth
43
+ * callback) names its error redirect in `exceeded`, and a caller over the limit gets that 303 instead of a 429 body.
44
+ */
45
+ export const RateLimit = (tier: RateTier, exceeded?: RateLimitExceeded): DoorDecorator => {
46
+ if (exceeded === undefined) return applyDecorators(SetMetadata(RATE_TIER_KEY, tier))
47
+ if (!isSameSitePath(exceeded.redirect))
48
+ throw new HttpSecurityError({
49
+ code: HttpSecurityErrorCode.RequestInvalid,
50
+ params: { fields: "RATE_LIMIT_REDIRECT" },
51
+ })
52
+ return applyDecorators(SetMetadata(RATE_TIER_KEY, tier), SetMetadata(RATE_EXCEEDED_KEY, exceeded))
53
+ }
30
54
 
31
55
  @Injectable()
32
- /** The first app guard: a fixed-window counter per caller address and tier, kept in process memory and stamped by the Clock. */
56
+ /**
57
+ * The first app guard: a fixed-window counter per caller address and tier, stamped by the Clock and kept in the primary
58
+ * database, so every replica counts against the same window. A hit that opens a fresh window also prunes a bounded batch of
59
+ * expired windows.
60
+ */
33
61
  export class RateLimitGuard implements CanActivate {
34
- private readonly buckets = new Map<string, Bucket>()
35
-
36
62
  constructor(
37
63
  @InjectReflector() private readonly reflector: Reflector,
38
64
  @InjectClock() private readonly clock: Clock,
39
65
  @InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
66
+ @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
40
67
  ) {}
41
68
 
42
- /** Counts the request and refuses it when the caller is over the limit of the door tier. */
43
- canActivate(context: ExecutionContext): boolean {
44
- const tier =
45
- this.reflector.getAllAndOverride<RateTier | undefined>(RATE_TIER_KEY, [
46
- context.getHandler(),
47
- context.getClass(),
48
- ]) ?? RateTier.Default
69
+ /**
70
+ * Counts the request and refuses it when the caller is over the limit of the door tier. A door that declares its
71
+ * exceeded redirect has the 303 written here; the refusal still travels, and the errors filter leaves the sent
72
+ * response as it is.
73
+ */
74
+ async canActivate(context: ExecutionContext): Promise<boolean> {
75
+ const targets = [context.getHandler(), context.getClass()]
76
+ const tier = this.reflector.getAllAndOverride<RateTier | undefined>(RATE_TIER_KEY, targets) ?? RateTier.Default
49
77
  const limit =
50
78
  tier === RateTier.Strict ? this.options.rateLimit.strictLimit : this.options.rateLimit.defaultLimit
51
- const now = this.clock.now().getTime()
52
- const key = `${tier}:${requestOf(context).ip ?? "unknown"}`
53
- const current = this.buckets.get(key)
54
- const bucket =
55
- current && current.resetAt > now ? current : { count: 0, resetAt: now + this.options.rateLimit.windowMs }
56
- bucket.count += 1
57
- this.buckets.set(key, bucket)
58
- if (this.buckets.size > BUCKETS_MAX) this.prune(now)
59
- if (bucket.count > limit) throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
60
- return true
61
- }
62
-
63
- private prune(now: number): void {
64
- for (const [key, bucket] of this.buckets) {
65
- if (bucket.resetAt <= now) this.buckets.delete(key)
66
- }
79
+ const now = this.clock.now()
80
+ const key = `${tier}:${requestOf(context).ip ?? "unknown"}`.slice(0, KEY_MAX)
81
+ const resetAt = new Date(now.getTime() + this.options.rateLimit.windowMs)
82
+ const rows: Array<RateWindowRow> = await this.entityManager.query(HIT_RATE_WINDOW, [key, now, resetAt])
83
+ const hits = rows[0]?.hits ?? Number.POSITIVE_INFINITY
84
+ if (hits === 1) await this.entityManager.query(PRUNE_RATE_WINDOWS, [now])
85
+ if (hits <= limit) return true
86
+ const exceeded = this.reflector.getAllAndOverride<RateLimitExceeded | undefined>(RATE_EXCEEDED_KEY, targets)
87
+ if (exceeded !== undefined && context.getType() === "http")
88
+ context.switchToHttp().getResponse<Response>().redirect(303, exceeded.redirect)
89
+ throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
67
90
  }
68
91
  }
@@ -1,12 +1,13 @@
1
- import { SetMetadata, createParamDecorator } from "@nestjs/common"
1
+ import { SetMetadata, applyDecorators, createParamDecorator } from "@nestjs/common"
2
2
  import type { ExecutionContext } from "@nestjs/common"
3
3
  import { injector } from "@modules/platform/composition"
4
- import type { TypedParameterDecorator } from "@modules/platform/composition"
4
+ import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
5
5
  import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
6
- import { requestOf } from "@modules/platform/http-security"
6
+ import { ServerToServer, requestOf } from "@modules/platform/http-security"
7
7
  import type { Principal } from "@modules/platform/cqrs"
8
8
  import type { CredentialAuthenticator, CredentialMetadata } from "./credential.contracts"
9
9
  import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
10
+ import { PublicReason } from "./identity.contracts"
10
11
  import type { PublicMetadata } from "./identity.contracts"
11
12
 
12
13
  /** Token of the access-token verifier the composition root hands to identity. */
@@ -19,8 +20,14 @@ export const InjectAccessTokenVerifier = (): TypedParameterDecorator<VerifySupab
19
20
  /** Metadata key of `@Public`. */
20
21
  export const PUBLIC_KEY = "domain.identity.public"
21
22
 
22
- /** Opens a door to anonymous callers, stating why; every other door needs a credential or a verified Supabase session. */
23
- export const Public = (metadata: PublicMetadata): ReturnType<typeof SetMetadata> => SetMetadata(PUBLIC_KEY, metadata)
23
+ /**
24
+ * Opens a door to anonymous callers, stating why; every other door needs a credential or a verified Supabase session.
25
+ * A signed webhook proves its caller by its signature, so it owes no browser origin.
26
+ */
27
+ export const Public = (metadata: PublicMetadata): DoorDecorator =>
28
+ metadata.reason === PublicReason.SignedWebhook
29
+ ? applyDecorators(SetMetadata(PUBLIC_KEY, metadata), ServerToServer())
30
+ : applyDecorators(SetMetadata(PUBLIC_KEY, metadata))
24
31
 
25
32
  /** Metadata key of `@Credential`. */
26
33
  export const CREDENTIAL_KEY: unique symbol = Symbol("domain.identity.credential")
@@ -32,9 +39,12 @@ export const CREDENTIAL_AUTHENTICATOR: unique symbol = Symbol("domain.identity.c
32
39
  export const InjectCredentialAuthenticator = (): TypedParameterDecorator<CredentialAuthenticator> =>
33
40
  injector<CredentialAuthenticator>(CREDENTIAL_AUTHENTICATOR)
34
41
 
35
- /** Marks a door authenticated by its own credential; `CredentialGuard` authenticates it and `AuthGuard` admits it. */
36
- export const Credential = (metadata: CredentialMetadata): ReturnType<typeof SetMetadata> =>
37
- SetMetadata(CREDENTIAL_KEY, metadata)
42
+ /**
43
+ * Marks a door authenticated by its own credential; `CredentialGuard` authenticates it and `AuthGuard` admits it. The
44
+ * credential travels in a header no browser attaches by itself, so the door owes no browser origin.
45
+ */
46
+ export const Credential = (metadata: CredentialMetadata): DoorDecorator =>
47
+ applyDecorators(SetMetadata(CREDENTIAL_KEY, metadata), ServerToServer())
38
48
 
39
49
  /** The authenticated Supabase caller established by the default-deny guard. */
40
50
  export const CurrentPrincipal = createParamDecorator((_data: unknown, context: ExecutionContext): Principal => {
@@ -5,11 +5,14 @@ import type { ErrorKind } from "@modules/platform/errors"
5
5
  export enum DatabaseErrorCode {
6
6
  /** A configured connection has no declared EntityManager injection token. */
7
7
  ConnectionUndeclared = "DATABASE_CONNECTION_UNDECLARED",
8
+ /** A dynamic SQL identifier was not on the names its caller allows. */
9
+ IdentifierRejected = "DATABASE_IDENTIFIER_REJECTED",
8
10
  }
9
11
 
10
12
  /** How each database code travels. */
11
13
  export const DATABASE_ERROR_KINDS: Record<DatabaseErrorCode, ErrorKind> = {
12
14
  [DatabaseErrorCode.ConnectionUndeclared]: "internal",
15
+ [DatabaseErrorCode.IdentifierRejected]: "internal",
13
16
  }
14
17
 
15
18
  /** The one error class of the database capability. */
@@ -1,7 +1,8 @@
1
1
  export { DatabaseModule } from "./database.module"
2
- export { DATABASE_PROBE, InjectDatabaseProbe } from "./primary.decorators"
2
+ export { DATABASE_PROBE, InjectDatabaseProbe, InjectPrimaryEntityManager, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
3
3
  export { DatabaseProbeService } from "./database-probe.service"
4
4
  export type { DatabaseConnectionOptions } from "./database.options"
5
5
  export { PRIMARY_DATA_SOURCE } from "./database.port"
6
+ export { sql } from "./database.sql"
6
7
  export { DATABASE_ERROR_KINDS } from "./errors/database.error"
7
8
  export { parsePrimaryDatabaseConfig } from "./primary.config"
@@ -4,7 +4,7 @@ export { parseHttpSecurityConfig } from "./http-security.config"
4
4
  export { HttpSecurityModule } from "./http-security.module"
5
5
  export type { HttpSecurityOptions } from "./http-security.options"
6
6
  export { HTTP_SECURITY_MESSAGES } from "./messages/http-security.messages"
7
- export { OriginGuard } from "./origin.guard"
7
+ export { OriginGuard, ServerToServer } from "./origin.guard"
8
8
  export { RateLimitGuard } from "./rate-limit.guard"
9
9
  export { applyHttpSecurity } from "./edge-security.service"
10
10
  export { ReadinessService } from "./readiness.service"