@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 +10 -0
- package/package.json +7 -7
- package/src/idempotency.ts +27 -6
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.
|
|
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.
|
|
38
|
-
"@ultimat3/core": "22.5.
|
|
39
|
-
"@ultimat3/entity": "22.5.
|
|
40
|
-
"@ultimat3/http": "22.5.
|
|
41
|
-
"@ultimat3/policy": "22.5.
|
|
42
|
-
"@ultimat3/schema": "22.5.
|
|
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
|
}
|
package/src/idempotency.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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 =
|
|
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
|