@starci/hfs 4.11.13 → 4.11.16

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/db/migrate.mjs +1 -1
  3. package/db/postgres.mjs +38 -3
  4. package/package.json +1 -1
  5. package/release/index.mjs +4 -3
  6. package/release/lint-baseline.mjs +123 -0
  7. package/release/urgent.mjs +19 -4
  8. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  9. package/runtime/knowledge/hfs/rules.yaml +4 -4
  10. package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
  11. package/runtime/scripts/hfs/architecture/identity.mjs +1 -1
  12. package/runtime/scripts/hfs/architecture/jwt-ast.mjs +14 -9
  13. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
  14. package/scaffold/add-compose.mjs +3 -0
  15. package/scaffold/add-inbox-lite.mjs +45 -23
  16. package/scaffold/lite-exports.mjs +13 -0
  17. package/src/main.mjs +2 -2
  18. package/stack/cli.mjs +5 -3
  19. package/stack/deploy.mjs +6 -2
  20. package/stack/urgent-floor.mjs +101 -7
  21. package/templates/be/inbox/entities.index.ts.tpl +4 -0
  22. package/templates/be/inbox/entity.ts.tpl +29 -0
  23. package/templates/be/inbox/index.ts.tpl +1 -0
  24. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
  25. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
  26. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
  27. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
  28. package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
  29. package/templates/be/inbox/service.spec.ts.tpl +39 -15
  30. package/templates/be/inbox/service.ts.tpl +13 -13
  31. package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
  32. package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +18 -8
  33. package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +12 -3
  34. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +39 -17
  35. package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
  36. package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
  37. package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
  38. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  39. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
  40. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
  41. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +8 -1
  42. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +121 -9
  43. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +12 -6
  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/rate-limit.guard.ts +3 -3
  46. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  47. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  48. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
  49. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
  50. package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
  51. package/templates/be/inbox/database-client.ts.tpl +0 -45
@@ -0,0 +1,61 @@
1
+ import { isRecord } from "@modules/platform/primitives"
2
+ import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
3
+
4
+ /** What node-postgres hands back for one returned column; a kind ending in `?` also admits SQL null. */
5
+ interface ColumnTypes {
6
+ readonly string: string
7
+ readonly number: number
8
+ readonly boolean: boolean
9
+ readonly date: Date
10
+ readonly json: unknown
11
+ readonly "string?": string | null
12
+ readonly "number?": number | null
13
+ readonly "boolean?": boolean | null
14
+ readonly "date?": Date | null
15
+ }
16
+
17
+ /** The kind one returned column is checked against. */
18
+ type ColumnKind = keyof ColumnTypes
19
+
20
+ /** The returned columns of one statement, each named as the database returns it (snake_case) with its kind. */
21
+ type ColumnShape = Readonly<Record<string, ColumnKind>>
22
+
23
+ /** The row a shape describes: every named column, typed by its kind. */
24
+ type RowOf<Shape extends ColumnShape> = { readonly [Column in keyof Shape]: ColumnTypes[Shape[Column]] }
25
+
26
+ /** A guard that proves one returned row has the shape its returned columns give it. */
27
+ type RowGuard<Row> = (value: unknown) => value is Row
28
+
29
+ /** Whether `value` is of the non-null part of a kind. */
30
+ const KIND_CHECKS: Readonly<Record<ColumnKind, (value: unknown) => boolean>> = {
31
+ string: (value) => typeof value === "string",
32
+ number: (value) => typeof value === "number",
33
+ boolean: (value) => typeof value === "boolean",
34
+ date: (value) => value instanceof Date,
35
+ json: (value) => value !== undefined,
36
+ "string?": (value) => value === null || typeof value === "string",
37
+ "number?": (value) => value === null || typeof value === "number",
38
+ "boolean?": (value) => value === null || typeof value === "boolean",
39
+ "date?": (value) => value === null || value instanceof Date,
40
+ }
41
+
42
+ /**
43
+ * The guard of the rows a RETURNING clause answers for `shape`: an object holding every named column with a value of its kind.
44
+ * A column the statement does not return is refused, so the shape and the RETURNING list cannot drift apart unseen.
45
+ */
46
+ export const rowShape =
47
+ <Shape extends ColumnShape>(shape: Shape): RowGuard<RowOf<Shape>> =>
48
+ (value: unknown): value is RowOf<Shape> =>
49
+ isRecord(value) && Object.entries(shape).every(([column, kind]) => KIND_CHECKS[kind](value[column]))
50
+
51
+ /**
52
+ * The rows a RETURNING statement answered, each proven by `isRow`: the result of `manager.query` for an INSERT or a CTE-wrapped
53
+ * UPDATE or DELETE, or the `raw` of a query builder's `execute()`. A result that is not a list of rows, or a row the guard
54
+ * refuses, throws `DATABASE_RETURNING_ROW_REJECTED`: the statement and the shape its caller expects disagree.
55
+ */
56
+ export const returnedRows = <Row>(raw: unknown, isRow: RowGuard<Row>): Array<Row> => {
57
+ if (!Array.isArray(raw)) throw new DatabaseError({ code: DatabaseErrorCode.ReturningRowRejected })
58
+ const rows: ReadonlyArray<unknown> = raw
59
+ if (!rows.every(isRow)) throw new DatabaseError({ code: DatabaseErrorCode.ReturningRowRejected })
60
+ return [...rows]
61
+ }
@@ -1,8 +1,15 @@
1
+ import { SetMetadata } from "@nestjs/common"
1
2
  import { injector } from "@modules/platform/composition"
2
- import type { TypedParameterDecorator } from "@modules/platform/composition"
3
+ import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
3
4
  import type { HttpSecurityOptions } from "./http-security.options"
4
5
  import type { WebhookSignatureService } from "./webhook-signature.service"
5
6
 
7
+ /** Metadata for doors that require an actual Origin, including the start of a cookie-authenticated flow. */
8
+ export const REQUIRE_ORIGIN_KEY = "platform.http-security.require-origin"
9
+
10
+ /** Requires an allowed Origin on mutations even before the caller has received its first cookie. */
11
+ export const RequireOrigin = (): DoorDecorator => SetMetadata(REQUIRE_ORIGIN_KEY, true)
12
+
6
13
  /** Token of the http-security options, exported so a spec can provide it. */
7
14
  export const HTTP_SECURITY_OPTIONS: unique symbol = Symbol("platform.http-security.options")
8
15
 
@@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core"
5
5
  import { Test } from "@nestjs/testing"
6
6
  import { mock } from "@starci/jest-preset"
7
7
  import { HttpSecurityErrorCode } from "./errors/http-security.error"
8
- import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
8
+ import { HTTP_SECURITY_OPTIONS, RequireOrigin } from "./http-security.decorators"
9
9
  import type { HttpSecurityOptions } from "./http-security.options"
10
10
  import { OriginGuard, ServerToServer } from "./origin.guard"
11
11
 
@@ -17,16 +17,31 @@ const options: HttpSecurityOptions = {
17
17
 
18
18
  class OpenDoor {}
19
19
 
20
+ @RequireOrigin()
21
+ class CookieDoor {}
22
+
23
+ class SignInDoor {
24
+ @RequireOrigin()
25
+ start(): undefined {
26
+ return undefined
27
+ }
28
+ }
29
+
20
30
  class SignedDoor {}
21
31
  ServerToServer()(SignedDoor)
22
32
 
23
- const build = async (method: string, headers: Request["headers"], door: Type<unknown> = OpenDoor) => {
33
+ const build = async (
34
+ method: string,
35
+ headers: Request["headers"],
36
+ door: Type<unknown> = OpenDoor,
37
+ handler: () => void = () => undefined,
38
+ ) => {
24
39
  const request = mock<Request>({ headers, method })
25
40
  const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
26
41
  http.getRequest.mockReturnValue(request)
27
42
  const context = mock<ExecutionContext>()
28
43
  context.switchToHttp.mockReturnValue(http)
29
- context.getHandler.mockReturnValue(() => undefined)
44
+ context.getHandler.mockReturnValue(handler)
30
45
  context.getClass.mockReturnValue(door)
31
46
  const moduleRef = await Test.createTestingModule({
32
47
  providers: [OriginGuard, Reflector, { provide: HTTP_SECURITY_OPTIONS, useValue: options }],
@@ -36,19 +51,23 @@ const build = async (method: string, headers: Request["headers"], door: Type<unk
36
51
 
37
52
  describe("OriginGuard", () => {
38
53
  it.each(["GET", "HEAD", "OPTIONS"])("allows the safe %s method without an origin", async (method) => {
39
- const { guard, context } = await build(method, {})
54
+ const { guard, context } = await build(method, { cookie: "session=opaque" }, CookieDoor)
40
55
 
41
56
  expect(guard.canActivate(context)).toBe(true)
42
57
  })
43
58
 
44
59
  it("allows a state-changing request carrying an allowed Origin header", async () => {
45
- const { guard, context } = await build("POST", { origin: "https://app.test" })
60
+ const { guard, context } = await build("POST", {
61
+ origin: "https://app.test",
62
+ })
46
63
 
47
64
  expect(guard.canActivate(context)).toBe(true)
48
65
  })
49
66
 
50
67
  it("falls back to the origin of an allowed Referer when Origin is absent", async () => {
51
- const { guard, context } = await build("PATCH", { referer: "https://app.test/notes/one" })
68
+ const { guard, context } = await build("PATCH", {
69
+ referer: "https://app.test/notes/one",
70
+ })
52
71
 
53
72
  expect(guard.canActivate(context)).toBe(true)
54
73
  })
@@ -56,12 +75,21 @@ describe("OriginGuard", () => {
56
75
  it.each([
57
76
  { name: "no Origin and no Referer", headers: {} },
58
77
  { name: "a Referer that is not a URL", headers: { referer: "not a URL" } },
59
- { name: "an Origin outside the allowlist", headers: { origin: "https://foreign.test" } },
78
+ {
79
+ name: "an Origin outside the allowlist",
80
+ headers: { origin: "https://foreign.test" },
81
+ },
60
82
  { name: "the opaque null Origin", headers: { origin: "null" } },
61
- { name: "a Referer outside the allowlist", headers: { referer: "https://foreign.test/form" } },
83
+ {
84
+ name: "a Referer outside the allowlist",
85
+ headers: { referer: "https://foreign.test/form" },
86
+ },
62
87
  {
63
88
  name: "a foreign Origin beside an allowed Referer",
64
- headers: { origin: "https://foreign.test", referer: "https://app.test/notes/one" },
89
+ headers: {
90
+ origin: "https://foreign.test",
91
+ referer: "https://app.test/notes/one",
92
+ },
65
93
  },
66
94
  ])("refuses a state-changing request with $name", async ({ headers }) => {
67
95
  const { guard, context } = await build("DELETE", headers)
@@ -75,3 +103,87 @@ describe("OriginGuard", () => {
75
103
  expect(guard.canActivate(context)).toBe(true)
76
104
  })
77
105
  })
106
+
107
+ describe("OriginGuard", () => {
108
+ it.each(["POST", "PUT", "PATCH", "DELETE"])(
109
+ "allows a cookie-bearing %s with an actual allowed Origin",
110
+ async (method) => {
111
+ const { guard, context } = await build(method, {
112
+ origin: "https://app.test",
113
+ cookie: "session=opaque",
114
+ })
115
+
116
+ expect(guard.canActivate(context)).toBe(true)
117
+ },
118
+ )
119
+
120
+ it.each([
121
+ { name: "no Origin", headers: {} },
122
+ {
123
+ name: "an allowed Referer alone",
124
+ headers: { referer: "https://app.test/form" },
125
+ },
126
+ { name: "a malformed Referer alone", headers: { referer: "not a URL" } },
127
+ { name: "a foreign Origin", headers: { origin: "https://foreign.test" } },
128
+ { name: "an opaque Origin", headers: { origin: "null" } },
129
+ { name: "an empty Origin", headers: { origin: "" } },
130
+ {
131
+ name: "a foreign Origin beside an allowed Referer",
132
+ headers: {
133
+ origin: "https://foreign.test",
134
+ referer: "https://app.test/form",
135
+ },
136
+ },
137
+ ])("refuses a cookie-bearing mutation with $name", async ({ headers }) => {
138
+ const { guard, context } = await build("POST", {
139
+ ...headers,
140
+ cookie: "session=opaque",
141
+ })
142
+
143
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
144
+ })
145
+ })
146
+
147
+ describe("OriginGuard", () => {
148
+ it.each([{}, { referer: "https://app.test/sign-in" }])(
149
+ "refuses a marked cookie-flow door without Origin before a cookie exists (%o)",
150
+ async (headers) => {
151
+ const { guard, context } = await build("POST", headers, CookieDoor)
152
+
153
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
154
+ },
155
+ )
156
+
157
+ it("requires Origin on an individually marked handler before a cookie exists", async () => {
158
+ const { guard, context } = await build("POST", {}, SignInDoor, SignInDoor.prototype.start)
159
+
160
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
161
+ })
162
+
163
+ it("admits a marked cookie-flow door with an actual allowed Origin before a cookie exists", async () => {
164
+ const { guard, context } = await build("POST", { origin: "https://app.test" }, CookieDoor)
165
+
166
+ expect(guard.canActivate(context)).toBe(true)
167
+ })
168
+
169
+ it("refuses Referer-only admission when a server-to-server door receives a cookie", async () => {
170
+ const { guard, context } = await build(
171
+ "POST",
172
+ { cookie: "session=opaque", referer: "https://app.test/form" },
173
+ SignedDoor,
174
+ )
175
+
176
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
177
+ })
178
+
179
+ it("refuses Referer-only admission when a server-to-server handler requires Origin", async () => {
180
+ const { guard, context } = await build(
181
+ "POST",
182
+ { referer: "https://app.test/form" },
183
+ SignedDoor,
184
+ SignInDoor.prototype.start,
185
+ )
186
+
187
+ expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
188
+ })
189
+ })
@@ -4,7 +4,7 @@ import type { Reflector } from "@nestjs/core"
4
4
  import { InjectReflector } from "@modules/platform/composition"
5
5
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
6
6
  import { requestOf } from "./execution-request.mapper"
7
- import { InjectHttpSecurityOptions } from "./http-security.decorators"
7
+ import { InjectHttpSecurityOptions, REQUIRE_ORIGIN_KEY } from "./http-security.decorators"
8
8
  import type { HttpSecurityOptions } from "./http-security.options"
9
9
 
10
10
  const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"])
@@ -26,9 +26,8 @@ const originOf = (origin: string | undefined, referer: string | undefined): stri
26
26
 
27
27
  @Injectable()
28
28
  /**
29
- * The second app guard, the CSRF defence: a state-changing request must name an allowed origin, by its Origin header or,
30
- * when Origin is absent, by its Referer. A request that names none is refused, since a session cookie rides on it
31
- * whatever sent it. A door marked `ServerToServer` proves its caller itself and owes no origin.
29
+ * The second app guard, the CSRF defence: cookie-bearing mutations and doors marked RequireOrigin need an actual allowed
30
+ * Origin. Other mutations retain Referer fallback and the ServerToServer exemption for callers proving themselves.
32
31
  */
33
32
  export class OriginGuard implements CanActivate {
34
33
  constructor(
@@ -40,12 +39,19 @@ export class OriginGuard implements CanActivate {
40
39
  canActivate(context: ExecutionContext): boolean {
41
40
  const request = requestOf(context)
42
41
  if (SAFE_METHODS.has(request.method)) return true
42
+ const requiresOrigin =
43
+ this.reflector.getAllAndOverride<boolean | undefined>(REQUIRE_ORIGIN_KEY, [
44
+ context.getHandler(),
45
+ context.getClass(),
46
+ ]) === true || request.headers.cookie !== undefined
43
47
  const exempt = this.reflector.getAllAndOverride<boolean | undefined>(SERVER_TO_SERVER_KEY, [
44
48
  context.getHandler(),
45
49
  context.getClass(),
46
50
  ])
47
- if (exempt === true) return true
48
- const origin = originOf(request.headers.origin, request.headers.referer)
51
+ if (!requiresOrigin && exempt === true) return true
52
+ const origin = requiresOrigin
53
+ ? request.headers.origin
54
+ : originOf(request.headers.origin, request.headers.referer)
49
55
  if (origin !== undefined && this.options.allowedOrigins.includes(origin)) return true
50
56
  throw new HttpSecurityError({ code: HttpSecurityErrorCode.OriginRejected })
51
57
  }
@@ -1,5 +1,10 @@
1
+ import { rowShape } from "@modules/platform/database"
2
+
1
3
  /** The row HIT_RATE_WINDOW answers. */
2
4
  export interface RateWindowRow {
3
5
  /** The hits of the caller's current window, this one included. */
4
6
  readonly hits: number
5
7
  }
8
+
9
+ /** Proves one row HIT_RATE_WINDOW answered is a RateWindowRow. */
10
+ export const isRateWindowRow: (value: unknown) => value is RateWindowRow = rowShape({ hits: "number" })
@@ -7,12 +7,12 @@ import { InjectClock } from "@modules/platform/clock"
7
7
  import type { Clock } from "@modules/platform/clock"
8
8
  import { InjectReflector } from "@modules/platform/composition"
9
9
  import type { DoorDecorator } from "@modules/platform/composition"
10
- import { InjectPrimaryEntityManager } from "@modules/platform/database"
10
+ import { InjectPrimaryEntityManager, returnedRows } from "@modules/platform/database"
11
11
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
12
12
  import { requestOf } from "./execution-request.mapper"
13
13
  import { InjectHttpSecurityOptions } from "./http-security.decorators"
14
14
  import type { HttpSecurityOptions } from "./http-security.options"
15
- import type { RateWindowRow } from "./persistence/rate-limit.rows"
15
+ import { isRateWindowRow } from "./persistence/rate-limit.rows"
16
16
  import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
17
17
 
18
18
  /** The rate limit tiers of a door. */
@@ -79,7 +79,7 @@ export class RateLimitGuard implements CanActivate {
79
79
  const now = this.clock.now()
80
80
  const key = `${tier}:${requestOf(context).ip ?? "unknown"}`.slice(0, KEY_MAX)
81
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])
82
+ const rows = returnedRows(await this.entityManager.query(HIT_RATE_WINDOW, [key, now, resetAt]), isRateWindowRow)
83
83
  const hits = rows[0]?.hits ?? Number.POSITIVE_INFINITY
84
84
  if (hits === 1) await this.entityManager.query(PRUNE_RATE_WINDOWS, [now])
85
85
  if (hits <= limit) return true
@@ -7,12 +7,15 @@ export enum DatabaseErrorCode {
7
7
  ConnectionUndeclared = "DATABASE_CONNECTION_UNDECLARED",
8
8
  /** A dynamic SQL identifier was not on the names its caller allows. */
9
9
  IdentifierRejected = "DATABASE_IDENTIFIER_REJECTED",
10
+ /** A RETURNING statement answered something other than rows of the shape its caller expects. */
11
+ ReturningRowRejected = "DATABASE_RETURNING_ROW_REJECTED",
10
12
  }
11
13
 
12
14
  /** How each database code travels. */
13
15
  export const DATABASE_ERROR_KINDS: Record<DatabaseErrorCode, ErrorKind> = {
14
16
  [DatabaseErrorCode.ConnectionUndeclared]: "internal",
15
17
  [DatabaseErrorCode.IdentifierRejected]: "internal",
18
+ [DatabaseErrorCode.ReturningRowRejected]: "internal",
16
19
  }
17
20
 
18
21
  /** The one error class of the database capability. */
@@ -4,4 +4,5 @@ export { DatabaseProbeService } from "./database-probe.service"
4
4
  export type { DatabaseConnectionOptions } from "./database.options"
5
5
  export { PRIMARY_DATA_SOURCE } from "./database.port"
6
6
  export { DATABASE_ERROR_KINDS } from "./errors/database.error"
7
+ export { returnedRows, rowShape } from "./returned-rows.policy"
7
8
  export { parsePrimaryDatabaseConfig } from "./primary.config"
@@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core"
5
5
  import { Test } from "@nestjs/testing"
6
6
  import { In, LessThanOrEqual, MoreThan } from "typeorm"
7
7
  import { FakeClock, mock, mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
8
- import type { MockEntityManager } from "@starci/jest-preset"
8
+ import type { FakeQueryBuilder, MockEntityManager } from "@starci/jest-preset"
9
9
  import { CLOCK } from "@modules/platform/clock"
10
10
  import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
11
11
  import { HttpSecurityErrorCode } from "./errors/http-security.error"
@@ -23,20 +23,31 @@ const options: HttpSecurityOptions = {
23
23
  webhooks: {},
24
24
  }
25
25
 
26
- /** The caller's live window holding `hits`. */
26
+ /** The caller's window holding `hits`. */
27
27
  const windowOf = (hits: number): RateWindowEntity => ({ key: "default:127.0.0.1", hits, resetAt: new Date(RESET_AT) })
28
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
29
+ /** A store with a live window of the caller, and the builder of the UPDATE that counted this hit in it. */
30
+ interface LiveWindow {
31
+ readonly entityManager: MockEntityManager
32
+ readonly counting: FakeQueryBuilder | undefined
33
+ }
34
+
35
+ /** A store whose live window of the caller holds `hits` after this hit: the counting UPDATE returns them. */
36
+ const liveWindow = (hits: number): LiveWindow => {
37
+ const entityManager = mockEntityManager()
38
+ const [counting] = queueQueryBuilders(entityManager, [{ execute: { raw: [{ hits }] } }])
39
+ return { entityManager, counting }
40
+ }
32
41
 
33
42
  /** 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
43
  const OPENING_WINDOW = {
35
- increment: [RateWindowEntity, { affected: 0 }],
36
44
  update: [RateWindowEntity, { affected: 0 }],
37
45
  find: [RateWindowEntity, []],
38
46
  } as const
39
47
 
48
+ /** The counting UPDATE of a hit that finds no live window: it returns no row. */
49
+ const NO_LIVE_WINDOW = { execute: { raw: [] } } as const
50
+
40
51
  const build = async (entityManager: MockEntityManager, tier?: RateTier, exceeded?: RateLimitExceeded) => {
41
52
  const reflector = mock<Reflector>()
42
53
  reflector.getAllAndOverride.mockImplementation((key: unknown) =>
@@ -76,7 +87,7 @@ describe("RateLimit", () => {
76
87
  it("marks a door with the tier it declares, and the guard counts it under that tier's limit", async () => {
77
88
  class Door {}
78
89
  RateLimit(RateTier.Strict)(Door)
79
- const entityManager = mockEntityManager(liveWindow(2))
90
+ const { entityManager, counting } = liveWindow(2)
80
91
  const { guard, reflector } = await build(entityManager, RateTier.Strict)
81
92
  const handler = () => undefined
82
93
 
@@ -85,12 +96,7 @@ describe("RateLimit", () => {
85
96
  code: HttpSecurityErrorCode.RateLimited,
86
97
  })
87
98
  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
- )
99
+ expect(counting?.callsOf("where")).toEqual([[{ key: "strict:127.0.0.1", resetAt: MoreThan(new Date(AT)) }]])
94
100
  })
95
101
 
96
102
  it("marks a redirect-only door with its exceeded redirect beside the tier", () => {
@@ -113,30 +119,31 @@ describe("RateLimit", () => {
113
119
 
114
120
  describe("RateLimitGuard", () => {
115
121
  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))
122
+ const { entityManager, counting } = liveWindow(2)
117
123
  const { guard } = await build(entityManager)
118
124
 
119
125
  await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
120
- expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: "default:127.0.0.1" })
126
+ expect(counting?.callsOf("update")).toEqual([[]])
127
+ expect(counting?.callsOf("returning")).toEqual([["hits"]])
128
+ expect(counting?.callsOf("where")).toEqual([[{ key: "default:127.0.0.1", resetAt: MoreThan(new Date(AT)) }]])
129
+ expect(entityManager.update).not.toHaveBeenCalled()
121
130
  })
122
131
 
123
132
  it("refuses a caller whose shared window is over the default limit", async () => {
124
- const { guard } = await build(mockEntityManager(liveWindow(3)))
133
+ const { guard } = await build(liveWindow(3).entityManager)
125
134
 
126
135
  await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
127
136
  code: HttpSecurityErrorCode.RateLimited,
128
137
  })
129
138
  })
130
139
 
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
- })
140
+ it("refuses to count from a returned row that is not the window's hits", async () => {
141
+ const entityManager = mockEntityManager()
142
+ queueQueryBuilders(entityManager, [{ execute: { raw: [{ hits: "2" }] } }])
136
143
  const { guard } = await build(entityManager)
137
144
 
138
145
  await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
139
- code: HttpSecurityErrorCode.RateLimited,
146
+ code: "DATABASE_RETURNING_ROW_REJECTED",
140
147
  })
141
148
  })
142
149
  })
@@ -145,11 +152,11 @@ describe("RateLimitGuard opening a window", () => {
145
152
  it("restarts an ended window at one hit and prunes the ended windows", async () => {
146
153
  const ended = windowOf(5)
147
154
  const entityManager = mockEntityManager({
148
- increment: [RateWindowEntity, {}],
149
155
  update: [RateWindowEntity, { affected: 1 }],
150
156
  find: [RateWindowEntity, [ended]],
151
157
  delete: [RateWindowEntity, { affected: 1 }],
152
158
  })
159
+ queueQueryBuilders(entityManager, [NO_LIVE_WINDOW])
153
160
  const { guard } = await build(entityManager)
154
161
 
155
162
  await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
@@ -172,7 +179,7 @@ describe("RateLimitGuard opening a window", () => {
172
179
 
173
180
  it("opens a missing window at one hit, ignoring a concurrent opener, and deletes nothing when no window ended", async () => {
174
181
  const entityManager = mockEntityManager(OPENING_WINDOW)
175
- const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
182
+ const [, opened] = queueQueryBuilders(entityManager, [NO_LIVE_WINDOW, { execute: {} }])
176
183
  const { guard } = await build(entityManager)
177
184
 
178
185
  await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
@@ -185,11 +192,10 @@ describe("RateLimitGuard opening a window", () => {
185
192
 
186
193
  it("opens a window when the restart answers no affected count", async () => {
187
194
  const entityManager = mockEntityManager({
188
- increment: [RateWindowEntity, { affected: 0 }],
189
195
  update: [RateWindowEntity, {}],
190
196
  find: [RateWindowEntity, []],
191
197
  })
192
- const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
198
+ const [, opened] = queueQueryBuilders(entityManager, [NO_LIVE_WINDOW, { execute: {} }])
193
199
  const { guard } = await build(entityManager)
194
200
 
195
201
  await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
@@ -199,20 +205,22 @@ describe("RateLimitGuard opening a window", () => {
199
205
 
200
206
  describe("RateLimitGuard keys", () => {
201
207
  it("counts a request without an address under the unknown key", async () => {
202
- const entityManager = mockEntityManager(liveWindow(2))
208
+ const { entityManager, counting } = liveWindow(2)
203
209
  const { guard } = await build(entityManager)
204
210
 
205
211
  await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(true)
206
- expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: "default:unknown" })
212
+ expect(counting?.callsOf("where")).toEqual([[{ key: "default:unknown", resetAt: MoreThan(new Date(AT)) }]])
207
213
  })
208
214
 
209
215
  it("bounds the window key to the length the table stores", async () => {
210
- const entityManager = mockEntityManager(liveWindow(2))
216
+ const { entityManager, counting } = liveWindow(2)
211
217
  const { guard } = await build(entityManager)
212
218
 
213
219
  await guard.canActivate(contextOf("f".repeat(300)))
214
220
 
215
- expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: `default:${"f".repeat(120)}` })
221
+ expect(counting?.callsOf("where")).toEqual([
222
+ [{ key: `default:${"f".repeat(120)}`, resetAt: MoreThan(new Date(AT)) }],
223
+ ])
216
224
  })
217
225
  })
218
226
 
@@ -226,7 +234,7 @@ describe("RateLimitGuard on a redirect-only door", () => {
226
234
  }
227
235
 
228
236
  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)
237
+ const { guard } = await build(liveWindow(2).entityManager, RateTier.Strict, EXCEEDED)
230
238
  const { response, redirect } = responseOf()
231
239
 
232
240
  await expect(
@@ -237,7 +245,7 @@ describe("RateLimitGuard on a redirect-only door", () => {
237
245
 
238
246
  it("writes no redirect while the caller is under the limit", async () => {
239
247
  const entityManager = mockEntityManager(OPENING_WINDOW)
240
- queueQueryBuilders(entityManager, [{ execute: {} }])
248
+ queueQueryBuilders(entityManager, [NO_LIVE_WINDOW, { execute: {} }])
241
249
  const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
242
250
  const { response, redirect } = responseOf()
243
251
 
@@ -248,7 +256,7 @@ describe("RateLimitGuard on a redirect-only door", () => {
248
256
  })
249
257
 
250
258
  it("refuses a GraphQL operation over the limit without writing a redirect", async () => {
251
- const { guard } = await build(mockEntityManager(liveWindow(2)), RateTier.Strict, EXCEEDED)
259
+ const { guard } = await build(liveWindow(2).entityManager, RateTier.Strict, EXCEEDED)
252
260
  const { response, redirect } = responseOf()
253
261
  const context = contextOf("127.0.0.1", () => undefined, class Door {}, response)
254
262
  context.getType.mockReturnValue("graphql")
@@ -8,7 +8,7 @@ import { InjectClock } from "@modules/platform/clock"
8
8
  import type { Clock } from "@modules/platform/clock"
9
9
  import { InjectReflector } from "@modules/platform/composition"
10
10
  import type { DoorDecorator } from "@modules/platform/composition"
11
- import { InjectPrimaryEntityManager } from "@modules/platform/database"
11
+ import { InjectPrimaryEntityManager, returnedRows, rowShape } from "@modules/platform/database"
12
12
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
13
13
  import { requestOf } from "./execution-request.mapper"
14
14
  import { InjectHttpSecurityOptions } from "./http-security.decorators"
@@ -35,6 +35,8 @@ const RATE_EXCEEDED_KEY = "platform.http-security.rate-exceeded"
35
35
  const KEY_MAX = 128
36
36
  /** The most ended windows one fresh window prunes. */
37
37
  const PRUNE_BATCH = 100
38
+ /** The row the counting update returns: the hits of the live window, this one included. */
39
+ const isCountedWindow = rowShape({ hits: "number" })
38
40
 
39
41
  /** A same-site path: one leading slash, never a protocol-relative `//` or `/\` that leaves the site. */
40
42
  const isSameSitePath = (location: string): boolean =>
@@ -91,16 +93,20 @@ export class RateLimitGuard implements CanActivate {
91
93
  }
92
94
 
93
95
  /**
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.
96
+ * Counts one hit in the caller's window and answers the hits it holds: a live window grows by one in one UPDATE that
97
+ * returns its hits, an ended window restarts at one hit ending at `resetAt`, and a missing one opens at one hit (a
98
+ * concurrent opener's row wins and this hit is its first).
97
99
  */
98
100
  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
- }
101
+ const counted = await this.entityManager
102
+ .createQueryBuilder(RateWindowEntity, "window")
103
+ .update()
104
+ .set({ hits: () => "hits + 1" })
105
+ .where({ key, resetAt: MoreThan(now) })
106
+ .returning("hits")
107
+ .execute()
108
+ const [live] = returnedRows(counted.raw, isCountedWindow)
109
+ if (live !== undefined) return live.hits
104
110
  const restarted = await this.entityManager.update(
105
111
  RateWindowEntity,
106
112
  { key, resetAt: LessThanOrEqual(now) },