@starci/hfs 4.11.11 → 4.11.13

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 (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/release/index.mjs +2 -2
  5. package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
  6. package/runtime/knowledge/hfs/rules.yaml +1 -1
  7. package/runtime/modules/kernel/failure-codes.yaml +5 -5
  8. package/runtime/scripts/api/sops/lib.mjs +25 -3
  9. package/runtime/scripts/api/sops/seal.mjs +4 -3
  10. package/runtime/scripts/hfs/path-findings.mjs +4 -2
  11. package/runtime/scripts/hfs/secret-init.mjs +15 -0
  12. package/runtime/scripts/hfs/secret-sops.mjs +65 -33
  13. package/runtime/scripts/lib/sops-envelope.mjs +36 -11
  14. package/scaffold/add-inbox-lite.mjs +6 -10
  15. package/scaffold/add-queue-lite.mjs +7 -2
  16. package/scaffold/add-table.mjs +5 -0
  17. package/scaffold/app.mjs +15 -3
  18. package/scaffold/lite-exports.mjs +23 -0
  19. package/src/main.mjs +3 -3
  20. package/stack/age-install.mjs +10 -2
  21. package/stack/context.mjs +2 -2
  22. package/stack/remote.mjs +29 -8
  23. package/stack/sealed.mjs +15 -11
  24. package/stack/server-key.mjs +31 -7
  25. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +1 -1
  26. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +1 -1
  27. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -1
  28. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +4 -1
  29. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +0 -2
  30. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -0
  31. package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +4 -0
  32. package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
  33. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +260 -0
  34. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +133 -0
@@ -0,0 +1,260 @@
1
+ import "reflect-metadata"
2
+ import type { ExecutionContext, Type } from "@nestjs/common"
3
+ import type { Request, Response } from "express"
4
+ import { Reflector } from "@nestjs/core"
5
+ import { Test } from "@nestjs/testing"
6
+ import { In, LessThanOrEqual, MoreThan } from "typeorm"
7
+ import { FakeClock, mock, mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
8
+ import type { MockEntityManager } from "@starci/jest-preset"
9
+ import { CLOCK } from "@modules/platform/clock"
10
+ import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
11
+ import { HttpSecurityErrorCode } from "./errors/http-security.error"
12
+ import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
13
+ import type { HttpSecurityOptions } from "./http-security.options"
14
+ import { RateWindowEntity } from "./persistence/entities/rate-window.entity"
15
+ import { RateLimit, RateLimitGuard, RateTier } from "./rate-limit.guard"
16
+ import type { RateLimitExceeded } from "./rate-limit.guard"
17
+
18
+ const AT = "2026-02-03T04:05:06.000Z"
19
+ const RESET_AT = "2026-02-03T04:06:06.000Z"
20
+ const options: HttpSecurityOptions = {
21
+ allowedOrigins: [],
22
+ rateLimit: { windowMs: 60_000, defaultLimit: 2, strictLimit: 1 },
23
+ webhooks: {},
24
+ }
25
+
26
+ /** The caller's live window holding `hits`. */
27
+ const windowOf = (hits: number): RateWindowEntity => ({ key: "default:127.0.0.1", hits, resetAt: new Date(RESET_AT) })
28
+
29
+ /** The answers of a store whose live window of the caller holds `hits` after this hit's increment. */
30
+ const liveWindow = (hits: number) =>
31
+ ({ increment: [RateWindowEntity, { affected: 1 }], findOneBy: [RateWindowEntity, windowOf(hits)] }) as const
32
+
33
+ /** The answers of a store where this hit opens the caller's window (no live or ended one) and the prune finds none ended. */
34
+ const OPENING_WINDOW = {
35
+ increment: [RateWindowEntity, { affected: 0 }],
36
+ update: [RateWindowEntity, { affected: 0 }],
37
+ find: [RateWindowEntity, []],
38
+ } as const
39
+
40
+ const build = async (entityManager: MockEntityManager, tier?: RateTier, exceeded?: RateLimitExceeded) => {
41
+ const reflector = mock<Reflector>()
42
+ reflector.getAllAndOverride.mockImplementation((key: unknown) =>
43
+ key === "platform.http-security.rate-exceeded" ? exceeded : tier,
44
+ )
45
+ const moduleRef = await Test.createTestingModule({
46
+ providers: [
47
+ RateLimitGuard,
48
+ { provide: Reflector, useValue: reflector },
49
+ { provide: CLOCK, useValue: new FakeClock(AT) },
50
+ { provide: HTTP_SECURITY_OPTIONS, useValue: options },
51
+ { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
52
+ ],
53
+ }).compile()
54
+ return { guard: moduleRef.get(RateLimitGuard), reflector }
55
+ }
56
+
57
+ /** An execution context of one POST from `ip` on the door; the handler and the class carry the tier metadata. */
58
+ const contextOf = (
59
+ ip: string | undefined,
60
+ handler = () => undefined,
61
+ door: Type<unknown> = class Door {},
62
+ response = mock<Response>(),
63
+ ) => {
64
+ const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
65
+ http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip }))
66
+ http.getResponse.mockReturnValue(response)
67
+ const context = mock<ExecutionContext>()
68
+ context.getType.mockReturnValue("http")
69
+ context.switchToHttp.mockReturnValue(http)
70
+ context.getHandler.mockReturnValue(handler)
71
+ context.getClass.mockReturnValue(door)
72
+ return context
73
+ }
74
+
75
+ describe("RateLimit", () => {
76
+ it("marks a door with the tier it declares, and the guard counts it under that tier's limit", async () => {
77
+ class Door {}
78
+ RateLimit(RateTier.Strict)(Door)
79
+ const entityManager = mockEntityManager(liveWindow(2))
80
+ const { guard, reflector } = await build(entityManager, RateTier.Strict)
81
+ const handler = () => undefined
82
+
83
+ expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
84
+ await expect(guard.canActivate(contextOf("127.0.0.1", handler, Door))).rejects.toMatchObject({
85
+ code: HttpSecurityErrorCode.RateLimited,
86
+ })
87
+ expect(reflector.getAllAndOverride).toHaveBeenCalledWith("platform.http-security.rate-tier", [handler, Door])
88
+ expect(entityManager.increment).toHaveBeenCalledWith(
89
+ RateWindowEntity,
90
+ { key: "strict:127.0.0.1", resetAt: MoreThan(new Date(AT)) },
91
+ "hits",
92
+ 1,
93
+ )
94
+ })
95
+
96
+ it("marks a redirect-only door with its exceeded redirect beside the tier", () => {
97
+ class Door {}
98
+ RateLimit(RateTier.Strict, { redirect: "/sign-in?auth=error" })(Door)
99
+
100
+ expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
101
+ expect(Reflect.getMetadata("platform.http-security.rate-exceeded", Door)).toEqual({
102
+ redirect: "/sign-in?auth=error",
103
+ })
104
+ })
105
+
106
+ it.each(["https://foreign.test/sign-in", "//foreign.test/sign-in", "/\\foreign.test", "sign-in"])(
107
+ "refuses an exceeded redirect %s that is not a same-site path",
108
+ (redirect) => {
109
+ expect(() => RateLimit(RateTier.Strict, { redirect })).toThrow(HttpSecurityErrorCode.RequestInvalid)
110
+ },
111
+ )
112
+ })
113
+
114
+ describe("RateLimitGuard", () => {
115
+ it("admits a caller under the default limit of an unmarked door, counting in the shared live window", async () => {
116
+ const entityManager = mockEntityManager(liveWindow(2))
117
+ const { guard } = await build(entityManager)
118
+
119
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
120
+ expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: "default:127.0.0.1" })
121
+ })
122
+
123
+ it("refuses a caller whose shared window is over the default limit", async () => {
124
+ const { guard } = await build(mockEntityManager(liveWindow(3)))
125
+
126
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
127
+ code: HttpSecurityErrorCode.RateLimited,
128
+ })
129
+ })
130
+
131
+ it("refuses the request when the counted window vanished before it was read", async () => {
132
+ const entityManager = mockEntityManager({
133
+ increment: [RateWindowEntity, { affected: 1 }],
134
+ findOneBy: [RateWindowEntity, null],
135
+ })
136
+ const { guard } = await build(entityManager)
137
+
138
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
139
+ code: HttpSecurityErrorCode.RateLimited,
140
+ })
141
+ })
142
+ })
143
+
144
+ describe("RateLimitGuard opening a window", () => {
145
+ it("restarts an ended window at one hit and prunes the ended windows", async () => {
146
+ const ended = windowOf(5)
147
+ const entityManager = mockEntityManager({
148
+ increment: [RateWindowEntity, {}],
149
+ update: [RateWindowEntity, { affected: 1 }],
150
+ find: [RateWindowEntity, [ended]],
151
+ delete: [RateWindowEntity, { affected: 1 }],
152
+ })
153
+ const { guard } = await build(entityManager)
154
+
155
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
156
+ expect(entityManager.update).toHaveBeenCalledWith(
157
+ RateWindowEntity,
158
+ { key: "default:127.0.0.1", resetAt: LessThanOrEqual(new Date(AT)) },
159
+ { hits: 1, resetAt: new Date(RESET_AT) },
160
+ )
161
+ expect(entityManager.find).toHaveBeenCalledWith(RateWindowEntity, {
162
+ select: { key: true },
163
+ where: { resetAt: LessThanOrEqual(new Date(AT)) },
164
+ order: { resetAt: "ASC" },
165
+ take: 100,
166
+ })
167
+ expect(entityManager.delete).toHaveBeenCalledWith(RateWindowEntity, {
168
+ key: In([ended.key]),
169
+ resetAt: LessThanOrEqual(new Date(AT)),
170
+ })
171
+ })
172
+
173
+ it("opens a missing window at one hit, ignoring a concurrent opener, and deletes nothing when no window ended", async () => {
174
+ const entityManager = mockEntityManager(OPENING_WINDOW)
175
+ const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
176
+ const { guard } = await build(entityManager)
177
+
178
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
179
+ expect(opened?.callsOf("values")).toEqual([
180
+ [{ key: "default:127.0.0.1", hits: 1, resetAt: new Date(RESET_AT) }],
181
+ ])
182
+ expect(opened?.callsOf("orIgnore")).toEqual([[]])
183
+ expect(entityManager.delete).not.toHaveBeenCalled()
184
+ })
185
+
186
+ it("opens a window when the restart answers no affected count", async () => {
187
+ const entityManager = mockEntityManager({
188
+ increment: [RateWindowEntity, { affected: 0 }],
189
+ update: [RateWindowEntity, {}],
190
+ find: [RateWindowEntity, []],
191
+ })
192
+ const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
193
+ const { guard } = await build(entityManager)
194
+
195
+ await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
196
+ expect(opened?.callsOf("insert")).toEqual([[]])
197
+ })
198
+ })
199
+
200
+ describe("RateLimitGuard keys", () => {
201
+ it("counts a request without an address under the unknown key", async () => {
202
+ const entityManager = mockEntityManager(liveWindow(2))
203
+ const { guard } = await build(entityManager)
204
+
205
+ await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(true)
206
+ expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: "default:unknown" })
207
+ })
208
+
209
+ it("bounds the window key to the length the table stores", async () => {
210
+ const entityManager = mockEntityManager(liveWindow(2))
211
+ const { guard } = await build(entityManager)
212
+
213
+ await guard.canActivate(contextOf("f".repeat(300)))
214
+
215
+ expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: `default:${"f".repeat(120)}` })
216
+ })
217
+ })
218
+
219
+ describe("RateLimitGuard on a redirect-only door", () => {
220
+ const EXCEEDED: RateLimitExceeded = { redirect: "/sign-in?auth=error" }
221
+
222
+ /** A response whose redirect is a plain double, so the spec reads the call without the deprecated overload. */
223
+ const responseOf = () => {
224
+ const redirect = jest.fn()
225
+ return { response: mock<Response>({ redirect }), redirect }
226
+ }
227
+
228
+ it("answers the door's own 303 when the caller is over the limit, and still refuses", async () => {
229
+ const { guard } = await build(mockEntityManager(liveWindow(2)), RateTier.Strict, EXCEEDED)
230
+ const { response, redirect } = responseOf()
231
+
232
+ await expect(
233
+ guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response)),
234
+ ).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
235
+ expect(redirect).toHaveBeenCalledWith(303, "/sign-in?auth=error")
236
+ })
237
+
238
+ it("writes no redirect while the caller is under the limit", async () => {
239
+ const entityManager = mockEntityManager(OPENING_WINDOW)
240
+ queueQueryBuilders(entityManager, [{ execute: {} }])
241
+ const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
242
+ const { response, redirect } = responseOf()
243
+
244
+ await expect(guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response))).resolves.toBe(
245
+ true,
246
+ )
247
+ expect(redirect).not.toHaveBeenCalled()
248
+ })
249
+
250
+ it("refuses a GraphQL operation over the limit without writing a redirect", async () => {
251
+ const { guard } = await build(mockEntityManager(liveWindow(2)), RateTier.Strict, EXCEEDED)
252
+ const { response, redirect } = responseOf()
253
+ const context = contextOf("127.0.0.1", () => undefined, class Door {}, response)
254
+ context.getType.mockReturnValue("graphql")
255
+ context.getArgs.mockReturnValue([{}, {}, { req: mock<Request>({ headers: {}, ip: "127.0.0.1" }) }, {}])
256
+
257
+ await expect(guard.canActivate(context)).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
258
+ expect(redirect).not.toHaveBeenCalled()
259
+ })
260
+ })
@@ -0,0 +1,133 @@
1
+ import { Injectable, SetMetadata, applyDecorators } from "@nestjs/common"
2
+ import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
+ import type { Reflector } from "@nestjs/core"
4
+ import type { Response } from "express"
5
+ import { In, LessThanOrEqual, MoreThan } from "typeorm"
6
+ import type { EntityManager } from "typeorm"
7
+ import { InjectClock } from "@modules/platform/clock"
8
+ import type { Clock } from "@modules/platform/clock"
9
+ import { InjectReflector } from "@modules/platform/composition"
10
+ import type { DoorDecorator } from "@modules/platform/composition"
11
+ import { InjectPrimaryEntityManager } from "@modules/platform/database"
12
+ import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
13
+ import { requestOf } from "./execution-request.mapper"
14
+ import { InjectHttpSecurityOptions } from "./http-security.decorators"
15
+ import type { HttpSecurityOptions } from "./http-security.options"
16
+ import { RateWindowEntity } from "./persistence/entities/rate-window.entity"
17
+
18
+ /** The rate limit tiers of a door. */
19
+ export enum RateTier {
20
+ /** The global default tier. */
21
+ Default = "default",
22
+ /** The tight tier every authentication handshake and signed webhook carries. */
23
+ Strict = "strict",
24
+ }
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
+
32
+ const RATE_TIER_KEY = "platform.http-security.rate-tier"
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
36
+ /** The most ended windows one fresh window prunes. */
37
+ const PRUNE_BATCH = 100
38
+
39
+ /** A same-site path: one leading slash, never a protocol-relative `//` or `/\` that leaves the site. */
40
+ const isSameSitePath = (location: string): boolean =>
41
+ location.startsWith("/") && !location.startsWith("//") && !location.startsWith("/\\")
42
+
43
+ /**
44
+ * Marks a door with a rate limit tier other than the default. A door whose contract answers only redirects (an OAuth
45
+ * callback) names its error redirect in `exceeded`, and a caller over the limit gets that 303 instead of a 429 body.
46
+ */
47
+ export const RateLimit = (tier: RateTier, exceeded?: RateLimitExceeded): DoorDecorator => {
48
+ if (exceeded === undefined) return applyDecorators(SetMetadata(RATE_TIER_KEY, tier))
49
+ if (!isSameSitePath(exceeded.redirect))
50
+ throw new HttpSecurityError({
51
+ code: HttpSecurityErrorCode.RequestInvalid,
52
+ params: { fields: "RATE_LIMIT_REDIRECT" },
53
+ })
54
+ return applyDecorators(SetMetadata(RATE_TIER_KEY, tier), SetMetadata(RATE_EXCEEDED_KEY, exceeded))
55
+ }
56
+
57
+ @Injectable()
58
+ /**
59
+ * The first app guard: a fixed-window counter per caller address and tier, stamped by the Clock and kept in the primary
60
+ * database through its entity, so every replica counts against the same window. A hit that opens a fresh window also prunes
61
+ * a bounded batch of expired windows.
62
+ */
63
+ export class RateLimitGuard implements CanActivate {
64
+ constructor(
65
+ @InjectReflector() private readonly reflector: Reflector,
66
+ @InjectClock() private readonly clock: Clock,
67
+ @InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
68
+ @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
69
+ ) {}
70
+
71
+ /**
72
+ * Counts the request and refuses it when the caller is over the limit of the door tier. A door that declares its
73
+ * exceeded redirect has the 303 written here; the refusal still travels, and the errors filter leaves the sent
74
+ * response as it is.
75
+ */
76
+ async canActivate(context: ExecutionContext): Promise<boolean> {
77
+ const targets = [context.getHandler(), context.getClass()]
78
+ const tier = this.reflector.getAllAndOverride<RateTier | undefined>(RATE_TIER_KEY, targets) ?? RateTier.Default
79
+ const limit =
80
+ tier === RateTier.Strict ? this.options.rateLimit.strictLimit : this.options.rateLimit.defaultLimit
81
+ const now = this.clock.now()
82
+ const key = `${tier}:${requestOf(context).ip ?? "unknown"}`.slice(0, KEY_MAX)
83
+ const resetAt = new Date(now.getTime() + this.options.rateLimit.windowMs)
84
+ const hits = await this.hit(key, now, resetAt)
85
+ if (hits === 1) await this.prune(now)
86
+ if (hits <= limit) return true
87
+ const exceeded = this.reflector.getAllAndOverride<RateLimitExceeded | undefined>(RATE_EXCEEDED_KEY, targets)
88
+ if (exceeded !== undefined && context.getType() === "http")
89
+ context.switchToHttp().getResponse<Response>().redirect(303, exceeded.redirect)
90
+ throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
91
+ }
92
+
93
+ /**
94
+ * Counts one hit in the caller's window and answers the hits it holds: a live window grows by one in one atomic increment,
95
+ * an ended window restarts at one hit ending at `resetAt`, and a missing one opens at one hit (a concurrent opener's row
96
+ * wins and this hit is its first). A window that vanished between the writes counts as over every limit.
97
+ */
98
+ private async hit(key: string, now: Date, resetAt: Date): Promise<number> {
99
+ const counted = await this.entityManager.increment(RateWindowEntity, { key, resetAt: MoreThan(now) }, "hits", 1)
100
+ if ((counted.affected ?? 0) > 0) {
101
+ const window = await this.entityManager.findOneBy(RateWindowEntity, { key })
102
+ return window?.hits ?? Number.POSITIVE_INFINITY
103
+ }
104
+ const restarted = await this.entityManager.update(
105
+ RateWindowEntity,
106
+ { key, resetAt: LessThanOrEqual(now) },
107
+ { hits: 1, resetAt },
108
+ )
109
+ if ((restarted.affected ?? 0) > 0) return 1
110
+ await this.entityManager
111
+ .createQueryBuilder(RateWindowEntity, "window")
112
+ .insert()
113
+ .values({ key, hits: 1, resetAt })
114
+ .orIgnore()
115
+ .execute()
116
+ return 1
117
+ }
118
+
119
+ /** Deletes a bounded batch of the windows that ended by `now`; a window a hit restarted meanwhile is kept. */
120
+ private async prune(now: Date): Promise<void> {
121
+ const ended = await this.entityManager.find(RateWindowEntity, {
122
+ select: { key: true },
123
+ where: { resetAt: LessThanOrEqual(now) },
124
+ order: { resetAt: "ASC" },
125
+ take: PRUNE_BATCH,
126
+ })
127
+ if (ended.length === 0) return
128
+ await this.entityManager.delete(RateWindowEntity, {
129
+ key: In(ended.map((window) => window.key)),
130
+ resetAt: LessThanOrEqual(now),
131
+ })
132
+ }
133
+ }