@ultimat3/action 22.5.0 → 22.5.1

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/README.md CHANGED
@@ -358,6 +358,16 @@ post-commit: a handler that took the money and then failed its own `output:` sch
358
358
  The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own
359
359
  code. Releasing it there is what made idempotency the cause of a double charge.
360
360
 
361
+ **The stored payload fingerprint is keyed.** `requestHash` is `keyedFingerprint(input, …)` from
362
+ `@ultimat3/core`: `h1:<key id>:<HMAC-SHA-256/128>` under a key derived from the app's signing
363
+ secret (`ULTIMATE_CURSOR_SECRET` / `configureCursorSigning`), never a bare hash — the row lives a
364
+ day, and an unkeyed hash of an input holding a short account or ID number is brute-forced offline
365
+ from a table read. A row fingerprinted by a pre-22.5.1 build (16 bare hex characters) is still
366
+ compared exactly until it ages out. A row keyed under a secret this process does not hold (a
367
+ rotation inside the window) is answered on its status alone — replay, never re-run — and logs
368
+ `action.idempotency.fingerprint-unverifiable`: a mismatched body under that key is not diagnosed
369
+ for that window, and no honest retry gets a false 409.
370
+
361
371
  **Where the records live is declared, and refused at registration.** The default store is process
362
372
  memory — bounded, swept on a 24h window, and `scope: 'process'`. An app on more than one replica
363
373
  must say so and bring a store that can keep it, or the retry that lands on another replica finds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "22.5.0",
3
+ "version": "22.5.1",
4
4
  "description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,11 +34,11 @@
34
34
  "test": "bun test"
35
35
  },
36
36
  "dependencies": {
37
- "@ultimat3/cache": "22.5.0",
38
- "@ultimat3/core": "22.5.0",
39
- "@ultimat3/entity": "22.5.0",
40
- "@ultimat3/http": "22.5.0",
41
- "@ultimat3/policy": "22.5.0",
42
- "@ultimat3/schema": "22.5.0"
37
+ "@ultimat3/cache": "22.5.1",
38
+ "@ultimat3/core": "22.5.1",
39
+ "@ultimat3/entity": "22.5.1",
40
+ "@ultimat3/http": "22.5.1",
41
+ "@ultimat3/policy": "22.5.1",
42
+ "@ultimat3/schema": "22.5.1"
43
43
  }
44
44
  }
@@ -4,7 +4,7 @@
4
4
  * a value or a failure — and a concurrent duplicate is refused rather than run twice, because a
5
5
  * double charge is worse than a 409.
6
6
  */
7
- import { fingerprint, isUltimateError, logger } from '@ultimat3/core';
7
+ import { compareFingerprint, isUltimateError, keyedFingerprint, logger } from '@ultimat3/core';
8
8
  import {
9
9
  IdempotencyConflictError,
10
10
  IdempotencyNotSharedError,
@@ -57,7 +57,12 @@ export function isIdempotencyStatus(value: string): value is IdempotencyStatus {
57
57
  export interface IdempotencyRecord {
58
58
  readonly id: string;
59
59
  readonly key: string;
60
- /** Fingerprint of the parsed input — a reused key with a new payload is a bug. */
60
+ /**
61
+ * KEYED fingerprint of the parsed input (`keyedFingerprint`, HMAC under the app's signing
62
+ * secret) — a reused key with a new payload is a bug. Never a bare hash: the record outlives the
63
+ * request by a day, and an unkeyed hash of an input holding a short account number is an
64
+ * offline oracle for that number to anyone who can read the table.
65
+ */
61
66
  readonly requestHash: string;
62
67
  readonly status: IdempotencyStatus;
63
68
  readonly value: unknown;
@@ -172,6 +177,9 @@ export function assertIdempotencyScope(
172
177
  if (store.scope !== 'shared') throw new IdempotencyNotSharedError(store.scope);
173
178
  }
174
179
 
180
+ /** The `purpose` the request fingerprint is keyed under — one derived key, for this use only. */
181
+ export const IDEMPOTENCY_FINGERPRINT_PURPOSE = 'action.idempotency.request';
182
+
175
183
  export interface IdempotentOutcome<T> {
176
184
  readonly value: T;
177
185
  readonly replayed: boolean;
@@ -196,11 +204,9 @@ export async function withIdempotency<T>(
196
204
  input: unknown,
197
205
  run: () => Promise<T>,
198
206
  ): Promise<IdempotentOutcome<T>> {
199
- const requestHash = fingerprint(input);
207
+ const requestHash = keyedFingerprint(input, IDEMPOTENCY_FINGERPRINT_PURPOSE);
200
208
  const { record, created } = await store.reserve(key, requestHash);
201
- if (record.requestHash !== requestHash) {
202
- throw new IdempotencyConflictError(key, 'payload-mismatch');
203
- }
209
+ if (record.requestHash !== requestHash) assertSamePayload(key, record.requestHash, input);
204
210
  if (!created) {
205
211
  if (record.status === 'in-flight') throw new IdempotencyConflictError(key, 'in-flight');
206
212
  if (record.status === 'failed') throw new IdempotencyReplayedFailureError(key, record.failure);
@@ -224,6 +230,21 @@ export async function withIdempotency<T>(
224
230
  return { value, replayed: false };
225
231
  }
226
232
 
233
+ /**
234
+ * A stored fingerprint that is not this request's exact string. A legacy unkeyed one (a row
235
+ * written before keying) is still checked exactly. One keyed under a secret this process does not
236
+ * hold — the signing secret rotated inside the window — cannot be checked either way, and is
237
+ * answered by its STATUS alone: replay, never re-run. Refusing it would 409 every honest retry
238
+ * across a rotation; running it would be the double charge. What is lost for that window only is
239
+ * the "same key, different body" diagnosis, and a warning says so.
240
+ */
241
+ function assertSamePayload(key: string, stored: string, input: unknown): void {
242
+ const verdict = compareFingerprint(stored, input, IDEMPOTENCY_FINGERPRINT_PURPOSE);
243
+ if (verdict === 'mismatch') throw new IdempotencyConflictError(key, 'payload-mismatch');
244
+ if (verdict === 'unverifiable')
245
+ logger.warn('action.idempotency.fingerprint-unverifiable', { key });
246
+ }
247
+
227
248
  /**
228
249
  * Record the failure, and never let recording it replace the failure itself. A store that refuses
229
250
  * here would otherwise surface as the caller's error, hiding the `X_OUTPUT_INVALID` or the