Skip to content

Commit 42251cb

Browse files
d-csclaude
andcommitted
fix(core): guard ksuid random source and address review nits
Fall back to node:crypto webcrypto when globalThis.crypto is unavailable (Node 18.20), share the ksuid length constant with the residency classifier, and tighten the ksuid/residency tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8276496 commit 42251cb

4 files changed

Lines changed: 36 additions & 17 deletions

File tree

packages/core/src/v3/isomorphic/friendlyId.test.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,6 @@ describe("RunId + WaitpointId mint cuid by default; ksuid via generateKsuidId",
5050
});
5151

5252
describe("generateKsuidId is a genuine KSUID (decodable timestamp, time-ordered)", () => {
53-
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
54-
const KSUID_EPOCH = 1_400_000_000;
55-
56-
// Decode the 27-char base62 body back to the 4-byte timestamp prefix (unix seconds).
57-
function decodeTimestamp(id: string): number {
58-
let n = 0n;
59-
for (const ch of id) n = n * 62n + BigInt(BASE62.indexOf(ch));
60-
return Number(n >> 128n) + KSUID_EPOCH; // top 4 of the 20 bytes
61-
}
62-
6353
afterEach(() => vi.useRealTimers());
6454

6555
it("is exactly 27 base62 chars", () => {
@@ -68,7 +58,7 @@ describe("generateKsuidId is a genuine KSUID (decodable timestamp, time-ordered)
6858

6959
it("carries a decodable timestamp within a few seconds of now", () => {
7060
const before = Math.floor(Date.now() / 1000);
71-
const ts = decodeTimestamp(generateKsuidId());
61+
const { timestampSeconds: ts } = decodeKsuid(generateKsuidId());
7262
expect(ts).toBeGreaterThanOrEqual(before - 2);
7363
expect(ts).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + 2);
7464
});
@@ -85,7 +75,7 @@ describe("generateKsuidId is a genuine KSUID (decodable timestamp, time-ordered)
8575

8676
it("is unique across many mints in the same second", () => {
8777
const n = 1000;
88-
expect(new Set(Array.from({ length: n }, generateKsuidId)).size).toBe(n);
78+
expect(new Set(Array.from({ length: n }, () => generateKsuidId())).size).toBe(n);
8979
});
9080
});
9181

packages/core/src/v3/isomorphic/friendlyId.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,35 @@ const KSUID_EPOCH = 1_400_000_000;
1212
const KSUID_TIMESTAMP_BYTES = 4;
1313
export const KSUID_PAYLOAD_BYTES = 16;
1414
const KSUID_TOTAL_BYTES = KSUID_TIMESTAMP_BYTES + KSUID_PAYLOAD_BYTES;
15-
const KSUID_STRING_LENGTH = 27;
15+
export const KSUID_STRING_LENGTH = 27;
1616
const BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1717

18+
// globalThis.crypto is absent on Node 18.20 (a supported engine) without a flag, so fall back to
19+
// node:crypto's webcrypto, loaded only when the global is missing to stay isomorphic.
20+
type RandomFiller = (array: Uint8Array) => void;
21+
22+
function resolveGetRandomValues(): RandomFiller {
23+
const globalCrypto = (globalThis as { crypto?: Crypto }).crypto;
24+
if (globalCrypto?.getRandomValues) {
25+
return (array) => globalCrypto.getRandomValues(array);
26+
}
27+
const webcrypto = loadNodeWebCrypto();
28+
if (webcrypto?.getRandomValues) {
29+
return (array) => webcrypto.getRandomValues(array);
30+
}
31+
throw new Error("No Web Crypto getRandomValues implementation available");
32+
}
33+
34+
function loadNodeWebCrypto(): Crypto | undefined {
35+
try {
36+
return (typeof require === "function" ? require("node:crypto") : undefined)?.webcrypto;
37+
} catch {
38+
return undefined;
39+
}
40+
}
41+
42+
const getRandomValues: RandomFiller = resolveGetRandomValues();
43+
1844
/** Encode raw bytes as base62 (big-endian), left-padded to the given length. */
1945
function base62Encode(bytes: Uint8Array, length: number): string {
2046
const digits = Array.from(bytes);
@@ -67,18 +93,18 @@ export function generateKsuidId(payload?: Uint8Array): string {
6793
bytes.set(payload, KSUID_TIMESTAMP_BYTES);
6894
}
6995
if (reserved < KSUID_PAYLOAD_BYTES) {
70-
globalThis.crypto.getRandomValues(bytes.subarray(KSUID_TIMESTAMP_BYTES + reserved));
96+
getRandomValues(bytes.subarray(KSUID_TIMESTAMP_BYTES + reserved));
7197
}
7298

7399
return base62Encode(bytes, KSUID_STRING_LENGTH);
74100
}
75101

76102
/** Decoded parts of a KSUID body: its mint timestamp and 16-byte payload. */
77-
export interface DecodedKsuid {
103+
export type DecodedKsuid = {
78104
timestampSeconds: number;
79105
timestamp: Date;
80106
payload: Uint8Array;
81-
}
107+
};
82108

83109
/**
84110
* Decode a KSUID body (or a `prefix_<body>` friendly id) into its timestamp + 16-byte payload.

packages/core/src/v3/isomorphic/runOpsResidency.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ describe("ownerEngine — residency classifier", () => {
4343
it("throws UnclassifiableRunId on malformed lengths (24, 26, 28, empty)", () => {
4444
for (const bad of ["", "x".repeat(24), "x".repeat(26), "x".repeat(28), "x".repeat(40)]) {
4545
expect(() => ownerEngine(bad)).toThrow(UnclassifiableRunId);
46+
expect(isClassifiable(bad)).toBe(false);
4647
}
4748
});
4849

packages/core/src/v3/isomorphic/runOpsResidency.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { KSUID_STRING_LENGTH } from "./friendlyId.js";
2+
13
/** The two run-ops stores a run/waitpoint can reside in. */
24
export type Residency = "LEGACY" | "NEW";
35

@@ -7,7 +9,7 @@ export type ResidencyKind = "cuid" | "ksuid";
79
/** @bugsnag/cuid emits 25-char ids (cuid path, flag OFF). */
810
export const CUID_LENGTH = 25;
911
/** KSUID / nanoid-27 emits 27-char ids (ksuid path, flag ON). */
10-
export const KSUID_LENGTH = 27;
12+
export const KSUID_LENGTH = KSUID_STRING_LENGTH;
1113

1214
/** Thrown when an id length matches neither the cuid nor the ksuid margin. */
1315
export class UnclassifiableRunId extends Error {

0 commit comments

Comments
 (0)