Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions examples/deno-sensitive-info/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
16 changes: 2 additions & 14 deletions examples/deno-sensitive-info/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 1 addition & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions protocol/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions protocol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
117 changes: 117 additions & 0 deletions protocol/test/typeid.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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}`);
}
});
83 changes: 83 additions & 0 deletions protocol/typeid.ts
Original file line number Diff line number Diff line change
@@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider validating nowMs is a finite integer in the UUIDv7 timestamp range 0 <= nowMs < 2 ** 48 before converting and shifting it. Because this helper is exported and accepts injectable values, negative or too-large timestamps can wrap when written via setBigUint64, producing IDs with an unexpected embedded timestamp.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider validating nowMs is a finite integer in the UUIDv7 timestamp range 0 <= nowMs < 2 ** 48 before converting and shifting it. Because this helper is exported and accepts injectable values, negative or too-large timestamps can wrap when written via setBigUint64, producing IDs with an unexpected embedded timestamp.

const randA = BigInt((random[0] << 4) | (random[1] >> 4)); // 12 bits

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider validating random.length === 10 or keeping this helper internal-only. With a short injected Uint8Array, the first bit operations can silently treat missing bytes as zero and the later BigInt(random[i]) calls can throw, which makes failures less obvious for callers/tests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider validating random.length === 10 or keeping this helper internal-only. With a short injected Uint8Array, the first bit operations can silently treat missing bytes as zero and the later BigInt(random[i]) calls can throw, which makes failures less obvious for callers/tests.

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<string>(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 — `<prefix>_<suffix>`, 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))}`;
}
Loading