@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 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
- app.use("/api/auth", limiters.auth);
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;AAE3C,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAC/D,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC;AAE3D;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,cAAc;IACd,gBAAgB;IAChB,OAAO;IACP,iBAAiB;CACT,CAAC;AAEX,gFAAgF;AAChF,MAAM,CAAC,MAAM,eAAe,GAA2B;IACrD,YAAY,EAAE,eAAe;IAC7B,cAAc,EAAE,iBAAiB;IACjC,MAAM,EAAE,QAAQ;IAChB,UAAU,EAAE,kBAAkB;CAC/B,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,oBAAoB,CAAC,GAAG,GAAsB,OAAO,CAAC,GAAG;IACvE,MAAM,UAAU,GAAG,GAAG,CAAC,gBAAgB,EAAE,IAAI,EAAE,CAAC;IAChD,IAAI,CAAC,UAAU;QAAE,OAAO,UAAU,CAAC;IACnC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;IAChE,CAAC;IAED,MAAM,IAAI,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;IACtC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,+CAA+C,CAAC,CAAC;IACnE,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;QAC/B,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,IAAI,OAAO,CAAC,CAAC;QAClD,IAAI,CAAC;YACH,IAAI,SAAS,CAAC,SAAS,CAAC,CAAC,cAAc,EAAE,EAAE,CAAC;gBAC1C,MAAM,IAAI,KAAK,CAAC,yBAAyB,IAAI,oCAAoC,CAAC,CAAC;YACrF,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACvD,MAAM,IAAI,KAAK,CAAC,6CAA6C,IAAI,QAAQ,CAAC,CAAC;YAC7E,CAAC;YACD,MAAM,KAAK,CAAC;QACd,CAAC;QACD,MAAM,QAAQ,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;QACzC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC;YAC/E,MAAM,IAAI,KAAK,CAAC,yBAAyB,IAAI,kDAAkD,CAAC,CAAC;QACnG,CAAC;QACD,UAAU,CAAC,QAAQ,EAAE,SAAS,CAAC,IAAI,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB;IAChC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CACpB,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,cAAc,CAAC,EAAE,MAAM,CAAC,CAC5C,CAAC;QAC1B,OAAO,GAAG,CAAC,OAAO,IAAI,SAAS,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAqBD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,SAAS,CAAC,IAAoB;IAC5C,MAAM,GAAG,GAAG,OAAO,EAAE,CAAC;IACtB,MAAM,aAAa,GAAG,oBAAoB,EAAE,CAAC;IAE7C,yEAAyE;IACzE,4EAA4E;IAC5E,yEAAyE;IACzE,yEAAyE;IACzE,4EAA4E;IAC5E,yEAAyE;IACzE,yEAAyE;IACzE,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,iBAAiB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;IAEvD,2EAA2E;IAC3E,mEAAmE;IACnE,wEAAwE;IACxE,WAAW;IACX,MAAM,QAAQ,GAAG,kBAAkB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAEjD,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;IACxC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;IAExB,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,QAAQ,CAAC,YAAY,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;QACrD,GAAG,CAAC,SAAS,CAAC,eAAe,EAAE,uDAAuD,CAAC,CAAC;QACxF,GAAG,CAAC,SAAS,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;QACpC,GAAG,CAAC,SAAS,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;QAC9B,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,GAAG,CACL,OAAO,CAAC,MAAM,CAAC,UAAU,EAAE;QACzB,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;KACzD,CAAC,CACH,CAAC;IAEF,mBAAmB;IACnB,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC1B,GAAG,CAAC,SAAS,CAAC,wBAAwB,EAAE,SAAS,CAAC,CAAC;QACnD,GAAG,CAAC,SAAS,CAAC,iBAAiB,EAAE,YAAY,CAAC,CAAC;QAC/C,GAAG,CAAC,SAAS,CAAC,iBAAiB,EAAE,iCAAiC,CAAC,CAAC;QACpE,IAAI,EAAE,CAAC;IACT,CAAC,CAAC,CAAC;IAEH,4EAA4E;IAC5E,+EAA+E;IAC/E,MAAM,cAAc,GAAG,kBAAkB,EAAE,CAAC;IAC5C,IAAI,IAAI,EAAE,MAAM,EAAE,CAAC;QACjB,qEAAqE;QACrE,oEAAoE;QACpE,uEAAuE;QACvE,sBAAsB;QACtB,GAAG,CAAC,GAAG,CAAC,UAAU,EAAE,mBAAmB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;IACxD,CAAC;SAAM,CAAC;QACN,yEAAyE;QACzE,oEAAoE;QACpE,sEAAsE;QACtE,uEAAuE;QACvE,sEAAsE;QACtE,qEAAqE;QACrE,sEAAsE;QACtE,0EAA0E;QAC1E,0EAA0E;QAC1E,GAAG,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAChC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC,CAC7D,CAAC;IACJ,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,GAAG,CAAS,WAAW,CAAC,CAAC;IAEhD,MAAM,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE;QACrD,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;YAC1B,GAAG,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACxB,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,GAAG,CAAC,CAAC,eAAe,EAAE,iBAAiB,EAAE,QAAQ,EAAE,kBAAkB,CAAC,EAAE,QAAQ,CAAC,YAAY,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QAC9G,wEAAwE;QACxE,mEAAmE;QACnE,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACnD,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;YAC1B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;YACtB,OAAO;QACT,CAAC;QACD,GAAG,CAAC,SAAS,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;QAC3C,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,GAAG,IAAI,OAAO,CAAC,CAAC,CAAC;IACzD,CAAC,CAAC,CAAC;IAEH,6EAA6E;IAC7E,4EAA4E;IAC5E,+BAA+B;IAC/B,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,CAAC,CAAC;IAE1B,8EAA8E;IAC9E,iFAAiF;IACjF,kFAAkF;IAClF,yEAAyE;IACzE,wEAAwE;IACxE,wEAAwE;IACxE,0EAA0E;IAC1E,6EAA6E;IAC7E,gEAAgE;IAChE,6EAA6E;IAC7E,6EAA6E;IAC7E,4EAA4E;IAC5E,+DAA+D;IAC/D,4EAA4E;IAC5E,4EAA4E;IAC5E,iCAAiC;IACjC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;IACpC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;IACjC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;IACjC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;IACxD,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,cAAc,CAAC,CAAC;IACzC,GAAG,CAAC,GAAG,CAAC,6BAA6B,EAAE,QAAQ,CAAC,CAAC;IACjD,GAAG,CAAC,GAAG,CAAC,qCAAqC,EAAE,gBAAgB,CAAC,CAAC;IACjE,GAAG,CAAC,GAAG,CAAC,0BAA0B,EAAE,YAAY,CAAC,CAAC;IAClD,GAAG,CAAC,GAAG,CAAC,0BAA0B,EAAE,YAAY,CAAC,CAAC;IAClD,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC,CAAC;IACpE,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,KAAK,EAAE,kBAAkB,CAAC,CAAC;IAC1E,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC;IAEnD,0EAA0E;IAC1E,8DAA8D;IAC9D,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;QACrC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,yDAAyD;IACzD,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,QAAQ,CAAC,YAAY,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;QACxD,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,YAAY,CAAC,CAAC,CAAC;IACpD,CAAC,CAAC,CAAC;IAEH,OAAO,GAAG,CAAC;AACb,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
- * and GitHub item links, plus their indexes; runs idempotent `ADD COLUMN IF
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
- * and GitHub item links, plus their indexes; runs idempotent `ADD COLUMN IF
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