@unbrained/pm-web 2026.9.26 → 2026.10.5
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 +15 -0
- package/README.md +63 -0
- package/dist/app.js +8 -5
- package/dist/app.js.map +1 -1
- package/dist/db.d.ts +2 -2
- package/dist/db.js +30 -2
- package/dist/db.js.map +1 -1
- package/dist/idempotency.d.ts +59 -0
- package/dist/idempotency.js +361 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/routes/groups.js +10 -1
- package/dist/routes/groups.js.map +1 -1
- package/dist/routes/pm.js +125 -108
- package/dist/routes/pm.js.map +1 -1
- package/dist/services/package-catalog.js +1 -1
- package/dist/services/package-catalog.js.map +1 -1
- package/dist/services/pm-runner.d.ts +3 -2
- package/dist/services/pm-runner.js +19 -6
- package/dist/services/pm-runner.js.map +1 -1
- package/manifest.json +2 -2
- package/package.json +23 -21
- package/public/src/api.js +79 -0
- package/public/src/api.js.map +1 -1
- package/public/src/api.ts +77 -0
- package/public/src/app.js +3 -1
- package/public/src/app.js.map +1 -1
- package/public/src/app.ts +3 -1
- package/public/src/offline-recovery.js +65 -0
- package/public/src/offline-recovery.js.map +1 -0
- package/public/src/offline-recovery.ts +68 -0
- package/public/src/sw.ts +431 -33
- package/public/src/views/auth.js +3 -1
- package/public/src/views/auth.js.map +1 -1
- package/public/src/views/auth.ts +3 -1
- package/public/src/views/graph.js +8 -2
- package/public/src/views/graph.js.map +1 -1
- package/public/src/views/graph.ts +9 -2
- package/public/sw.js +379 -26
- package/sql/schema.sql +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.10.5 - 2026-10-05
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Two limiters counted one request twice, halving every published budget on the affected routes ([pm-web-s552](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-s552.toon))
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- The per-IP rate limiter trusted a client-supplied forwarded address by default, so any caller could rotate a header to a fresh bucket ([pm-web-25nv](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-25nv.toon))
|
|
12
|
+
- Offline mutation queue is not bound to the originating account or idempotency key ([pm-web-ccie](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-ccie.toon))
|
|
13
|
+
|
|
14
|
+
### Other
|
|
15
|
+
|
|
16
|
+
- Auto-merge green Dependabot updates and group the pm toolchain into one daily PR ([pm-web-56no](https://github.com/unbraind/pm-web/blob/main/.agents/pm/tasks/pm-web-56no.toon))
|
|
17
|
+
|
|
3
18
|
## 2026.9.26 - 2026-09-26
|
|
4
19
|
|
|
5
20
|
### Other
|
package/README.md
CHANGED
|
@@ -301,3 +301,66 @@ itself lists each affected stream in its output; `pm history --verify <id>` spot
|
|
|
301
301
|
content, so `reconcile` only re-greens the hash chain (no data loss) — see the authoritative
|
|
302
302
|
[pm-cli merge-safety guide](https://github.com/unbraind/pm-cli/blob/main/docs/MERGE_SAFETY.md). The
|
|
303
303
|
older blunt `pm history-repair --all` remains available as a lower-level primitive.
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
### Offline mutation identity and recovery
|
|
307
|
+
|
|
308
|
+
Each service-worker write outside `/api/auth` gets an `Idempotency-Key` and
|
|
309
|
+
captures its originating account before its first network attempt. If that
|
|
310
|
+
response is lost, the queued record keeps the same key, body and account.
|
|
311
|
+
Every replay sends `X-PM-Expected-Account`; the server refuses a different or
|
|
312
|
+
missing account with 409 before executing or claiming a key, and the worker
|
|
313
|
+
keeps the record. The session is resent when a worker first controls the page or changes;
|
|
314
|
+
session writes and explicit adoption extend the worker event lifetime.
|
|
315
|
+
|
|
316
|
+
The SPA displays blocked offline records. For unknown-owner records, the
|
|
317
|
+
signed-in user may click **These changes are mine — adopt for my signed-in
|
|
318
|
+
account** after reviewing the listed paths. The recovery message carries the
|
|
319
|
+
approving account and adoption verifies it against the current server session.
|
|
320
|
+
Adoption and flush share one operation chain. Adoption reads and updates
|
|
321
|
+
ownership in one IndexedDB transaction, preserves any existing key and assigns
|
|
322
|
+
only missing keys, then retries the queue.
|
|
323
|
+
Records owned by another account remain blocked until that account signs in.
|
|
324
|
+
Auth routes always use live responses and are never keyed or queued; legacy
|
|
325
|
+
auth records remain blocked. Deleted or unknown token accounts bypass keying
|
|
326
|
+
and receive the route's normal authentication outcome. Retention cleanup runs
|
|
327
|
+
before key claims so a cleanup failure leaves the request retryable.
|
|
328
|
+
|
|
329
|
+
API rate limits apply before idempotency, including to replay attempts. Explicit
|
|
330
|
+
pre-mutation refusals (425 and 429) release the intent and are never replayed.
|
|
331
|
+
Handlers may call `markIdempotencyPreCommitFailure` only when they can prove
|
|
332
|
+
no mutation committed. Group creation does so on connection acquisition failure
|
|
333
|
+
or a successful rollback before any COMMIT attempt. A later rollback cannot
|
|
334
|
+
prove that an attempted COMMIT failed. Proven pre-commit 5xx failures release
|
|
335
|
+
the claim, allowing the same key to retry. Every other 5xx and premature response
|
|
336
|
+
close settles into terminal `outcome_unknown`, returning 409 with
|
|
337
|
+
`PM_IDEMPOTENCY_OUTCOME_UNKNOWN` on retries without re-executing. Live duplicates
|
|
338
|
+
wait up to `PM_WEB_IDEMPOTENCY_WAIT_MS` (default 30 seconds), then receive 409
|
|
339
|
+
with `PM_IDEMPOTENCY_IN_FLIGHT`. After `PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS`
|
|
340
|
+
(default five minutes), pending rows settle as unknown on duplicate lookup or
|
|
341
|
+
the next retention sweep, including intents stranded by process termination.
|
|
342
|
+
Outcome persistence failures are caught and logged without request contents.
|
|
343
|
+
|
|
344
|
+
The worker durably marks unknown outcomes and three consecutive 5xx responses
|
|
345
|
+
as **needs attention**, then stops automatic attempts for that record. Replays
|
|
346
|
+
preserve FIFO within each owner's workspace; later records in other workspaces
|
|
347
|
+
may continue. Paths under `/projects/<id>/` define a workspace, including legacy
|
|
348
|
+
records. Account-level paths are barriers: they wait for earlier unresolved
|
|
349
|
+
owned work and block all later owned work until resolved. Other accounts have
|
|
350
|
+
separate ordering. Network or local persistence failures stop the flush.
|
|
351
|
+
|
|
352
|
+
The recovery notice offers the originating owner **Retry as a new request**
|
|
353
|
+
and **Discard**. Retrying requires a confirmation that the original might
|
|
354
|
+
already have committed; it assigns a fresh key at the same queue position,
|
|
355
|
+
then resumes ordered replay. Each new request is deduplicated separately:
|
|
356
|
+
the user's approved retry can duplicate an unknown original commit. Discard
|
|
357
|
+
removes only the queued record and does not undo any server commit. Recovery
|
|
358
|
+
verifies the approving account against the server and updates IndexedDB in a
|
|
359
|
+
single transaction. The keyboard-accessible **Dismiss** control hides the
|
|
360
|
+
notice until the blocked record set changes; dismissing preserves all records.
|
|
361
|
+
|
|
362
|
+
Completed responses and terminal unknown outcomes are retained for
|
|
363
|
+
`PM_WEB_IDEMPOTENCY_RETENTION_MS` (default seven days) from settlement.
|
|
364
|
+
Deduplication is bounded by that window; a retry after expiry may execute again.
|
|
365
|
+
Cleanup uses a partial `updated_at` index and runs at most once per minute per
|
|
366
|
+
guard instance. Pending intents are settled before retention can remove them.
|
package/dist/app.js
CHANGED
|
@@ -15,6 +15,7 @@ import { githubRouter } from "./routes/github.js";
|
|
|
15
15
|
import { adminRouter } from "./routes/admin.js";
|
|
16
16
|
import { createTierLimiters, resolveTrustProxy } from "./rate-limit.js";
|
|
17
17
|
import { csrfProtection } from "./csrf.js";
|
|
18
|
+
import { idempotencyGuard } from "./idempotency.js";
|
|
18
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
|
|
20
21
|
/**
|
|
@@ -215,18 +216,20 @@ export function createApp(deps) {
|
|
|
215
216
|
// /api/projects/... request matched the /api/projects prefix before its own
|
|
216
217
|
// nested mount. Each shared prefix therefore carries its limiters once, and
|
|
217
218
|
// the routers mount behind them.
|
|
218
|
-
|
|
219
|
+
// Charge every attempt (including replays) before touching the idempotency store.
|
|
220
|
+
const keyedWrites = idempotencyGuard();
|
|
221
|
+
app.use("/api/auth", limiters.auth, keyedWrites);
|
|
219
222
|
app.use("/api/auth", oidcRouter);
|
|
220
223
|
app.use("/api/auth", authRouter);
|
|
221
|
-
app.use("/api/projects", limiters.read, limiters.write);
|
|
224
|
+
app.use("/api/projects", limiters.read, limiters.write, keyedWrites);
|
|
222
225
|
app.use("/api/projects", projectsRouter);
|
|
223
226
|
app.use("/api/projects/:projectId/pm", pmRouter);
|
|
224
227
|
app.use("/api/projects/:projectId/extensions", extensionsRouter);
|
|
225
228
|
app.use("/api/projects/:id/shares", sharesRouter);
|
|
226
229
|
app.use("/api/projects/:id/github", githubRouter);
|
|
227
|
-
app.use("/api/groups", limiters.read, limiters.write, groupsRouter);
|
|
228
|
-
app.use("/api/shared", limiters.read, limiters.write, sharedWithMeRouter);
|
|
229
|
-
app.use("/api/admin", limiters.admin, adminRouter);
|
|
230
|
+
app.use("/api/groups", limiters.read, limiters.write, keyedWrites, groupsRouter);
|
|
231
|
+
app.use("/api/shared", limiters.read, limiters.write, keyedWrites, sharedWithMeRouter);
|
|
232
|
+
app.use("/api/admin", limiters.admin, keyedWrites, adminRouter);
|
|
230
233
|
// Unknown API routes get a JSON 404 instead of falling through to the SPA
|
|
231
234
|
// shell, which would hand HTML to API clients expecting JSON.
|
|
232
235
|
app.all("/api/{*splat}", (_req, res) => {
|
package/dist/app.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,OAAyB,MAAM,SAAS,CAAC;AAChD,OAAO,YAAY,MAAM,eAAe,CAAC;AACzC,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACjG,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,mBAAmB,EAAwB,MAAM,aAAa,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
|
package/dist/db.d.ts
CHANGED
|
@@ -22,8 +22,8 @@ export declare const pool: import("pg").Pool;
|
|
|
22
22
|
*
|
|
23
23
|
* Issues `CREATE TABLE IF NOT EXISTS` for users, projects, groups, group
|
|
24
24
|
* members, project shares, external (OIDC) identities, the admin audit log,
|
|
25
|
-
*
|
|
26
|
-
* NOT EXISTS` migrations for later-added columns; and, when
|
|
25
|
+
* GitHub item links and idempotency keys, plus their indexes; runs idempotent
|
|
26
|
+
* `ADD COLUMN IF NOT EXISTS` migrations for later-added columns; and, when
|
|
27
27
|
* `PM_WEB_BOOTSTRAP_ADMIN_EMAIL` is set, promotes that (lower-cased) user to
|
|
28
28
|
* admin. Safe to call on every boot.
|
|
29
29
|
*/
|
package/dist/db.js
CHANGED
|
@@ -67,8 +67,8 @@ const bootstrapAdminEmail = (process.env.PM_WEB_BOOTSTRAP_ADMIN_EMAIL || "")
|
|
|
67
67
|
*
|
|
68
68
|
* Issues `CREATE TABLE IF NOT EXISTS` for users, projects, groups, group
|
|
69
69
|
* members, project shares, external (OIDC) identities, the admin audit log,
|
|
70
|
-
*
|
|
71
|
-
* NOT EXISTS` migrations for later-added columns; and, when
|
|
70
|
+
* GitHub item links and idempotency keys, plus their indexes; runs idempotent
|
|
71
|
+
* `ADD COLUMN IF NOT EXISTS` migrations for later-added columns; and, when
|
|
72
72
|
* `PM_WEB_BOOTSTRAP_ADMIN_EMAIL` is set, promotes that (lower-cased) user to
|
|
73
73
|
* admin. Safe to call on every boot.
|
|
74
74
|
*/
|
|
@@ -173,6 +173,34 @@ export async function initSchema() {
|
|
|
173
173
|
);
|
|
174
174
|
`);
|
|
175
175
|
await pool.query(`CREATE INDEX IF NOT EXISTS pm_github_item_links_project ON pm_github_item_links(project_id)`);
|
|
176
|
+
// Idempotency keys: one row per (account, key) recording the outcome of a
|
|
177
|
+
// mutating request that carried that key, so a retry after an ambiguous
|
|
178
|
+
// (lost) response replays the stored outcome instead of re-executing. A row
|
|
179
|
+
// with a NULL status_code marks an execution still in flight (or crashed
|
|
180
|
+
// before responding); see src/idempotency.ts for the unknown-outcome policy.
|
|
181
|
+
await pool.query(`
|
|
182
|
+
CREATE TABLE IF NOT EXISTS pm_idempotency_keys (
|
|
183
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
184
|
+
user_id UUID NOT NULL REFERENCES pm_users(id) ON DELETE CASCADE,
|
|
185
|
+
idempotency_key TEXT NOT NULL,
|
|
186
|
+
method TEXT NOT NULL,
|
|
187
|
+
path TEXT NOT NULL,
|
|
188
|
+
request_fingerprint TEXT NOT NULL,
|
|
189
|
+
outcome_state TEXT NOT NULL DEFAULT 'pending' CHECK (outcome_state IN ('pending', 'completed', 'outcome_unknown')),
|
|
190
|
+
status_code INTEGER,
|
|
191
|
+
response_body TEXT,
|
|
192
|
+
response_content_type TEXT,
|
|
193
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
194
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
195
|
+
UNIQUE (user_id, idempotency_key)
|
|
196
|
+
);
|
|
197
|
+
`);
|
|
198
|
+
await pool.query("ALTER TABLE pm_idempotency_keys ADD COLUMN IF NOT EXISTS response_content_type TEXT");
|
|
199
|
+
await pool.query("ALTER TABLE pm_idempotency_keys ADD COLUMN IF NOT EXISTS outcome_state TEXT NOT NULL DEFAULT 'pending' CHECK (outcome_state IN ('pending', 'completed', 'outcome_unknown'))");
|
|
200
|
+
await pool.query("UPDATE pm_idempotency_keys SET outcome_state = 'completed' WHERE outcome_state = 'pending' AND status_code IS NOT NULL");
|
|
201
|
+
await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL");
|
|
202
|
+
await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_settled_at ON pm_idempotency_keys (updated_at) WHERE status_code IS NOT NULL");
|
|
203
|
+
await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_pending_at ON pm_idempotency_keys (created_at) WHERE status_code IS NULL");
|
|
176
204
|
if (bootstrapAdminEmail) {
|
|
177
205
|
await pool.query(`UPDATE pm_users SET is_admin = TRUE, updated_at = NOW() WHERE lower(email) = lower($1)`, [bootstrapAdminEmail]);
|
|
178
206
|
}
|
package/dist/db.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAEpB,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;AAEpB;;;;;;;GAOG;AACH,SAAS,iBAAiB;IACxB,IAAI,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC;QAC7B,OAAO,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC;IACxD,CAAC;IAED,oEAAoE;IACpE,IAAI,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC;QACzD,OAAO;YACL,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa;YAC/B,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,MAAM,EAAE,EAAE,CAAC;YACvD,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa;YAC/B,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,iBAAiB;YACvC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW;SAClC,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,CAAC;AACZ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB;IAChC,MAAM,UAAU,GACd,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;QACjC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAChE,IAAI,UAAU;QAAE,OAAO;IACvB,MAAM,IAAI,KAAK,CACb,mEAAmE;QACjE,mCAAmC;QACnC,qEAAqE;QACrE,yEAAyE,CAC5E,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC;IAC3B,GAAG,iBAAiB,EAAE;IACtB,2EAA2E;IAC3E,wEAAwE;IACxE,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;IAC5E,iBAAiB,EAAE,MAAM;IACzB,uBAAuB,EAAE,KAAK;CAC/B,CAAC,CAAC;AAEH,MAAM,mBAAmB,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,4BAA4B,IAAI,EAAE,CAAC;KACzE,IAAI,EAAE;KACN,WAAW,EAAE,CAAC;AAEjB;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDhB,CAAC,CAAC;IAEH,0CAA0C;IAC1C,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;GAMhB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;;;;GAahB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CACd;;;;;;MAME,CACH,CAAC;IAEF,MAAM,IAAI,CAAC,KAAK,CACd,yFAAyF,CAC1F,CAAC;IAEF,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;GAUhB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CACd,6FAA6F,CAC9F,CAAC;IAEF,IAAI,mBAAmB,EAAE,CAAC;QACxB,MAAM,IAAI,CAAC,KAAK,CACd,wFAAwF,EACxF,CAAC,mBAAmB,CAAC,CACtB,CAAC;IACJ,CAAC;AACH,CAAC"}
|
|
1
|
+
{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { RequestHandler, Response } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Minimum length an `Idempotency-Key` header value may have.
|
|
4
|
+
*
|
|
5
|
+
* Keys are generated by the offline mutation queue as 128-bit UUID-shaped
|
|
6
|
+
* strings, so anything shorter than this is a client bug rather than a key,
|
|
7
|
+
* and accepting it would let a truncated or hand-typed value collide with a
|
|
8
|
+
* future real key.
|
|
9
|
+
*/
|
|
10
|
+
export declare const IDEMPOTENCY_KEY_MIN_LENGTH = 8;
|
|
11
|
+
/**
|
|
12
|
+
* Maximum length an `Idempotency-Key` header value may have.
|
|
13
|
+
*
|
|
14
|
+
* Bounds the stored key so a hostile client cannot stuff multi-kilobyte
|
|
15
|
+
* strings into the unique index; real keys are 36-character UUID strings.
|
|
16
|
+
*/
|
|
17
|
+
export declare const IDEMPOTENCY_KEY_MAX_LENGTH = 200;
|
|
18
|
+
/** Mark a failure only after proving no mutation committed (for example, confirmed rollback before COMMIT was attempted). */
|
|
19
|
+
export declare function markIdempotencyPreCommitFailure(res: Response): void;
|
|
20
|
+
/**
|
|
21
|
+
* Compute the fingerprint that binds one idempotency key to one request.
|
|
22
|
+
*
|
|
23
|
+
* The fingerprint covers the method, the full path with query string, and the
|
|
24
|
+
* parsed JSON body, so a key can only ever be replayed for the identical
|
|
25
|
+
* request it was created for. Re-serializing the parsed body is stable here
|
|
26
|
+
* because a retry sends the same bytes (the offline queue persists the exact
|
|
27
|
+
* body string), which parses to the same property order; a different body —
|
|
28
|
+
* different properties or different values — produces a different fingerprint
|
|
29
|
+
* and is rejected rather than silently deduplicated.
|
|
30
|
+
*
|
|
31
|
+
* @param method - The HTTP method of the request.
|
|
32
|
+
* @param path - The request path including any query string.
|
|
33
|
+
* @param body - The parsed request body, or `null` when the request has none.
|
|
34
|
+
* @returns A hex SHA-256 digest over the request identity.
|
|
35
|
+
*/
|
|
36
|
+
export declare function requestFingerprint(method: string, path: string, body: unknown): string;
|
|
37
|
+
/**
|
|
38
|
+
* Build the middleware that makes keyed mutating requests at-most-once.
|
|
39
|
+
*
|
|
40
|
+
* Scope: keyed mutating `/api` requests outside `/api/auth`. Auth responses
|
|
41
|
+
* always execute live so session cookies are never lost to body-only replay.
|
|
42
|
+
* An expected-account header is checked against an existing authenticated user
|
|
43
|
+
* before execution or claiming, including recovery probes to `/api/auth/me`.
|
|
44
|
+
*
|
|
45
|
+
* Exactly-once per account: the key is stored together with the authenticated
|
|
46
|
+
* user, so two accounts using the same key never interfere. The first
|
|
47
|
+
* execution atomically claims the (account, key) pair, stores its outcome
|
|
48
|
+
* when the response finishes, and any duplicate — a retry after an ambiguous
|
|
49
|
+
* response the client never saw, or a concurrent duplicate of an in-flight
|
|
50
|
+
* request — is answered from that stored outcome without re-executing. A key
|
|
51
|
+
* reused for a *different* request is rejected with 422 rather than silently
|
|
52
|
+
* deduplicated; an unfinished record older than the pending timeout is
|
|
53
|
+
* answered with 409 outcome unknown without re-execution; an in-flight
|
|
54
|
+
* duplicate waits for it to settle and answers 409 only at the
|
|
55
|
+
* deadline.
|
|
56
|
+
*
|
|
57
|
+
* @returns An Express middleware enforcing the contract on its mount.
|
|
58
|
+
*/
|
|
59
|
+
export declare function idempotencyGuard(): RequestHandler;
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { pool } from "./db.js";
|
|
3
|
+
import { extractToken, verifyToken } from "./auth.js";
|
|
4
|
+
import { isUnsafeMethod } from "./rate-limit.js";
|
|
5
|
+
/**
|
|
6
|
+
* Minimum length an `Idempotency-Key` header value may have.
|
|
7
|
+
*
|
|
8
|
+
* Keys are generated by the offline mutation queue as 128-bit UUID-shaped
|
|
9
|
+
* strings, so anything shorter than this is a client bug rather than a key,
|
|
10
|
+
* and accepting it would let a truncated or hand-typed value collide with a
|
|
11
|
+
* future real key.
|
|
12
|
+
*/
|
|
13
|
+
export const IDEMPOTENCY_KEY_MIN_LENGTH = 8;
|
|
14
|
+
/**
|
|
15
|
+
* Maximum length an `Idempotency-Key` header value may have.
|
|
16
|
+
*
|
|
17
|
+
* Bounds the stored key so a hostile client cannot stuff multi-kilobyte
|
|
18
|
+
* strings into the unique index; real keys are 36-character UUID strings.
|
|
19
|
+
*/
|
|
20
|
+
export const IDEMPOTENCY_KEY_MAX_LENGTH = 200;
|
|
21
|
+
/**
|
|
22
|
+
* How long a duplicate waits for the in-flight original to settle, in
|
|
23
|
+
* milliseconds, before answering 409. Override: `PM_WEB_IDEMPOTENCY_WAIT_MS`.
|
|
24
|
+
*
|
|
25
|
+
* The default covers the longest mutating route (pm CLI commands time out at
|
|
26
|
+
* 30 s), so a concurrent duplicate normally observes the original's stored
|
|
27
|
+
* outcome instead of being told to retry.
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_WAIT_MS = 30_000;
|
|
30
|
+
/** How often a waiting duplicate re-checks the store, in milliseconds. */
|
|
31
|
+
const DEFAULT_POLL_MS = 25;
|
|
32
|
+
/** Age after which an unfinished execution is reported as outcome unknown. */
|
|
33
|
+
const DEFAULT_PENDING_TIMEOUT_MS = 300_000;
|
|
34
|
+
/**
|
|
35
|
+
* How long a settled idempotency record is kept before it is swept, in
|
|
36
|
+
* milliseconds. Override: `PM_WEB_IDEMPOTENCY_RETENTION_MS`.
|
|
37
|
+
*
|
|
38
|
+
* Completed outcomes are deduplicated within this window. A retry after expiry
|
|
39
|
+
* can execute again. Unknown outcomes have the same bounded retention, measured
|
|
40
|
+
* from settlement; stale pending intents must settle before they can expire.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
43
|
+
/** Handler assertions are process-local and cannot be forged through request headers. */
|
|
44
|
+
const preCommitFailures = new WeakSet();
|
|
45
|
+
/** Mark a failure only after proving no mutation committed (for example, confirmed rollback before COMMIT was attempted). */
|
|
46
|
+
export function markIdempotencyPreCommitFailure(res) {
|
|
47
|
+
preCommitFailures.add(res);
|
|
48
|
+
}
|
|
49
|
+
/** Machine-readable refusal reused by failure settlement and stale-intent recovery. */
|
|
50
|
+
const UNKNOWN_OUTCOME = JSON.stringify({
|
|
51
|
+
code: "PM_IDEMPOTENCY_OUTCOME_UNKNOWN",
|
|
52
|
+
error: "Idempotency outcome unknown; reconcile the original mutation before submitting new work",
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* Compute the fingerprint that binds one idempotency key to one request.
|
|
56
|
+
*
|
|
57
|
+
* The fingerprint covers the method, the full path with query string, and the
|
|
58
|
+
* parsed JSON body, so a key can only ever be replayed for the identical
|
|
59
|
+
* request it was created for. Re-serializing the parsed body is stable here
|
|
60
|
+
* because a retry sends the same bytes (the offline queue persists the exact
|
|
61
|
+
* body string), which parses to the same property order; a different body —
|
|
62
|
+
* different properties or different values — produces a different fingerprint
|
|
63
|
+
* and is rejected rather than silently deduplicated.
|
|
64
|
+
*
|
|
65
|
+
* @param method - The HTTP method of the request.
|
|
66
|
+
* @param path - The request path including any query string.
|
|
67
|
+
* @param body - The parsed request body, or `null` when the request has none.
|
|
68
|
+
* @returns A hex SHA-256 digest over the request identity.
|
|
69
|
+
*/
|
|
70
|
+
export function requestFingerprint(method, path, body) {
|
|
71
|
+
return crypto
|
|
72
|
+
.createHash("sha256")
|
|
73
|
+
.update(`${method}\n${path}\n${JSON.stringify(body ?? null)}`)
|
|
74
|
+
.digest("hex");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a positive millisecond duration from the environment.
|
|
78
|
+
*
|
|
79
|
+
* Accepts only finite positive numbers; anything else (unset, empty, zero,
|
|
80
|
+
* negative, non-numeric) falls back to the shipped default so a typo in an
|
|
81
|
+
* environment variable degrades to the documented behaviour instead of
|
|
82
|
+
* disabling a timeout entirely.
|
|
83
|
+
*
|
|
84
|
+
* @param name - The environment variable to read.
|
|
85
|
+
* @param fallback - The default used when the variable is unusable.
|
|
86
|
+
* @returns The resolved duration in milliseconds.
|
|
87
|
+
*/
|
|
88
|
+
function envDurationMs(name, fallback) {
|
|
89
|
+
const parsed = Number(process.env[name]);
|
|
90
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Insert the intent to execute one keyed request, or report a conflict.
|
|
94
|
+
*
|
|
95
|
+
* `ON CONFLICT DO NOTHING` makes the insert atomic against a concurrent
|
|
96
|
+
* duplicate: exactly one request can own a (user, key) pair at a time, and
|
|
97
|
+
* the loser learns it lost by receiving no row rather than by an error it
|
|
98
|
+
* would have to disambiguate from a genuine failure.
|
|
99
|
+
*
|
|
100
|
+
* @returns The id of the inserted row when this call owns the execution, or
|
|
101
|
+
* `null` when the key is already held.
|
|
102
|
+
*/
|
|
103
|
+
async function insertPendingKey(userId, key, method, path, fingerprint) {
|
|
104
|
+
const result = await pool.query(`INSERT INTO pm_idempotency_keys (user_id, idempotency_key, method, path, request_fingerprint)
|
|
105
|
+
SELECT id, $2, $3, $4, $5 FROM pm_users WHERE id = $1 FOR KEY SHARE
|
|
106
|
+
ON CONFLICT (user_id, idempotency_key) DO NOTHING
|
|
107
|
+
RETURNING id`, [userId, key, method, path, fingerprint]);
|
|
108
|
+
const row = result.rows[0];
|
|
109
|
+
return row?.id ?? null;
|
|
110
|
+
}
|
|
111
|
+
/** Check that a verified token still names an existing account before keying. */
|
|
112
|
+
async function accountExists(userId) {
|
|
113
|
+
if (typeof userId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId))
|
|
114
|
+
return false;
|
|
115
|
+
const result = await pool.query("SELECT id FROM pm_users WHERE id = $1", [userId]);
|
|
116
|
+
return result.rows.length > 0;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Read the current idempotency record for one (account, key) pair.
|
|
120
|
+
*
|
|
121
|
+
* @returns The record, or `null` when no row exists (which can only happen
|
|
122
|
+
* transiently: a settled record is swept only by retention, and a pending
|
|
123
|
+
* one only by an explicit transient refusal).
|
|
124
|
+
*/
|
|
125
|
+
async function selectKey(userId, key) {
|
|
126
|
+
const result = await pool.query(`SELECT id, request_fingerprint, status_code, response_body, response_content_type, created_at, outcome_state
|
|
127
|
+
FROM pm_idempotency_keys
|
|
128
|
+
WHERE user_id = $1 AND idempotency_key = $2`, [userId, key]);
|
|
129
|
+
return result.rows[0] ?? null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Sweep idempotency records past the retention window.
|
|
133
|
+
*
|
|
134
|
+
* Called at most once per minute per guard instance. Stale pending records first
|
|
135
|
+
* become terminal unknown outcomes; retention starts at that settlement time.
|
|
136
|
+
*/
|
|
137
|
+
async function deleteExpiredKeys() {
|
|
138
|
+
await pool.query(`UPDATE pm_idempotency_keys SET outcome_state = 'outcome_unknown', status_code = 409,
|
|
139
|
+
response_body = $2, response_content_type = 'application/json', updated_at = NOW()
|
|
140
|
+
WHERE status_code IS NULL AND outcome_state = 'pending'
|
|
141
|
+
AND created_at < NOW() - ($1::double precision * interval '1 ms')`, [envDurationMs("PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS", DEFAULT_PENDING_TIMEOUT_MS), UNKNOWN_OUTCOME]);
|
|
142
|
+
await pool.query(`DELETE FROM pm_idempotency_keys
|
|
143
|
+
WHERE status_code IS NOT NULL AND updated_at < NOW() - ($1::double precision * interval '1 ms')`, [envDurationMs("PM_WEB_IDEMPOTENCY_RETENTION_MS", DEFAULT_RETENTION_MS)]);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Store a definitive outcome. Release only explicit pre-mutation refusals
|
|
147
|
+
* (425, 429, or handler-proven pre-commit failures). All other server failures
|
|
148
|
+
* settle as terminal unknown outcomes because they may follow a commit.
|
|
149
|
+
*/
|
|
150
|
+
async function persistOutcome(rowId, statusCode, body, contentType, preCommit = false) {
|
|
151
|
+
if (statusCode === 425 || statusCode === 429 || (statusCode >= 500 && preCommit)) {
|
|
152
|
+
await pool.query(`DELETE FROM pm_idempotency_keys WHERE id = $1 AND outcome_state = 'pending'`, [rowId]);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const unknown = statusCode >= 500;
|
|
156
|
+
await pool.query(`UPDATE pm_idempotency_keys
|
|
157
|
+
SET status_code = $2, response_body = $3, response_content_type = $4, outcome_state = $5, updated_at = NOW()
|
|
158
|
+
WHERE id = $1 AND outcome_state = 'pending'`, [rowId, unknown ? 409 : statusCode, unknown ? UNKNOWN_OUTCOME : body,
|
|
159
|
+
unknown ? "application/json" : contentType, unknown ? "outcome_unknown" : "completed"]);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Answer a duplicate from the stored outcome of the original request.
|
|
163
|
+
*
|
|
164
|
+
* The `Idempotency-Replayed` header lets the client (and the tests) tell a
|
|
165
|
+
* replay from a fresh execution without diffing bodies. Responses that had no
|
|
166
|
+
* body keep having none; responses that had one are served with the content
|
|
167
|
+
* type they originally carried.
|
|
168
|
+
*/
|
|
169
|
+
function replayStored(record, res) {
|
|
170
|
+
res.setHeader("Idempotency-Replayed", "true");
|
|
171
|
+
if (record.response_body === null) {
|
|
172
|
+
res.status(record.status_code ?? 500).end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
res.setHeader("Content-Type", record.response_content_type ?? "application/json");
|
|
176
|
+
res.status(record.status_code ?? 500).send(record.response_body);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Answer a settled record, or reject a key recycled onto a different request.
|
|
180
|
+
*
|
|
181
|
+
* @param record - The settled record held under the client's key.
|
|
182
|
+
* @param fingerprint - The fingerprint of the request being answered.
|
|
183
|
+
* @param res - The response to answer on.
|
|
184
|
+
*/
|
|
185
|
+
function finishSettled(record, fingerprint, res) {
|
|
186
|
+
if (record.request_fingerprint !== fingerprint) {
|
|
187
|
+
res.status(422).json({
|
|
188
|
+
error: "Idempotency-Key was already used for a different request",
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
replayStored(record, res);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Capture the executing request's response and persist it when it finishes.
|
|
196
|
+
*
|
|
197
|
+
* `res.json`/`res.send` are wrapped once to record the body the handler
|
|
198
|
+
* produced, and the `finish` listener persists whatever status the response
|
|
199
|
+
* left with — so handlers that never call `json`/`send` (204s, `end()`) are
|
|
200
|
+
* still memoized, just without a body.
|
|
201
|
+
*
|
|
202
|
+
* @param rowId - The id of this execution's idempotency record.
|
|
203
|
+
* @param res - The executing response.
|
|
204
|
+
*/
|
|
205
|
+
function captureOutcome(rowId, res) {
|
|
206
|
+
let capturedBody = null;
|
|
207
|
+
const originalJson = res.json.bind(res);
|
|
208
|
+
const originalSend = res.send.bind(res);
|
|
209
|
+
/** Capture the first response serialization without recording request content. */
|
|
210
|
+
const capture = (body) => {
|
|
211
|
+
if (capturedBody !== null)
|
|
212
|
+
return;
|
|
213
|
+
capturedBody = typeof body === "string" ? body : JSON.stringify(body ?? null);
|
|
214
|
+
};
|
|
215
|
+
res.json = (body) => {
|
|
216
|
+
capture(body);
|
|
217
|
+
return originalJson(body);
|
|
218
|
+
};
|
|
219
|
+
res.send = (body) => {
|
|
220
|
+
capture(body);
|
|
221
|
+
return originalSend(body);
|
|
222
|
+
};
|
|
223
|
+
let finished = false;
|
|
224
|
+
/** Finish and premature close share one persistence path, avoiding conflicting writes. */
|
|
225
|
+
const settle = (statusCode) => {
|
|
226
|
+
const contentType = res.getHeader("Content-Type");
|
|
227
|
+
void persistOutcome(rowId, statusCode, capturedBody, typeof contentType === "string" ? contentType : null, preCommitFailures.has(res)).catch((error) => {
|
|
228
|
+
// Do not log response bodies, tokens or database error details.
|
|
229
|
+
console.error("Failed to persist idempotency outcome; key remains pending", error instanceof Error ? error.name : "unknown error");
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
res.on("finish", () => {
|
|
233
|
+
finished = true;
|
|
234
|
+
settle(res.statusCode);
|
|
235
|
+
});
|
|
236
|
+
res.on("close", () => {
|
|
237
|
+
if (!finished)
|
|
238
|
+
settle(500);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Build the middleware that makes keyed mutating requests at-most-once.
|
|
243
|
+
*
|
|
244
|
+
* Scope: keyed mutating `/api` requests outside `/api/auth`. Auth responses
|
|
245
|
+
* always execute live so session cookies are never lost to body-only replay.
|
|
246
|
+
* An expected-account header is checked against an existing authenticated user
|
|
247
|
+
* before execution or claiming, including recovery probes to `/api/auth/me`.
|
|
248
|
+
*
|
|
249
|
+
* Exactly-once per account: the key is stored together with the authenticated
|
|
250
|
+
* user, so two accounts using the same key never interfere. The first
|
|
251
|
+
* execution atomically claims the (account, key) pair, stores its outcome
|
|
252
|
+
* when the response finishes, and any duplicate — a retry after an ambiguous
|
|
253
|
+
* response the client never saw, or a concurrent duplicate of an in-flight
|
|
254
|
+
* request — is answered from that stored outcome without re-executing. A key
|
|
255
|
+
* reused for a *different* request is rejected with 422 rather than silently
|
|
256
|
+
* deduplicated; an unfinished record older than the pending timeout is
|
|
257
|
+
* answered with 409 outcome unknown without re-execution; an in-flight
|
|
258
|
+
* duplicate waits for it to settle and answers 409 only at the
|
|
259
|
+
* deadline.
|
|
260
|
+
*
|
|
261
|
+
* @returns An Express middleware enforcing the contract on its mount.
|
|
262
|
+
*/
|
|
263
|
+
export function idempotencyGuard() {
|
|
264
|
+
let nextSweepAt = 0;
|
|
265
|
+
return async (req, res, next) => {
|
|
266
|
+
const authRoute = /^\/api\/auth(?:\/|$)/i.test(req.originalUrl.split("?")[0]);
|
|
267
|
+
const expectedAccount = req.get("x-pm-expected-account");
|
|
268
|
+
const checkExpected = expectedAccount !== undefined
|
|
269
|
+
&& (!authRoute || /^\/api\/auth\/me\/?(?:\?|$)/i.test(req.originalUrl));
|
|
270
|
+
const header = req.get("idempotency-key");
|
|
271
|
+
const keyed = isUnsafeMethod(req.method) && !authRoute && header !== undefined;
|
|
272
|
+
if (!keyed && !checkExpected) {
|
|
273
|
+
next();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Only record requests we can attribute to an account; an unauthenticated
|
|
277
|
+
// or invalid-session request is passed through for the route to refuse,
|
|
278
|
+
// so an anonymous probe can never occupy a key.
|
|
279
|
+
let userId = null;
|
|
280
|
+
const token = extractToken(req);
|
|
281
|
+
if (token) {
|
|
282
|
+
try {
|
|
283
|
+
userId = verifyToken(token).userId;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
userId = null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (userId !== null && !await accountExists(userId))
|
|
290
|
+
userId = null;
|
|
291
|
+
if (checkExpected && (userId === null || expectedAccount !== userId)) {
|
|
292
|
+
res.status(409).json({ code: "PM_EXPECTED_ACCOUNT_MISMATCH", error: "The signed-in account differs from the account that approved this work" });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (!keyed || userId === null) {
|
|
296
|
+
next();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const key = header.trim();
|
|
300
|
+
if (key.length < IDEMPOTENCY_KEY_MIN_LENGTH || key.length > IDEMPOTENCY_KEY_MAX_LENGTH) {
|
|
301
|
+
res.status(400).json({
|
|
302
|
+
error: `Idempotency-Key must be between ${IDEMPOTENCY_KEY_MIN_LENGTH} and ${IDEMPOTENCY_KEY_MAX_LENGTH} characters`,
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (Date.now() >= nextSweepAt) {
|
|
307
|
+
// Cleanup can fail only before an intent exists, leaving the key retryable.
|
|
308
|
+
nextSweepAt = Date.now() + 60_000;
|
|
309
|
+
await deleteExpiredKeys();
|
|
310
|
+
}
|
|
311
|
+
const fingerprint = requestFingerprint(req.method, req.originalUrl, req.body);
|
|
312
|
+
const deadline = Date.now() + envDurationMs("PM_WEB_IDEMPOTENCY_WAIT_MS", DEFAULT_WAIT_MS);
|
|
313
|
+
let rowId = await insertPendingKey(userId, key, req.method, req.originalUrl, fingerprint);
|
|
314
|
+
while (rowId === null) {
|
|
315
|
+
const record = await selectKey(userId, key);
|
|
316
|
+
if (record && record.status_code !== null) {
|
|
317
|
+
// The original settled before this duplicate arrived: replay it.
|
|
318
|
+
finishSettled(record, fingerprint, res);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (record && record.request_fingerprint !== fingerprint) {
|
|
322
|
+
res.status(422).json({
|
|
323
|
+
error: "Idempotency-Key was already used for a different request",
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (record === null) {
|
|
328
|
+
// Deletion can race token validation; the locked INSERT SELECT refuses
|
|
329
|
+
// a missing user without a foreign-key failure or a permanent wait.
|
|
330
|
+
if (!await accountExists(userId)) {
|
|
331
|
+
next();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
rowId = await insertPendingKey(userId, key, req.method, req.originalUrl, fingerprint);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (isStalePending(record)) {
|
|
338
|
+
await persistOutcome(record.id, 500, null, null);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (Date.now() >= deadline) {
|
|
342
|
+
res.status(409).json({ code: "PM_IDEMPOTENCY_IN_FLIGHT", error: "The request with this Idempotency-Key is still executing; retry later" });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
await new Promise((resolve) => { setTimeout(resolve, envDurationMs("PM_WEB_IDEMPOTENCY_POLL_MS", DEFAULT_POLL_MS)); });
|
|
346
|
+
}
|
|
347
|
+
captureOutcome(rowId, res);
|
|
348
|
+
next();
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Report whether a pending record is old enough to be a crashed execution.
|
|
353
|
+
*
|
|
354
|
+
* @param record - The still-pending record to age-check.
|
|
355
|
+
* @returns `true` when the record predates the pending timeout.
|
|
356
|
+
*/
|
|
357
|
+
function isStalePending(record) {
|
|
358
|
+
const timeoutMs = envDurationMs("PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS", DEFAULT_PENDING_TIMEOUT_MS);
|
|
359
|
+
return Date.now() - record.created_at.getTime() > timeoutMs;
|
|
360
|
+
}
|
|
361
|
+
//# sourceMappingURL=idempotency.js.map
|