Skip to content

Commit c8d8177

Browse files
committed
fix(core): don't assume a 64-character idempotency key is pre-hashed on reset
`resetIdempotencyKey` treated any 64-character string as an already-computed hash and sent it to the API verbatim. That short-circuit ran before the scope logic, so a user key that is itself a 64-character digest had an explicitly passed `scope` silently discarded and was sent un-hashed, matching no run. A 64-character string is now only passed through when there is evidence it is already a hash: the idempotency key catalog recognises it (so it came from `idempotencyKeys.create()`), or no `scope` was passed and the length is the only signal available. An explicit `scope` is an explicit request to derive the hash, so it is always honoured. This keeps both existing behaviours intact: a key from `idempotencyKeys.create()` is still forwarded unchanged, and 64-character key material passed straight to `trigger()` and reset without a scope is still sent verbatim. `isIdempotencyKey` is deliberately untouched, since the trigger path is self-consistent and changing it would invalidate already-stored keys. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 69f396f commit c8d8177

3 files changed

Lines changed: 123 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
`idempotencyKeys.reset()` now works when your idempotency key is itself 64 characters long (for example if you use a hash of your own as the key). Previously any 64-character key was assumed to be already hashed, so passing one along with a `scope` silently ignored the scope and the reset never found a matching run. Keys returned by `idempotencyKeys.create()` continue to be reset exactly as before.

packages/core/src/v3/idempotencyKeys.test.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { describe, it, expect } from "vitest";
1+
import { createServer, type Server } from "node:http";
2+
import type { AddressInfo } from "node:net";
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { apiClientManager } from "./apiClientManager-api.js";
25
import {
36
createIdempotencyKey,
47
getIdempotencyKeyOptions,
8+
resetIdempotencyKey,
59
resetIdempotencyKeyCatalog,
610
} from "./idempotencyKeys.js";
11+
import { digestSHA256 } from "./utils/crypto.js";
712

813
describe("idempotencyKeys metadata retention", () => {
914
it("retains key/scope options for every key created in a run, even beyond 1000", async () => {
@@ -40,3 +45,96 @@ describe("idempotencyKeys metadata retention", () => {
4045
expect(getIdempotencyKeyOptions(key)).toBeUndefined();
4146
});
4247
});
48+
49+
describe("resetIdempotencyKey", () => {
50+
// A user key that is itself a 64-character digest, which is indistinguishable by
51+
// length from a key returned by `idempotencyKeys.create()`.
52+
const digestShapedKey = "a".repeat(64);
53+
54+
let server: Server;
55+
let resetKeys: string[] = [];
56+
57+
/** The value `resetIdempotencyKey` put on the wire. */
58+
async function resetAndCaptureKey(
59+
...args: Parameters<typeof resetIdempotencyKey>
60+
): Promise<string> {
61+
resetKeys = [];
62+
await resetIdempotencyKey(...args);
63+
expect(resetKeys).toHaveLength(1);
64+
return resetKeys[0]!;
65+
}
66+
67+
beforeEach(async () => {
68+
resetIdempotencyKeyCatalog();
69+
70+
server = createServer((req, res) => {
71+
req.resume();
72+
req.on("end", () => {
73+
const match = /^\/api\/v1\/idempotencyKeys\/(.+)\/reset$/.exec(req.url ?? "");
74+
if (!match) {
75+
res.writeHead(404).end();
76+
return;
77+
}
78+
79+
resetKeys.push(decodeURIComponent(match[1]!));
80+
res.writeHead(200, { "content-type": "application/json" });
81+
res.end(JSON.stringify({ id: "run_reset" }));
82+
});
83+
});
84+
85+
await new Promise<void>((resolve) => {
86+
server.listen(0, "127.0.0.1", () => resolve());
87+
});
88+
89+
apiClientManager.setGlobalAPIClientConfiguration({
90+
baseURL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
91+
accessToken: "tr_test_key",
92+
});
93+
});
94+
95+
afterEach(async () => {
96+
apiClientManager.disable();
97+
resetIdempotencyKeyCatalog();
98+
await new Promise<void>((resolve) => server.close(() => resolve()));
99+
});
100+
101+
it("hashes 64-character key material when an explicit scope is passed", async () => {
102+
const created = await createIdempotencyKey(digestShapedKey, { scope: "global" });
103+
104+
// The reset happens in a different process from the trigger (e.g. from a
105+
// lifecycle hook), so the catalog no longer knows the key.
106+
resetIdempotencyKeyCatalog();
107+
108+
expect(await resetAndCaptureKey("my-task", digestShapedKey, { scope: "global" })).toBe(created);
109+
});
110+
111+
it("hashes 64-character key material for run scope when an explicit scope is passed", async () => {
112+
const parentRunId = "run_abc123";
113+
const expected = await digestSHA256(`${digestShapedKey}-${parentRunId}`);
114+
115+
expect(
116+
await resetAndCaptureKey("my-task", digestShapedKey, { scope: "run", parentRunId })
117+
).toBe(expected);
118+
});
119+
120+
it("sends a key created with idempotencyKeys.create() unchanged", async () => {
121+
const created = await createIdempotencyKey("my-key", { scope: "global" });
122+
123+
expect(await resetAndCaptureKey("my-task", created)).toBe(created);
124+
// An explicit scope must not hash an already-created key a second time.
125+
expect(await resetAndCaptureKey("my-task", created, { scope: "global" })).toBe(created);
126+
});
127+
128+
it("sends a 64-character key unchanged when no scope is passed", async () => {
129+
// Passing 64-character material straight to `trigger()` stores it un-hashed, so
130+
// resetting it without a scope must keep sending it verbatim.
131+
expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey);
132+
});
133+
134+
it("hashes key material that is not 64 characters", async () => {
135+
const created = await createIdempotencyKey("my-key", { scope: "global" });
136+
resetIdempotencyKeyCatalog();
137+
138+
expect(await resetAndCaptureKey("my-task", "my-key", { scope: "global" })).toBe(created);
139+
});
140+
});

packages/core/src/v3/idempotencyKeys.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -234,19 +234,28 @@ export async function resetIdempotencyKey(
234234
): Promise<{ id: string }> {
235235
const client = apiClientManager.clientOrThrow();
236236

237-
// If the key is already a 64-char hash, use it directly
237+
// A 64-character string is ambiguous: it can be a hash returned by
238+
// `idempotencyKeys.create()`, or it can be the caller's own key material (using
239+
// a digest of some identity as the key is common). Send it through untouched
240+
// only when we have evidence it is already a hash:
241+
//
242+
// - the catalog recognises it, so it came from `idempotencyKeys.create()`, or
243+
// - no `scope` was passed, so there is nothing to derive a hash from and the
244+
// length is the only signal available.
245+
//
246+
// An explicit `scope` is an explicit request to derive the hash, so we never
247+
// short-circuit past it. Previously any 64-character key material was assumed to
248+
// be pre-hashed and sent as-is, which matched no run.
238249
if (typeof idempotencyKey === "string" && idempotencyKey.length === 64) {
239-
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
240-
}
250+
const isCreatedKey = getIdempotencyKeyOptions(idempotencyKey) !== undefined;
241251

242-
// Try to extract options from an IdempotencyKey created with idempotencyKeys.create()
243-
const attachedOptions =
244-
typeof idempotencyKey === "string" ? getIdempotencyKeyOptions(idempotencyKey) : undefined;
252+
if (isCreatedKey || options?.scope === undefined) {
253+
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
254+
}
255+
}
245256

246-
const scope = attachedOptions?.scope ?? options?.scope ?? "run";
247-
const keyArray = Array.isArray(idempotencyKey)
248-
? idempotencyKey
249-
: [attachedOptions?.key ?? String(idempotencyKey)];
257+
const scope = options?.scope ?? "run";
258+
const keyArray = Array.isArray(idempotencyKey) ? idempotencyKey : [idempotencyKey];
250259

251260
// Build scope suffix based on scope type
252261
let scopeSuffix: string[] = [];

0 commit comments

Comments
 (0)