@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.
- package/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/db/migrate.mjs +1 -1
- package/db/postgres.mjs +38 -3
- package/package.json +1 -1
- package/release/index.mjs +6 -5
- package/release/lint-baseline.mjs +123 -0
- package/release/urgent.mjs +19 -4
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
- package/runtime/scripts/api/sops/lib.mjs +25 -3
- package/runtime/scripts/api/sops/seal.mjs +4 -3
- package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
- package/runtime/scripts/hfs/secret-init.mjs +15 -0
- package/runtime/scripts/hfs/secret-sops.mjs +65 -33
- package/runtime/scripts/lib/sops-envelope.mjs +36 -11
- package/scaffold/add-compose.mjs +3 -0
- package/scaffold/add-inbox-lite.mjs +45 -23
- package/scaffold/lite-exports.mjs +13 -0
- package/src/main.mjs +5 -5
- package/stack/age-install.mjs +10 -2
- package/stack/cli.mjs +5 -3
- package/stack/context.mjs +2 -2
- package/stack/deploy.mjs +6 -2
- package/stack/remote.mjs +29 -8
- package/stack/sealed.mjs +15 -11
- package/stack/server-key.mjs +31 -7
- package/stack/urgent-floor.mjs +101 -7
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +1 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +1 -1
- package/templates/be/inbox/entities.index.ts.tpl +4 -0
- package/templates/be/inbox/entity.ts.tpl +29 -0
- package/templates/be/inbox/index.ts.tpl +1 -0
- package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
- package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
- package/templates/be/inbox/service.spec.ts.tpl +39 -15
- package/templates/be/inbox/service.ts.tpl +13 -13
- package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
- package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
- package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -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/rate-limit.guard.ts +3 -3
- 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 +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
- package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
- package/templates/be/inbox/database-client.ts.tpl +0 -45
package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
132
|
-
const entityManager = mockEntityManager(
|
|
133
|
-
|
|
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:
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
95
|
-
* an ended window restarts at one hit ending at `resetAt`, and a missing one opens at one hit (a
|
|
96
|
-
* wins and this hit is its first).
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
}
|