@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.
- package/CHANGELOG.md +28 -0
- package/README.md +14 -0
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +1 -1
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +1 -1
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +81 -18
- package/release/index.mjs +19 -8
- package/release/push.mjs +4 -4
- package/release/records.mjs +54 -0
- package/release/urgent.mjs +129 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
- package/runtime/knowledge/hfs/rules.yaml +74 -3
- package/runtime/knowledge/hfs/slots.yaml +36 -3
- package/runtime/modules/kernel/failure-codes.yaml +21 -0
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/check.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/scaffold/app.mjs +3 -1
- package/src/main.mjs +7 -4
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +41 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +14 -2
- package/templates/be/skeleton/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +2 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/migrations/1789900000000-create-rate-windows.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +149 -47
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +57 -34
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +2 -1
- 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
|
+
}
|
package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts
ADDED
|
@@ -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 (
|
|
25
|
+
const build = async (entityManager: MockEntityManager, tier?: RateTier, exceeded?: RateLimitExceeded) => {
|
|
21
26
|
const reflector = mock<Reflector>()
|
|
22
|
-
reflector.getAllAndOverride.
|
|
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:
|
|
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 = (
|
|
36
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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(
|
|
56
|
-
|
|
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("
|
|
63
|
-
const {
|
|
64
|
-
const {
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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("
|
|
72
|
-
const
|
|
73
|
-
const { guard } = await build(
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
148
|
+
await guard.canActivate(contextOf("f".repeat(300)))
|
|
81
149
|
|
|
82
|
-
expect(
|
|
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("
|
|
86
|
-
const { guard } = await build(
|
|
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(
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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)).
|
|
108
|
-
expect(
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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()
|
|
52
|
-
const key = `${tier}:${requestOf(context).ip ?? "unknown"}
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
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
|
-
/**
|
|
23
|
-
|
|
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
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
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"
|