From 7c23f1781662e7fa523a72f08491e3e448cc79e9 Mon Sep 17 00:00:00 2001 From: Quinn Blenkinsop Date: Fri, 26 Jun 2026 15:28:30 -0700 Subject: [PATCH 1/2] deps(protocol): vendor TypeID generation, drop typeid-js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the external typeid-js dependency with a small, dependency-free inline TypeID generator in @arcjet/protocol. This removes typeid-js and its transitive uuid dependency, resolving the uuid <11.1.1 advisory in the dependency tree (root npm audit: 8 → 6 findings, uuid gone). We only ever mint new local request IDs with a fixed prefix, so the vendored module covers generation only: a UUIDv7 (RFC 9562) encoded as a 26-character Crockford base32 suffix, matching the TypeID spec (https://github.com/jetify-com/typeid). It mirrors the implementation already vendored and shipping in the Arcjet Python SDK, so both SDKs produce identical IDs. Tests are ported from the Python SDK's test_typeid.py (format, UUIDv7 version/variant, first-char bound, uniqueness, time-sortability, zero/max timestamp, low/max entropy, alphabet, and fuzz/round-trip invariants). Also drops the now-unused typeid-js alias from the deno example. Validated: 13 ported tests pass, protocol lint passes, typeid-js + uuid removed from the root and deno lockfiles. Co-Authored-By: Claude --- examples/deno-sensitive-info/deno.json | 3 +- examples/deno-sensitive-info/deno.lock | 16 +--- package-lock.json | 25 +----- protocol/index.ts | 4 +- protocol/package.json | 3 +- protocol/test/typeid.test.ts | 117 +++++++++++++++++++++++++ protocol/typeid.ts | 83 ++++++++++++++++++ 7 files changed, 207 insertions(+), 44 deletions(-) create mode 100644 protocol/test/typeid.test.ts create mode 100644 protocol/typeid.ts diff --git a/examples/deno-sensitive-info/deno.json b/examples/deno-sensitive-info/deno.json index 1182f58d6f..34b6b973f5 100644 --- a/examples/deno-sensitive-info/deno.json +++ b/examples/deno-sensitive-info/deno.json @@ -24,7 +24,6 @@ "@bufbuild/protobuf": "npm:@bufbuild/protobuf@2.11.0", "@connectrpc/connect": "npm:@connectrpc/connect@2.1.1", "@connectrpc/connect-node": "npm:@connectrpc/connect-node@2.1.1", - "arcjet": "../../arcjet/index.js", - "typeid-js": "npm:typeid-js@1.2.0" + "arcjet": "../../arcjet/index.js" } } diff --git a/examples/deno-sensitive-info/deno.lock b/examples/deno-sensitive-info/deno.lock index 375b7bead7..e26e79089b 100644 --- a/examples/deno-sensitive-info/deno.lock +++ b/examples/deno-sensitive-info/deno.lock @@ -5,8 +5,7 @@ "npm:@bufbuild/protobuf@2.11.0": "2.11.0", "npm:@connectrpc/connect-node@2.1.1": "2.1.1_@bufbuild+protobuf@2.11.0_@connectrpc+connect@2.1.1__@bufbuild+protobuf@2.11.0", "npm:@connectrpc/connect@2.1.1": "2.1.1_@bufbuild+protobuf@2.11.0", - "npm:@types/node@*": "22.15.15", - "npm:typeid-js@1.2.0": "1.2.0" + "npm:@types/node@*": "22.15.15" }, "jsr": { "@std/dotenv@0.225.5": { @@ -36,26 +35,15 @@ "undici-types" ] }, - "typeid-js@1.2.0": { - "integrity": "sha512-t76ZucAnvGC60ea/HjVsB0TSoB0cw9yjnfurUgtInXQWUI/VcrlZGpO23KN3iSe8yOGUgb1zr7W7uEzJ3hSljA==", - "dependencies": [ - "uuid" - ] - }, "undici-types@6.21.0": { "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, - "uuid@10.0.0": { - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "bin": true } }, "workspace": { "dependencies": [ "npm:@bufbuild/protobuf@2.11.0", "npm:@connectrpc/connect-node@2.1.1", - "npm:@connectrpc/connect@2.1.1", - "npm:typeid-js@1.2.0" + "npm:@connectrpc/connect@2.1.1" ] } } diff --git a/package-lock.json b/package-lock.json index 01ca5dd2e1..d50a5555df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9512,15 +9512,6 @@ "node": ">= 0.8.0" } }, - "node_modules/typeid-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/typeid-js/-/typeid-js-1.2.0.tgz", - "integrity": "sha512-t76ZucAnvGC60ea/HjVsB0TSoB0cw9yjnfurUgtInXQWUI/VcrlZGpO23KN3iSe8yOGUgb1zr7W7uEzJ3hSljA==", - "license": "Apache-2.0", - "dependencies": { - "uuid": "^10.0.0" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -9937,19 +9928,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -10519,8 +10497,7 @@ "dependencies": { "@arcjet/cache": "1.5.0", "@bufbuild/protobuf": "2.12.0", - "@connectrpc/connect": "2.1.2", - "typeid-js": "1.2.0" + "@connectrpc/connect": "2.1.2" }, "devDependencies": { "@arcjet/eslint-config": "1.5.0", diff --git a/protocol/index.ts b/protocol/index.ts index f30e295917..9c3e80a4bc 100644 --- a/protocol/index.ts +++ b/protocol/index.ts @@ -1,7 +1,7 @@ import type { Cache } from "@arcjet/cache"; import { isMessage } from "@bufbuild/protobuf"; -import { typeid } from "typeid-js"; import { ReasonSchema } from "./proto/decide/v1alpha1/decide_pb.js"; +import { typeid } from "./typeid.js"; // Re-export the Well Known Bots from the generated file export type * from "./well-known-bots.js"; @@ -1253,7 +1253,7 @@ export abstract class ArcjetDecision { if (typeof init.id === "string") { this.id = init.id; } else { - this.id = typeid("lreq").toString(); + this.id = typeid("lreq"); } this.results = init.results; diff --git a/protocol/package.json b/protocol/package.json index 9cb1283c25..388b0b454f 100644 --- a/protocol/package.json +++ b/protocol/package.json @@ -51,8 +51,7 @@ "dependencies": { "@arcjet/cache": "1.5.0", "@bufbuild/protobuf": "2.12.0", - "@connectrpc/connect": "2.1.2", - "typeid-js": "1.2.0" + "@connectrpc/connect": "2.1.2" }, "devDependencies": { "@arcjet/eslint-config": "1.5.0", diff --git a/protocol/test/typeid.test.ts b/protocol/test/typeid.test.ts new file mode 100644 index 0000000000..dcb32fd724 --- /dev/null +++ b/protocol/test/typeid.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CROCKFORD_ALPHABET, typeid, uuidV7Bytes } from "../typeid.js"; + +// Ported from the Arcjet Python SDK's tests/unit/test_typeid.py — both SDKs +// vendor the same inline TypeID generation, so they must satisfy the same +// invariants (https://github.com/jetify-com/typeid). + +const CROCKFORD_RE = /^[0-9a-hj-km-np-tv-z]{26}$/; + +/** Assert `rid` is a well-formed `lreq` TypeID and return the suffix. */ +function assertValidTypeid(rid: string): string { + const sep = rid.indexOf("_"); + const prefix = rid.slice(0, sep); + const suffix = rid.slice(sep + 1); + assert.equal(prefix, "lreq"); + assert.equal(suffix.length, 26); + assert.match(suffix, CROCKFORD_RE, `bad suffix chars: ${suffix}`); + // 128-bit value encoded in 130 bits, so the leading char never exceeds 7. + assert.ok("01234567".includes(suffix[0]), `overflow: first char '${suffix[0]}'`); + return suffix; +} + +/** Assert `raw` is valid UUIDv7 bytes (version 7, RFC 4122 variant). */ +function assertValidUuidV7(raw: Uint8Array): void { + assert.equal(raw.length, 16); + assert.equal(raw[6] >> 4, 7, "version nibble must be 7"); + assert.equal(raw[8] >> 6, 0b10, "variant bits must be 10"); +} + +// --- Happy path ----------------------------------------------------------- + +test("format: lreq_ prefix and 26-char Crockford base32 suffix", () => { + assertValidTypeid(typeid("lreq")); +}); + +test("uuidv7 version and variant", () => { + assertValidUuidV7(uuidV7Bytes()); +}); + +test("suffix first char is always 0-7", () => { + for (let i = 0; i < 50; i++) { + assertValidTypeid(typeid("lreq")); + } +}); + +test("ids are unique", () => { + const ids = new Set(); + for (let i = 0; i < 200; i++) { + ids.add(typeid("lreq")); + } + assert.equal(ids.size, 200); +}); + +test("ids are time-sortable", () => { + const earlier = typeid("lreq", 1_000); + const later = typeid("lreq", 2_000); + assert.ok(later > earlier); +}); + +// --- Edge cases ----------------------------------------------------------- + +const zero10 = new Uint8Array(10); // all 0x00 +const ff10 = new Uint8Array(10).fill(0xff); // all 0xff + +test("zero timestamp still well-formed", () => { + assertValidTypeid(typeid("lreq", 0)); +}); + +test("max 48-bit timestamp still well-formed", () => { + assertValidTypeid(typeid("lreq", 2 ** 48 - 1)); +}); + +test("low-entropy (all-zero) random still valid", () => { + assertValidTypeid(typeid("lreq", Date.now(), zero10)); +}); + +test("max-entropy (all-0xff) random still valid", () => { + assertValidTypeid(typeid("lreq", Date.now(), ff10)); +}); + +test("all-0xff random preserves UUIDv7 version and variant", () => { + assertValidUuidV7(uuidV7Bytes(Date.now(), ff10)); +}); + +test("Crockford alphabet excludes ambiguous chars", () => { + for (const ch of "ilou") { + assert.ok(!CROCKFORD_ALPHABET.includes(ch)); + } + assert.equal(CROCKFORD_ALPHABET.length, 32); +}); + +// --- Fuzz (loop over random timestamp + random bytes) --------------------- + +const MAX_TIMESTAMP_MS = 2 ** 48 - 1; + +test("fuzz: any timestamp + random bytes yields a valid TypeID and UUIDv7", () => { + for (let i = 0; i < 500; i++) { + const ts = Math.floor(Math.random() * MAX_TIMESTAMP_MS); + const random = crypto.getRandomValues(new Uint8Array(10)); + assertValidTypeid(typeid("lreq", ts, random)); + assertValidUuidV7(uuidV7Bytes(ts, random)); + } +}); + +test("fuzz: embedded timestamp round-trips (truncated to integer ms)", () => { + for (let i = 0; i < 500; i++) { + const ts = Math.floor(Math.random() * MAX_TIMESTAMP_MS); + const random = crypto.getRandomValues(new Uint8Array(10)); + const raw = uuidV7Bytes(ts, random); + let actualMs = 0n; + for (let b = 0; b < 6; b++) { + actualMs = (actualMs << 8n) | BigInt(raw[b]); + } + assert.equal(actualMs, BigInt(ts), `timestamp mismatch: ${actualMs} != ${ts}`); + } +}); diff --git a/protocol/typeid.ts b/protocol/typeid.ts new file mode 100644 index 0000000000..63eff2234f --- /dev/null +++ b/protocol/typeid.ts @@ -0,0 +1,83 @@ +/** + * Minimal, dependency-free TypeID generator for local request IDs. + * + * Replaces the external `typeid-js` package (and its transitive `uuid` + * dependency) with an inline implementation. We only ever mint new IDs with a + * fixed prefix, so this covers generation — not parsing or decoding. + * + * The suffix is the 26-character Crockford base32 encoding of a UUIDv7 + * (RFC 9562), matching the TypeID specification + * (https://github.com/jetify-com/typeid). Mirrors the vendored implementation + * in the Arcjet Python SDK so both SDKs produce identical IDs. + */ + +/** Crockford base32 alphabet (lowercase, excludes `i`, `l`, `o`, `u`). */ +export const CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; + +/** Fill a 10-byte buffer from the platform CSPRNG (Web Crypto). */ +function randomBytes(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(10)); +} + +/** + * Generate the 16 raw bytes of a UUIDv7 (RFC 9562): a 48-bit big-endian + * millisecond timestamp, version `7`, the RFC 4122 variant (`10`), and random + * bits filling the remainder. + * + * `nowMs` and `random` are injectable for deterministic testing; production + * callers use the defaults. + */ +export function uuidV7Bytes( + nowMs: number = Date.now(), + random: Uint8Array = randomBytes(), +): Uint8Array { + const timestampMs = BigInt(Math.trunc(nowMs)); + const randA = BigInt((random[0] << 4) | (random[1] >> 4)); // 12 bits + let randBFull = 0n; + for (let i = 2; i < 10; i++) { + randBFull = (randBFull << 8n) | BigInt(random[i]); + } + const randB = randBFull & ((1n << 62n) - 1n); // 62 bits + + const hi = (timestampMs << 16n) | (0x7n << 12n) | randA; // version 7 + const lo = (0b10n << 62n) | randB; // RFC 4122 variant + + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + view.setBigUint64(0, hi, false); // big-endian + view.setBigUint64(8, lo, false); + return bytes; +} + +/** + * Encode 16 bytes as a 26-character Crockford base32 string. The bytes are read + * as a big-endian 128-bit integer, most-significant character first. 26 × 5 = + * 130 bits, so the leading character carries only the top 3 bits and is always + * `0`-`7`. + */ +function encodeCrockford(bytes: Uint8Array): string { + let n = 0n; + for (const byte of bytes) { + n = (n << 8n) | BigInt(byte); + } + const out = new Array(26); + for (let i = 25; i >= 0; i--) { + out[i] = CROCKFORD_ALPHABET[Number(n & 0x1fn)]; + n >>= 5n; + } + return out.join(""); +} + +/** + * Generate a new TypeID string — `_`, where the suffix is the + * Crockford base32 encoding of a freshly generated UUIDv7. + * + * `nowMs` and `random` are injectable for deterministic testing. + */ +export function typeid( + prefix: string, + nowMs?: number, + random?: Uint8Array, +): string { + return `${prefix}_${encodeCrockford(uuidV7Bytes(nowMs, random))}`; +} From 500338fb22aa1373136ca1b76a927242c6c219c9 Mon Sep 17 00:00:00 2001 From: Quinn Blenkinsop Date: Mon, 29 Jun 2026 09:09:05 -0700 Subject: [PATCH 2/2] fix(protocol): validate typeid generator inputs Address review feedback on the vendored TypeID generator. `uuidV7Bytes` is exported and accepts injectable `nowMs`/`random` (for deterministic tests), so guard the two UUIDv7 invariants instead of silently emitting a malformed ID: - `nowMs` must be an integer in `[0, 2 ** 48)`. A negative or too-large value wraps when written via `setBigUint64`, embedding the wrong timestamp. - `random` must be exactly 10 bytes. A shorter array silently zero-fills the leading bytes and throws an opaque error on the `BigInt` conversions. Both now throw a clear `RangeError`. Adds tests for the valid boundaries (0 and `2 ** 48 - 1`) and the rejected inputs. Co-Authored-By: Claude Opus 4.8 (1M context) --- protocol/test/typeid.test.ts | 25 +++++++++++++++++++++++++ protocol/typeid.ts | 17 ++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/protocol/test/typeid.test.ts b/protocol/test/typeid.test.ts index dcb32fd724..5e41dcbdf2 100644 --- a/protocol/test/typeid.test.ts +++ b/protocol/test/typeid.test.ts @@ -115,3 +115,28 @@ test("fuzz: embedded timestamp round-trips (truncated to integer ms)", () => { assert.equal(actualMs, BigInt(ts), `timestamp mismatch: ${actualMs} != ${ts}`); } }); + +// --- Input validation ----------------------------------------------------- + +test("rejects an out-of-range or non-integer `nowMs`", () => { + for (const bad of [-1, 2 ** 48, 2 ** 53, 1.5, NaN, Infinity, -Infinity]) { + assert.throws( + () => uuidV7Bytes(bad, zero10), + RangeError, + `expected RangeError for nowMs=${bad}`, + ); + } + // Boundaries are valid: 0 and the largest 48-bit millisecond. + assert.doesNotThrow(() => uuidV7Bytes(0, zero10)); + assert.doesNotThrow(() => uuidV7Bytes(2 ** 48 - 1, zero10)); +}); + +test("rejects `random` that is not exactly 10 bytes", () => { + for (const len of [0, 9, 11, 16]) { + assert.throws( + () => uuidV7Bytes(0, new Uint8Array(len)), + RangeError, + `expected RangeError for random length ${len}`, + ); + } +}); diff --git a/protocol/typeid.ts b/protocol/typeid.ts index 63eff2234f..c4b19d6ce9 100644 --- a/protocol/typeid.ts +++ b/protocol/typeid.ts @@ -26,12 +26,27 @@ function randomBytes(): Uint8Array { * * `nowMs` and `random` are injectable for deterministic testing; production * callers use the defaults. + * + * @throws {RangeError} + * If `nowMs` is not an integer in the 48-bit range `[0, 2 ** 48)`, or if + * `random` is not exactly 10 bytes. Both would otherwise silently produce a + * malformed ID (a wrapped timestamp or zero-filled entropy). */ export function uuidV7Bytes( nowMs: number = Date.now(), random: Uint8Array = randomBytes(), ): Uint8Array { - const timestampMs = BigInt(Math.trunc(nowMs)); + if (!Number.isInteger(nowMs) || nowMs < 0 || nowMs >= 2 ** 48) { + throw new RangeError( + `uuidV7Bytes: \`nowMs\` must be an integer in [0, 2 ** 48), got ${nowMs}`, + ); + } + if (random.length !== 10) { + throw new RangeError( + `uuidV7Bytes: \`random\` must be exactly 10 bytes, got ${random.length}`, + ); + } + const timestampMs = BigInt(nowMs); const randA = BigInt((random[0] << 4) | (random[1] >> 4)); // 12 bits let randBFull = 0n; for (let i = 2; i < 10; i++) {