@starci/hfs 4.11.12 → 4.11.15

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 (56) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/db/migrate.mjs +1 -1
  4. package/db/postgres.mjs +38 -3
  5. package/package.json +1 -1
  6. package/release/index.mjs +6 -5
  7. package/release/lint-baseline.mjs +123 -0
  8. package/release/urgent.mjs +19 -4
  9. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  10. package/runtime/knowledge/hfs/rules.yaml +3 -3
  11. package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
  12. package/runtime/scripts/api/sops/lib.mjs +25 -3
  13. package/runtime/scripts/api/sops/seal.mjs +4 -3
  14. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
  15. package/runtime/scripts/hfs/secret-init.mjs +15 -0
  16. package/runtime/scripts/hfs/secret-sops.mjs +65 -33
  17. package/runtime/scripts/lib/sops-envelope.mjs +36 -11
  18. package/scaffold/add-compose.mjs +3 -0
  19. package/scaffold/add-inbox-lite.mjs +45 -23
  20. package/scaffold/lite-exports.mjs +13 -0
  21. package/src/main.mjs +5 -5
  22. package/stack/age-install.mjs +10 -2
  23. package/stack/cli.mjs +5 -3
  24. package/stack/context.mjs +2 -2
  25. package/stack/deploy.mjs +6 -2
  26. package/stack/remote.mjs +29 -8
  27. package/stack/sealed.mjs +15 -11
  28. package/stack/server-key.mjs +31 -7
  29. package/stack/urgent-floor.mjs +101 -7
  30. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +1 -1
  31. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +1 -1
  32. package/templates/be/inbox/entities.index.ts.tpl +4 -0
  33. package/templates/be/inbox/entity.ts.tpl +29 -0
  34. package/templates/be/inbox/index.ts.tpl +1 -0
  35. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
  36. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
  37. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
  38. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
  39. package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
  40. package/templates/be/inbox/service.spec.ts.tpl +39 -15
  41. package/templates/be/inbox/service.ts.tpl +13 -13
  42. package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
  43. package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
  44. package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
  45. package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
  46. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  47. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
  48. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
  49. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  50. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
  51. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  52. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  53. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
  54. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
  55. package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
  56. package/templates/be/inbox/database-client.ts.tpl +0 -45
@@ -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) },
@@ -1,45 +0,0 @@
1
- import { mockEntityManager } from "@starci/jest-preset"
2
- import type { EntityManager } from "typeorm"
3
- import { DatabaseErrorCode } from "./errors/database.error"
4
- import { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"
5
-
6
- const INSERT = webhookReceiptInsert("spec_inbox")
7
- const PAYLOAD = { id: "delivery-1" }
8
-
9
- const record = (manager: EntityManager, deliveryId: string | undefined) =>
10
- recordWebhookDelivery({ manager, table: "spec_inbox", provider: "spec", deliveryId, payload: PAYLOAD })
11
-
12
- describe("webhookReceiptInsert", () => {
13
- it("refuses a table name that is not a lowercase identifier", () => {
14
- expect(() => webhookReceiptInsert("spec; drop table x")).toThrow(TypeError)
15
- })
16
- })
17
-
18
- describe("recordWebhookDelivery", () => {
19
- it("records a new delivery through the caller's transaction manager", async () => {
20
- const manager = mockEntityManager({ query: [INSERT, [{ id: "inbox-1" }]] })
21
-
22
- const receiptId = await record(manager, " delivery-1 ")
23
-
24
- expect(manager.query).toHaveBeenCalledTimes(1)
25
- expect(manager.query).toHaveBeenCalledWith(INSERT, ["spec", "delivery-1", JSON.stringify(PAYLOAD)])
26
- expect(receiptId).toBe("inbox-1")
27
- })
28
-
29
- it("acknowledges a duplicate without any completion or business write", async () => {
30
- const manager = mockEntityManager({ query: [INSERT, []] })
31
-
32
- const receiptId = await record(manager, "delivery-1")
33
-
34
- expect(manager.query).toHaveBeenCalledTimes(1)
35
- expect(receiptId).toBeUndefined()
36
- })
37
-
38
- it.each([["no", undefined], ["a blank", " "]])("refuses %s delivery id before writing a receipt", async (_label, deliveryId) => {
39
- const manager = mockEntityManager()
40
-
41
- await expect(record(manager, deliveryId)).rejects.toMatchObject({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
42
-
43
- expect(manager.query).not.toHaveBeenCalled()
44
- })
45
- })
@@ -1,45 +0,0 @@
1
- import type { EntityManager } from "typeorm"
2
- import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
3
-
4
- interface RecordWebhookDelivery {
5
- readonly manager: EntityManager
6
- /** The provider inbox table under public, created by its db migration. */
7
- readonly table: string
8
- readonly provider: string
9
- readonly deliveryId: string | undefined
10
- readonly payload: object
11
- }
12
-
13
- type WebhookReceiptId = string | undefined
14
-
15
- /** A provider inbox table name: a lowercase identifier, never a value. */
16
- const TABLE = /^[a-z_][a-z0-9_]*$/
17
-
18
- /** The conflict-safe insert of one pending delivery into `public.<table>`, answering its id only to the winning transaction. */
19
- export const webhookReceiptInsert = (table: string): string => {
20
- if (!TABLE.test(table)) throw new TypeError(`${table} is not a provider inbox table name`)
21
- return `INSERT INTO public.${table} (provider, delivery_id, payload) VALUES ($1, $2, $3::jsonb) ON CONFLICT (provider, delivery_id) DO NOTHING RETURNING id`
22
- }
23
-
24
- /**
25
- * Records one pending delivery using the intake's transaction manager and returns its durable receipt identity.
26
- * Business processing and the completion marker belong to the reactor or job that consumes the committed row.
27
- */
28
- export const recordWebhookDelivery = async ({
29
- manager,
30
- table,
31
- provider,
32
- deliveryId,
33
- payload,
34
- }: RecordWebhookDelivery): Promise<WebhookReceiptId> => {
35
- const normalizedDeliveryId = deliveryId?.trim()
36
- if (!normalizedDeliveryId) {
37
- throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
38
- }
39
- const rows: Array<{ readonly id: string }> = await manager.query(webhookReceiptInsert(table), [
40
- provider,
41
- normalizedDeliveryId,
42
- JSON.stringify(payload),
43
- ])
44
- return rows[0]?.id
45
- }