@smonn/ids 0.3.0 → 0.3.2

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 CHANGED
@@ -6,7 +6,7 @@ Public-facing branded IDs for TypeScript apps.
6
6
  pnpm add @smonn/ids
7
7
  ```
8
8
 
9
- Each ID looks like `usr_01h7b3k9rqxn4cw3p9r8t2sgkz`: a three-letter brand, an underscore, then 26 Crockford base32 characters encoding a 48-bit millisecond Unix timestamp followed by 80 random bits. Same byte layout as a [ULID](https://github.com/ulid/spec); see [ADR-0002](./docs/adr/0002-payload-layout.md) for the deliberate divergences.
9
+ Each ID looks like `usr_01h7b3k9rqxn4cw3p9r8t2sgkz`: a three-letter brand, an underscore, then 26 Crockford base32 characters of payload. The Timestamp codec encodes a 48-bit millisecond Unix timestamp followed by 80 random bits same byte layout as a [ULID](https://github.com/ulid/spec); see [ADR-0002](./docs/adr/0002-payload-layout.md) for the deliberate divergences. The Opaque codec (`@smonn/ids/opaque`) keeps the same wire shape but encrypts the payload under a key, so the timestamp isn't readable from the ID.
10
10
 
11
11
  ## What this is for
12
12
 
@@ -117,7 +117,7 @@ Both `Options` fields are optional. Defaults are `Date.now` and an entropy harve
117
117
 
118
118
  ### "Catch a double-registered brand before it bites in production"
119
119
 
120
- The intended pattern is one codec per brand per process, constructed at module init. Calling `createId(brand)` a second time for the same brand usually means a bundling or import bug (accidental re-export, a test re-importing without resetting). In development (`process.env.NODE_ENV !== "production"`), the second call emits a one-shot `console.warn`; the brand-tracking registry is skipped in production. Tests that intentionally re-create codecs can opt out:
120
+ The intended pattern is one codec per brand per process, constructed at module init. Calling `createId(brand)` a second time for the same brand usually means a bundling or import bug (accidental re-export, a test re-importing without resetting). In development (`process.env.NODE_ENV !== "production"`), the second call emits a one-shot `console.warn`; the brand-tracking registry is skipped in production. The same registry covers cross-codec collisions: `createId("usr")` followed by `createOpaqueId("usr")` warns too, because codec choice is a per-brand commitment ([ADR-0007](./docs/adr/0007-wire-indistinguishable-codec-variants.md)). Tests that intentionally re-create codecs can opt out:
121
121
 
122
122
  ```ts
123
123
  const users = createId("usr", { allowDuplicateBrand: true });
@@ -164,12 +164,36 @@ The `pattern` describes the **canonical form only** — it matches `generate()`
164
164
 
165
165
  `example` is produced by calling `generate()` on each invocation, so it is fresh (non-deterministic) and always matches the returned `pattern`. One consequence: a codec wired with an injected `now` outside the 48-bit range — the same misconfiguration that breaks `generate()` — makes `toJsonSchema()` throw too.
166
166
 
167
+ ### "Don't leak creation time in IDs that customers can see"
168
+
169
+ The Timestamp codec exposes the creation timestamp by design — that's what makes `ORDER BY id` work. If that's a leak you can't accept (invoice IDs revealing billing cadence, signup IDs revealing acquisition velocity), use the Opaque codec at `@smonn/ids/opaque`. Same `<brand>_<26 chars>` wire shape, but the payload is AES-encrypted under a key you supply.
170
+
171
+ ```ts
172
+ import { createOpaqueId, importOpaqueKey } from "@smonn/ids/opaque";
173
+
174
+ const key = await importOpaqueKey(new Uint8Array(16)); // 128- or 256-bit raw key
175
+ const invoices = createOpaqueId("inv", { key });
176
+
177
+ const id = await invoices.generate(); // "inv_…", timestamp not extractable without the key
178
+ await invoices.extractTimestamp(id); // Date — same codec, same key required
179
+ ```
180
+
181
+ Three differences from the Timestamp codec:
182
+
183
+ - **Async key-dependent methods.** WebCrypto is async-only, so `generate`, `generateAt`, and `extractTimestamp` return `Promise`s. `is`, `parse`, `safeParse`, `toJsonSchema`, and the Standard Schema adapter stay sync — they work on the wire form only ([ADR-0006](./docs/adr/0006-async-keyed-codec-contract.md)).
184
+ - **No `minIdForTime` / `maxIdForTime`.** Encrypted payloads don't sort by time. If you need time-range scans on Opaque-coded entities, store the timestamp in a separate column.
185
+ - **Wire-indistinguishable from the Timestamp codec.** Codec choice is a per-brand commitment; the brand registry warns if you register the same brand against both in dev ([ADR-0007](./docs/adr/0007-wire-indistinguishable-codec-variants.md)).
186
+
187
+ Encryption is AES-CBC with a zero IV. That's deliberately safe here because the plaintext already carries 80 bits of entropy per ID; see [ADR-0004](./docs/adr/0004-aes-cbc-strip-trick.md) for the full rationale.
188
+
189
+ To store or transport key material outside the library, `encodeOpaqueKey` / `decodeOpaqueKey` round-trip raw bytes in `hex` or `base64url` — distinct from the Crockford base32 used in ID payloads. The CLI's `keygen` subcommand emits keys in this format (see [CLI](#cli)).
190
+
167
191
  ## What this is **not** for
168
192
 
169
193
  - **Internal surrogate primary keys.** If nobody outside your service ever sees the ID, the brand prefix and lenient parsing are dead weight. Use a `bigint` sequence.
170
194
  - **Wire-compatible ULIDs.** The byte layout is ULID-shaped but the encoding is lowercase and wrapped in a brand envelope. Stock ULID parsers will reject these.
171
195
  - **Distributed-trace / request-correlation IDs.** Use OpenTelemetry-format IDs.
172
- - **Hiding when your system launched.** Anyone with one known-time ID can compute the epoch offset. A custom epoch isn't supported, and wouldn't help anyway.
196
+ - **Hiding creation time with the Timestamp codec.** Anyone with one ID at a known creation time can compute the epoch offset. A custom epoch wouldn't help and isn't supported. To hide creation time per-ID, use the Opaque codec (above).
173
197
 
174
198
  ## API surface
175
199
 
@@ -183,25 +207,39 @@ import {
183
207
  type ParseResult, // safeParse return type
184
208
  type JsonSchema, // toJsonSchema return type
185
209
  } from "@smonn/ids";
210
+
211
+ import {
212
+ createOpaqueId, // (brand: string, opts: { key, now?, rng?, allowDuplicateBrand? }) => OpaqueCodec<Brand>
213
+ importOpaqueKey, // (bytes: Uint8Array) => Promise<CryptoKey>
214
+ encodeOpaqueKey, // (bytes: Uint8Array, format: OpaqueKeyFormat) => string
215
+ decodeOpaqueKey, // (encoded: string, format: OpaqueKeyFormat) => Uint8Array
216
+ type OpaqueCodec, // returned by createOpaqueId
217
+ type OpaqueOptions, // { key, now, rng, allowDuplicateBrand } injection points
218
+ type OpaqueKeyFormat, // "hex" | "base64url"
219
+ } from "@smonn/ids/opaque";
186
220
  ```
187
221
 
188
- ### `Codec<Brand>`
222
+ ### Codec methods
189
223
 
190
- | Method | Description |
191
- | ---------------------- | ----------------------------------------------------------------------------- |
192
- | `generate()` | Produce a fresh ID |
193
- | `generateAt(date)` | Produce a fresh ID with timestamp bytes from `date` (for backfills) |
194
- | `is(value)` | Strict type guard: `true` only for already-canonical strings |
195
- | `parse(value)` | Lenient: normalise to canonical, or throw |
196
- | `safeParse(value)` | Lenient: normalise to canonical, or return `{ ok: false, error }` |
197
- | `extractTimestamp(id)` | Decode the creation `Date` from an `Id<Brand>` (trusts the type) |
198
- | `minIdForTime(date)` | Tight lower bound for any ID generated at `date` (for range queries) |
199
- | `maxIdForTime(date)` | Tight upper bound for any ID generated at `date` (for range queries) |
200
- | `toJsonSchema()` | JSON Schema (`type`/`pattern`/`description`/`example`) for the canonical form |
224
+ | Method | `Codec<Brand>` | `OpaqueCodec<Brand>` | Description |
225
+ | ---------------------- | -------------- | -------------------- | ----------------------------------------------------------------------------- |
226
+ | `generate()` | sync | async | Produce a fresh ID |
227
+ | `generateAt(date)` | sync | async | Produce a fresh ID with timestamp bytes from `date` (for backfills) |
228
+ | `is(value)` | sync | sync | Strict type guard: `true` only for already-canonical strings |
229
+ | `parse(value)` | sync | sync | Lenient: normalise to canonical, or throw |
230
+ | `safeParse(value)` | sync | sync | Lenient: normalise to canonical, or return `{ ok: false, error }` |
231
+ | `extractTimestamp(id)` | sync | async | Decode the creation `Date` from an `Id<Brand>` (trusts the type) |
232
+ | `minIdForTime(date)` | sync | — | Tight lower bound for any ID generated at `date` (for range queries) |
233
+ | `maxIdForTime(date)` | sync | — | Tight upper bound for any ID generated at `date` (for range queries) |
234
+ | `toJsonSchema()` | sync | sync | JSON Schema (`type`/`pattern`/`description`/`example`) for the canonical form |
201
235
 
202
236
  ## CLI
203
237
 
204
- Two brand-agnostic subcommands, no install required:
238
+ Brand-agnostic subcommands, no install required. Run `npx @smonn/ids --help` for the full flag list.
239
+
240
+ ### `inspect` (`i`)
241
+
242
+ Decode an ID and print brand, timestamp, canonical form, and whether the input was already canonical.
205
243
 
206
244
  ```bash
207
245
  $ npx @smonn/ids inspect usr_01h7b3k9rqxn1cw3p9r8t2sgkz
@@ -209,14 +247,54 @@ brand: usr
209
247
  timestamp: 1983-05-27T10:24:22.469Z (43 years ago)
210
248
  canonical: usr_01h7b3k9rqxn1cw3p9r8t2sgkz
211
249
  input: canonical
250
+ ```
251
+
252
+ Accepts non-canonical input (uppercase, Crockford aliases). Assumes the **Timestamp codec** — if the brand uses the **Opaque codec**, pass `--opaque` and set `IDS_KEY` (below); otherwise the timestamp line is meaningless garbage.
253
+
254
+ ```bash
255
+ IDS_KEY=<hex-or-base64url-key> npx @smonn/ids inspect inv_… --opaque
256
+ ```
257
+
258
+ Prints the decrypted timestamp **assuming `IDS_KEY` matches the key used at generation** — a well-formed but wrong key yields a plausible but incorrect timestamp, not an error (see [CONTEXT.md](./CONTEXT.md)).
259
+
260
+ ### `generate` (`g`)
212
261
 
262
+ Mint one or more canonical IDs for a brand. Output is one ID per line (pipeable).
263
+
264
+ ```bash
213
265
  $ npx @smonn/ids generate usr --count 3
214
266
  usr_…
215
267
  usr_…
216
268
  usr_…
217
269
  ```
218
270
 
219
- `inspect` accepts non-canonical input (uppercase, Crockford aliases) and shows the canonical form. `generate` prints one ID per line so output is pipeable. Invalid input prints the parse error to stderr and exits non-zero.
271
+ Flags: `--count` / `-c N` (default 1). Uses the Timestamp codec unless `--opaque` is set.
272
+
273
+ ```bash
274
+ IDS_KEY=<hex-or-base64url-key> npx @smonn/ids generate inv --opaque --count 2
275
+ ```
276
+
277
+ ### `keygen` (`k`)
278
+
279
+ Emit a random Opaque key to stdout (a secret — do not log or commit). Default: 256-bit hex.
280
+
281
+ ```bash
282
+ $ npx @smonn/ids keygen
283
+ a1b2c3…
284
+
285
+ $ npx @smonn/ids keygen --bits 128 --key-format base64url
286
+ AbCdEf…
287
+ ```
288
+
289
+ Flags: `--bits 128|192|256` (default 256), `--key-format hex|base64url` (default `hex`). `IDS_KEY_FORMAT` does not affect `keygen` — only `--key-format` on the command line. Output round-trips through `decodeOpaqueKey` / `importOpaqueKey`.
290
+
291
+ ### Opaque mode (`--opaque`)
292
+
293
+ `generate --opaque` and `inspect --opaque` read the AES key from the `IDS_KEY` environment variable — not from argv (argv leaks via `ps` and shell history). Missing or malformed `IDS_KEY` prints a clear stderr message and exits non-zero.
294
+
295
+ Key format defaults to `hex`; override per-invocation with `--key-format` or set `IDS_KEY_FORMAT=hex|base64url` for a session default. `--key-format` on the command line wins over `IDS_KEY_FORMAT`.
296
+
297
+ Invalid input prints the parse error to stderr and exits non-zero.
220
298
 
221
299
  ## Design
222
300
 
package/dist/cli.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { t as createId } from "./id-B4GiFKYV.mjs";
2
+ import { t as createId } from "./id-BlQPohZp.mjs";
3
+ import { i as encodeOpaqueKey, n as importOpaqueKey, r as decodeOpaqueKey, t as createOpaqueId } from "./opaque-ChnxvPm5.mjs";
3
4
  //#region src/cli.ts
4
- function run(opts) {
5
+ async function run(opts) {
5
6
  const [subcommand, ...rest] = opts.argv;
6
7
  if (subcommand === "generate" || subcommand === "g") return runGenerate(rest, opts);
7
8
  if (subcommand === "inspect" || subcommand === "i") return runInspect(rest, opts);
9
+ if (subcommand === "keygen" || subcommand === "k") return runKeygen(rest, opts);
8
10
  if (subcommand === void 0 || subcommand === "--help" || subcommand === "-h") {
9
11
  opts.stdout(usage());
10
12
  return 0;
@@ -17,21 +19,72 @@ function usage() {
17
19
  "Usage: ids <subcommand> [args]",
18
20
  "",
19
21
  "Subcommands:",
20
- " inspect, i <id> Decode an ID and print brand, timestamp, and canonical form.",
21
- " generate, g <brand> [--count, -c N] Mint one or more canonical IDs for the given brand.",
22
+ " inspect, i <id> [--opaque] [--key-format hex|base64url]",
23
+ " Decode an ID and print brand, timestamp, and canonical form.",
24
+ " --opaque reads the AES key from IDS_KEY (hex by default; IDS_KEY_FORMAT or --key-format).",
25
+ " generate, g <brand> [--count, -c N] [--opaque] [--key-format hex|base64url]",
26
+ " Mint one or more canonical IDs for the given brand.",
27
+ " --opaque reads the AES key from IDS_KEY (hex by default; IDS_KEY_FORMAT or --key-format).",
28
+ " keygen, k [--bits 128|192|256] [--key-format hex|base64url]",
29
+ " Emit a random AES key for importOpaqueKey (stdout only).",
22
30
  ""
23
31
  ].join("\n");
24
32
  }
25
33
  function runInspect(args, opts) {
26
- const [input] = args;
34
+ const { flags, values, positionals } = splitFlags(args);
35
+ const [input] = positionals;
27
36
  if (input === void 0) {
28
37
  opts.stderr(usage());
29
- return 1;
38
+ return Promise.resolve(1);
30
39
  }
40
+ const opaque = flags.has("--opaque");
31
41
  const brand = input.slice(0, 3).toLowerCase();
42
+ if (opaque) {
43
+ const format = parseOpaqueKeyFormat(values, opts);
44
+ if (isKeyFormatError(format)) {
45
+ opts.stderr(format + "\n");
46
+ return Promise.resolve(1);
47
+ }
48
+ return runOpaqueInspect(brand, input, format, opts);
49
+ }
32
50
  let codec;
33
51
  try {
34
52
  codec = createId(brand, codecOpts(opts));
53
+ } catch (err) {
54
+ opts.stderr(err.message + "\n");
55
+ return Promise.resolve(1);
56
+ }
57
+ const validation = codec["~standard"].validate(input);
58
+ if (validation.issues) {
59
+ opts.stderr(validation.issues[0].message + "\n");
60
+ return Promise.resolve(1);
61
+ }
62
+ const canonical = validation.value;
63
+ const timestamp = codec.extractTimestamp(canonical);
64
+ const nowMs = (opts.now ?? Date.now)();
65
+ const relative = formatRelative(timestamp.getTime(), nowMs);
66
+ const inputLine = describeInputForm(input, canonical);
67
+ opts.stdout([
68
+ `brand: ${brand}`,
69
+ `timestamp: ${timestamp.toISOString()} (${relative})`,
70
+ `canonical: ${canonical}`,
71
+ `input: ${inputLine}`,
72
+ ""
73
+ ].join("\n"));
74
+ return Promise.resolve(0);
75
+ }
76
+ async function runOpaqueInspect(brand, input, format, opts) {
77
+ const keyResult = await loadOpaqueKey(opts, format);
78
+ if (typeof keyResult === "string") {
79
+ opts.stderr(keyResult + "\n");
80
+ return 1;
81
+ }
82
+ let codec;
83
+ try {
84
+ codec = createOpaqueId(brand, {
85
+ key: keyResult,
86
+ ...codecOpts(opts)
87
+ });
35
88
  } catch (err) {
36
89
  opts.stderr(err.message + "\n");
37
90
  return 1;
@@ -42,10 +95,11 @@ function runInspect(args, opts) {
42
95
  return 1;
43
96
  }
44
97
  const canonical = validation.value;
45
- const timestamp = codec.extractTimestamp(canonical);
98
+ const timestamp = await codec.extractTimestamp(canonical);
46
99
  const nowMs = (opts.now ?? Date.now)();
47
100
  const relative = formatRelative(timestamp.getTime(), nowMs);
48
101
  const inputLine = describeInputForm(input, canonical);
102
+ opts.stderr("note: timestamp assumes IDS_KEY matches the key used at generation; a wrong key yields a plausible but incorrect timestamp\n");
49
103
  opts.stdout([
50
104
  `brand: ${brand}`,
51
105
  `timestamp: ${timestamp.toISOString()} (${relative})`,
@@ -89,30 +143,171 @@ function unit(n, name) {
89
143
  return `${n} ${n === 1 ? name : `${name}s`}`;
90
144
  }
91
145
  function runGenerate(args, opts) {
92
- const [brand, ...flags] = args;
93
- const count = parseCount(flags);
146
+ const { flags, values, positionals } = splitFlags(args);
147
+ const [brand] = positionals;
148
+ const count = parseCount(values);
94
149
  if (typeof count === "string") {
95
150
  opts.stderr(count + "\n");
96
- return 1;
151
+ return Promise.resolve(1);
152
+ }
153
+ if (flags.has("--opaque")) {
154
+ const format = parseOpaqueKeyFormat(values, opts);
155
+ if (isKeyFormatError(format)) {
156
+ opts.stderr(format + "\n");
157
+ return Promise.resolve(1);
158
+ }
159
+ return runOpaqueGenerate(brand ?? "", count, format, opts);
97
160
  }
98
161
  let codec;
99
162
  try {
100
163
  codec = createId(brand ?? "", codecOpts(opts));
101
164
  } catch (err) {
102
165
  opts.stderr(err.message + "\n");
103
- return 1;
166
+ return Promise.resolve(1);
104
167
  }
105
168
  for (let i = 0; i < count; i++) opts.stdout(codec.generate() + "\n");
169
+ return Promise.resolve(0);
170
+ }
171
+ async function runOpaqueGenerate(brand, count, format, opts) {
172
+ const keyResult = await loadOpaqueKey(opts, format);
173
+ if (typeof keyResult === "string") {
174
+ opts.stderr(keyResult + "\n");
175
+ return 1;
176
+ }
177
+ let codec;
178
+ try {
179
+ codec = createOpaqueId(brand, {
180
+ key: keyResult,
181
+ ...codecOpts(opts)
182
+ });
183
+ } catch (err) {
184
+ opts.stderr(err.message + "\n");
185
+ return 1;
186
+ }
187
+ for (let i = 0; i < count; i++) opts.stdout(await codec.generate() + "\n");
106
188
  return 0;
107
189
  }
108
- function parseCount(flags) {
109
- const idx = flags.findIndex((f) => f === "--count" || f === "-c");
110
- if (idx === -1) return 1;
111
- const raw = flags[idx + 1];
112
- if (raw === void 0) return "--count requires a value";
190
+ function runKeygen(args, opts) {
191
+ const { values } = splitFlags(args);
192
+ const bits = parseBits(values);
193
+ if (typeof bits === "string") {
194
+ opts.stderr(bits + "\n");
195
+ return Promise.resolve(1);
196
+ }
197
+ const format = parseKeygenFormat(values);
198
+ if (isKeyFormatError(format)) {
199
+ opts.stderr(format + "\n");
200
+ return Promise.resolve(1);
201
+ }
202
+ const bytes = new Uint8Array(bits / 8);
203
+ crypto.getRandomValues(bytes);
204
+ opts.stdout(encodeOpaqueKey(bytes, format) + "\n");
205
+ return Promise.resolve(0);
206
+ }
207
+ async function loadOpaqueKey(opts, format) {
208
+ const raw = (opts.env ?? process.env).IDS_KEY;
209
+ if (raw === void 0 || raw === "") return "missing IDS_KEY environment variable";
210
+ try {
211
+ return importOpaqueKey(decodeOpaqueKey(raw, format));
212
+ } catch (err) {
213
+ return err.message;
214
+ }
215
+ }
216
+ function parseCount(values) {
217
+ const raw = values.get("--count") ?? values.get("-c");
218
+ if (raw === void 0) return 1;
219
+ if (raw === "") return "--count requires a value";
113
220
  if (!/^[1-9][0-9]*$/.test(raw)) return `--count must be a positive integer, got '${raw}'`;
114
221
  return Number(raw);
115
222
  }
223
+ function parseBits(values) {
224
+ const raw = values.get("--bits");
225
+ if (raw === void 0) return 256;
226
+ if (raw === "") return "--bits requires a value";
227
+ if (raw === "128") return 128;
228
+ if (raw === "192") return 192;
229
+ if (raw === "256") return 256;
230
+ return `--bits must be 128, 192, or 256, got '${raw}'`;
231
+ }
232
+ function isKeyFormatError(result) {
233
+ return result !== "hex" && result !== "base64url";
234
+ }
235
+ function parseKeyFormatFlag(values) {
236
+ const fromFlag = values.get("--key-format");
237
+ if (fromFlag === void 0) return void 0;
238
+ if (fromFlag === "") return "--key-format requires a value";
239
+ if (fromFlag === "hex" || fromFlag === "base64url") return fromFlag;
240
+ return `--key-format must be hex or base64url, got '${fromFlag}'`;
241
+ }
242
+ function parseKeygenFormat(values) {
243
+ const fromFlag = parseKeyFormatFlag(values);
244
+ if (fromFlag === void 0) return "hex";
245
+ return fromFlag;
246
+ }
247
+ function parseOpaqueKeyFormat(values, opts) {
248
+ const fromFlag = parseKeyFormatFlag(values);
249
+ if (fromFlag !== void 0) return fromFlag;
250
+ const fromEnv = (opts.env ?? process.env).IDS_KEY_FORMAT;
251
+ if (fromEnv === void 0 || fromEnv === "") return "hex";
252
+ if (fromEnv === "hex" || fromEnv === "base64url") return fromEnv;
253
+ return `IDS_KEY_FORMAT must be hex or base64url, got '${fromEnv}'`;
254
+ }
255
+ function splitFlagToken(arg) {
256
+ const eq = arg.indexOf("=");
257
+ if (eq <= 0) return {
258
+ flag: arg,
259
+ inlineValue: void 0
260
+ };
261
+ return {
262
+ flag: arg.slice(0, eq),
263
+ inlineValue: arg.slice(eq + 1)
264
+ };
265
+ }
266
+ function splitFlags(args) {
267
+ const flags = /* @__PURE__ */ new Set();
268
+ const values = /* @__PURE__ */ new Map();
269
+ const positionals = [];
270
+ const valueFlags = new Set([
271
+ "--count",
272
+ "-c",
273
+ "--bits",
274
+ "--key-format"
275
+ ]);
276
+ for (let i = 0; i < args.length; i++) {
277
+ const raw = args[i];
278
+ const { flag, inlineValue } = splitFlagToken(raw);
279
+ if (flag === "--opaque") {
280
+ flags.add(flag);
281
+ continue;
282
+ }
283
+ if (valueFlags.has(flag)) {
284
+ if (inlineValue !== void 0) {
285
+ flags.add(flag);
286
+ values.set(flag, inlineValue);
287
+ continue;
288
+ }
289
+ const value = args[i + 1];
290
+ if (value === void 0 || value.startsWith("-")) {
291
+ values.set(flag, "");
292
+ continue;
293
+ }
294
+ flags.add(flag);
295
+ values.set(flag, value);
296
+ i++;
297
+ continue;
298
+ }
299
+ if (flag.startsWith("-")) {
300
+ flags.add(flag);
301
+ continue;
302
+ }
303
+ positionals.push(raw);
304
+ }
305
+ return {
306
+ flags,
307
+ values,
308
+ positionals
309
+ };
310
+ }
116
311
  function codecOpts(opts) {
117
312
  const o = { allowDuplicateBrand: true };
118
313
  if (opts.now !== void 0) o.now = opts.now;
@@ -121,7 +316,7 @@ function codecOpts(opts) {
121
316
  }
122
317
  //#endregion
123
318
  //#region bin/cli.ts
124
- process.exitCode = run({
319
+ process.exitCode = await run({
125
320
  argv: process.argv.slice(2),
126
321
  stdout: (s) => process.stdout.write(s),
127
322
  stderr: (s) => process.stderr.write(s)
package/dist/cli.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts","../bin/cli.ts"],"sourcesContent":["import { createId, type Options } from \"./id.js\";\nimport type { Id } from \"./types.js\";\n\nexport type RunOpts = {\n argv: ReadonlyArray<string>;\n stdout: (chunk: string) => void;\n stderr: (chunk: string) => void;\n now?: Options[\"now\"];\n rng?: Options[\"rng\"];\n};\n\nexport function run(opts: RunOpts): number {\n const [subcommand, ...rest] = opts.argv;\n if (subcommand === \"generate\" || subcommand === \"g\") return runGenerate(rest, opts);\n if (subcommand === \"inspect\" || subcommand === \"i\") return runInspect(rest, opts);\n if (subcommand === undefined || subcommand === \"--help\" || subcommand === \"-h\") {\n opts.stdout(usage());\n return 0;\n }\n opts.stderr(usage());\n return 1;\n}\n\nfunction usage(): string {\n return [\n \"Usage: ids <subcommand> [args]\",\n \"\",\n \"Subcommands:\",\n \" inspect, i <id> Decode an ID and print brand, timestamp, and canonical form.\",\n \" generate, g <brand> [--count, -c N] Mint one or more canonical IDs for the given brand.\",\n \"\",\n ].join(\"\\n\");\n}\n\nfunction runInspect(args: ReadonlyArray<string>, opts: RunOpts): number {\n const [input] = args;\n if (input === undefined) {\n opts.stderr(usage());\n return 1;\n }\n const brand = input.slice(0, 3).toLowerCase();\n let codec;\n try {\n codec = createId(brand, codecOpts(opts));\n } catch (err) {\n opts.stderr((err as Error).message + \"\\n\");\n return 1;\n }\n const validation = codec[\"~standard\"].validate(input);\n if (validation.issues) {\n opts.stderr(validation.issues[0]!.message + \"\\n\");\n return 1;\n }\n const canonical = validation.value;\n const timestamp = codec.extractTimestamp(canonical);\n const nowMs = (opts.now ?? Date.now)();\n const relative = formatRelative(timestamp.getTime(), nowMs);\n const inputLine = describeInputForm(input, canonical);\n opts.stdout(\n [\n `brand: ${brand}`,\n `timestamp: ${timestamp.toISOString()} (${relative})`,\n `canonical: ${canonical}`,\n `input: ${inputLine}`,\n \"\",\n ].join(\"\\n\"),\n );\n return 0;\n}\n\nfunction describeInputForm(input: string, canonical: Id<string>): string {\n if (input === canonical) return \"canonical\";\n const notes: string[] = [];\n if (input !== input.toLowerCase()) notes.push(\"was uppercase\");\n if (/[ilo]/i.test(input.slice(4))) notes.push(\"used Crockford aliases\");\n return `not canonical (${notes.join(\" + \")})`;\n}\n\nconst msPerSecond = 1000;\nconst msPerMinute = 60 * msPerSecond;\nconst msPerHour = 60 * msPerMinute;\nconst msPerDay = 24 * msPerHour;\nconst daysPerMonth = 30.44;\nconst monthsPerYear = 12;\n\nfunction formatRelative(thenMs: number, nowMs: number): string {\n const diff = nowMs - thenMs;\n const abs = Math.abs(diff);\n const suffix = diff < 0 ? \"from now\" : \"ago\";\n\n const head = headUnits(abs);\n return head === \"\" ? \"just now\" : `${head} ${suffix}`;\n}\n\nfunction headUnits(abs: number): string {\n if (abs < msPerMinute) return \"\";\n if (abs < msPerHour) return unit(Math.round(abs / msPerMinute), \"minute\");\n if (abs < msPerDay) return unit(Math.round(abs / msPerHour), \"hour\");\n if (abs < msPerDay * daysPerMonth) return unit(Math.round(abs / msPerDay), \"day\");\n\n const totalMonths = Math.round(abs / (msPerDay * daysPerMonth));\n if (totalMonths < monthsPerYear) return unit(totalMonths, \"month\");\n\n const years = Math.floor(totalMonths / monthsPerYear);\n const months = totalMonths % monthsPerYear;\n return months === 0 ? unit(years, \"year\") : `${unit(years, \"year\")} ${unit(months, \"month\")}`;\n}\n\nfunction unit(n: number, name: string): string {\n return `${n} ${n === 1 ? name : `${name}s`}`;\n}\n\nfunction runGenerate(args: ReadonlyArray<string>, opts: RunOpts): number {\n const [brand, ...flags] = args;\n const count = parseCount(flags);\n if (typeof count === \"string\") {\n opts.stderr(count + \"\\n\");\n return 1;\n }\n let codec;\n try {\n codec = createId(brand ?? \"\", codecOpts(opts));\n } catch (err) {\n opts.stderr((err as Error).message + \"\\n\");\n return 1;\n }\n for (let i = 0; i < count; i++) opts.stdout(codec.generate() + \"\\n\");\n return 0;\n}\n\nfunction parseCount(flags: ReadonlyArray<string>): number | string {\n const idx = flags.findIndex((f) => f === \"--count\" || f === \"-c\");\n if (idx === -1) return 1;\n const raw = flags[idx + 1];\n if (raw === undefined) return \"--count requires a value\";\n if (!/^[1-9][0-9]*$/.test(raw)) return `--count must be a positive integer, got '${raw}'`;\n return Number(raw);\n}\n\nfunction codecOpts(opts: RunOpts): Partial<Options> {\n // CLI invocations are intentionally ephemeral — one codec per run, never\n // retained — so a repeated `createId(brand)` here is not the bundling/import\n // bug that the duplicate-brand warning is designed to catch.\n const o: Partial<Options> = { allowDuplicateBrand: true };\n if (opts.now !== undefined) o.now = opts.now;\n if (opts.rng !== undefined) o.rng = opts.rng;\n return o;\n}\n","#!/usr/bin/env node\nimport { run } from \"../src/cli.js\";\n\nprocess.exitCode = run({\n argv: process.argv.slice(2),\n stdout: (s) => process.stdout.write(s),\n stderr: (s) => process.stderr.write(s),\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts","../bin/cli.ts"],"sourcesContent":["import { createId, type Options } from \"./id.js\";\nimport {\n createOpaqueId,\n decodeOpaqueKey,\n encodeOpaqueKey,\n importOpaqueKey,\n type OpaqueKeyFormat,\n} from \"./opaque.js\";\nimport type { Id } from \"./types.js\";\n\nexport type RunOpts = {\n argv: ReadonlyArray<string>;\n stdout: (chunk: string) => void;\n stderr: (chunk: string) => void;\n now?: Options[\"now\"];\n rng?: Options[\"rng\"];\n /** Defaults to `process.env`. Injected in tests for `IDS_KEY`. */\n env?: Readonly<Record<string, string | undefined>>;\n};\n\nexport async function run(opts: RunOpts): Promise<number> {\n const [subcommand, ...rest] = opts.argv;\n if (subcommand === \"generate\" || subcommand === \"g\") return runGenerate(rest, opts);\n if (subcommand === \"inspect\" || subcommand === \"i\") return runInspect(rest, opts);\n if (subcommand === \"keygen\" || subcommand === \"k\") return runKeygen(rest, opts);\n if (subcommand === undefined || subcommand === \"--help\" || subcommand === \"-h\") {\n opts.stdout(usage());\n return 0;\n }\n opts.stderr(usage());\n return 1;\n}\n\nfunction usage(): string {\n return [\n \"Usage: ids <subcommand> [args]\",\n \"\",\n \"Subcommands:\",\n \" inspect, i <id> [--opaque] [--key-format hex|base64url]\",\n \" Decode an ID and print brand, timestamp, and canonical form.\",\n \" --opaque reads the AES key from IDS_KEY (hex by default; IDS_KEY_FORMAT or --key-format).\",\n \" generate, g <brand> [--count, -c N] [--opaque] [--key-format hex|base64url]\",\n \" Mint one or more canonical IDs for the given brand.\",\n \" --opaque reads the AES key from IDS_KEY (hex by default; IDS_KEY_FORMAT or --key-format).\",\n \" keygen, k [--bits 128|192|256] [--key-format hex|base64url]\",\n \" Emit a random AES key for importOpaqueKey (stdout only).\",\n \"\",\n ].join(\"\\n\");\n}\n\nfunction runInspect(args: ReadonlyArray<string>, opts: RunOpts): Promise<number> {\n const { flags, values, positionals } = splitFlags(args);\n const [input] = positionals;\n if (input === undefined) {\n opts.stderr(usage());\n return Promise.resolve(1);\n }\n const opaque = flags.has(\"--opaque\");\n const brand = input.slice(0, 3).toLowerCase();\n if (opaque) {\n const format = parseOpaqueKeyFormat(values, opts);\n if (isKeyFormatError(format)) {\n opts.stderr(format + \"\\n\");\n return Promise.resolve(1);\n }\n return runOpaqueInspect(brand, input, format, opts);\n }\n let codec;\n try {\n codec = createId(brand, codecOpts(opts));\n } catch (err) {\n opts.stderr((err as Error).message + \"\\n\");\n return Promise.resolve(1);\n }\n const validation = codec[\"~standard\"].validate(input);\n if (validation.issues) {\n opts.stderr(validation.issues[0]!.message + \"\\n\");\n return Promise.resolve(1);\n }\n const canonical = validation.value;\n const timestamp = codec.extractTimestamp(canonical);\n const nowMs = (opts.now ?? Date.now)();\n const relative = formatRelative(timestamp.getTime(), nowMs);\n const inputLine = describeInputForm(input, canonical);\n opts.stdout(\n [\n `brand: ${brand}`,\n `timestamp: ${timestamp.toISOString()} (${relative})`,\n `canonical: ${canonical}`,\n `input: ${inputLine}`,\n \"\",\n ].join(\"\\n\"),\n );\n return Promise.resolve(0);\n}\n\nasync function runOpaqueInspect(\n brand: string,\n input: string,\n format: OpaqueKeyFormat,\n opts: RunOpts,\n): Promise<number> {\n const keyResult = await loadOpaqueKey(opts, format);\n if (typeof keyResult === \"string\") {\n opts.stderr(keyResult + \"\\n\");\n return 1;\n }\n let codec;\n try {\n codec = createOpaqueId(brand, { key: keyResult, ...codecOpts(opts) });\n } catch (err) {\n opts.stderr((err as Error).message + \"\\n\");\n return 1;\n }\n const validation = codec[\"~standard\"].validate(input);\n if (validation.issues) {\n opts.stderr(validation.issues[0]!.message + \"\\n\");\n return 1;\n }\n const canonical = validation.value;\n const timestamp = await codec.extractTimestamp(canonical);\n const nowMs = (opts.now ?? Date.now)();\n const relative = formatRelative(timestamp.getTime(), nowMs);\n const inputLine = describeInputForm(input, canonical);\n opts.stderr(\n \"note: timestamp assumes IDS_KEY matches the key used at generation; a wrong key yields a plausible but incorrect timestamp\\n\",\n );\n opts.stdout(\n [\n `brand: ${brand}`,\n `timestamp: ${timestamp.toISOString()} (${relative})`,\n `canonical: ${canonical}`,\n `input: ${inputLine}`,\n \"\",\n ].join(\"\\n\"),\n );\n return 0;\n}\n\nfunction describeInputForm(input: string, canonical: Id<string>): string {\n if (input === canonical) return \"canonical\";\n const notes: string[] = [];\n if (input !== input.toLowerCase()) notes.push(\"was uppercase\");\n if (/[ilo]/i.test(input.slice(4))) notes.push(\"used Crockford aliases\");\n return `not canonical (${notes.join(\" + \")})`;\n}\n\nconst msPerSecond = 1000;\nconst msPerMinute = 60 * msPerSecond;\nconst msPerHour = 60 * msPerMinute;\nconst msPerDay = 24 * msPerHour;\nconst daysPerMonth = 30.44;\nconst monthsPerYear = 12;\n\nfunction formatRelative(thenMs: number, nowMs: number): string {\n const diff = nowMs - thenMs;\n const abs = Math.abs(diff);\n const suffix = diff < 0 ? \"from now\" : \"ago\";\n\n const head = headUnits(abs);\n return head === \"\" ? \"just now\" : `${head} ${suffix}`;\n}\n\nfunction headUnits(abs: number): string {\n if (abs < msPerMinute) return \"\";\n if (abs < msPerHour) return unit(Math.round(abs / msPerMinute), \"minute\");\n if (abs < msPerDay) return unit(Math.round(abs / msPerHour), \"hour\");\n if (abs < msPerDay * daysPerMonth) return unit(Math.round(abs / msPerDay), \"day\");\n\n const totalMonths = Math.round(abs / (msPerDay * daysPerMonth));\n if (totalMonths < monthsPerYear) return unit(totalMonths, \"month\");\n\n const years = Math.floor(totalMonths / monthsPerYear);\n const months = totalMonths % monthsPerYear;\n return months === 0 ? unit(years, \"year\") : `${unit(years, \"year\")} ${unit(months, \"month\")}`;\n}\n\nfunction unit(n: number, name: string): string {\n return `${n} ${n === 1 ? name : `${name}s`}`;\n}\n\nfunction runGenerate(args: ReadonlyArray<string>, opts: RunOpts): Promise<number> {\n const { flags, values, positionals } = splitFlags(args);\n const [brand] = positionals;\n const count = parseCount(values);\n if (typeof count === \"string\") {\n opts.stderr(count + \"\\n\");\n return Promise.resolve(1);\n }\n const opaque = flags.has(\"--opaque\");\n if (opaque) {\n const format = parseOpaqueKeyFormat(values, opts);\n if (isKeyFormatError(format)) {\n opts.stderr(format + \"\\n\");\n return Promise.resolve(1);\n }\n return runOpaqueGenerate(brand ?? \"\", count, format, opts);\n }\n let codec;\n try {\n codec = createId(brand ?? \"\", codecOpts(opts));\n } catch (err) {\n opts.stderr((err as Error).message + \"\\n\");\n return Promise.resolve(1);\n }\n for (let i = 0; i < count; i++) opts.stdout(codec.generate() + \"\\n\");\n return Promise.resolve(0);\n}\n\nasync function runOpaqueGenerate(\n brand: string,\n count: number,\n format: OpaqueKeyFormat,\n opts: RunOpts,\n): Promise<number> {\n const keyResult = await loadOpaqueKey(opts, format);\n if (typeof keyResult === \"string\") {\n opts.stderr(keyResult + \"\\n\");\n return 1;\n }\n let codec;\n try {\n codec = createOpaqueId(brand, { key: keyResult, ...codecOpts(opts) });\n } catch (err) {\n opts.stderr((err as Error).message + \"\\n\");\n return 1;\n }\n for (let i = 0; i < count; i++) opts.stdout((await codec.generate()) + \"\\n\");\n return 0;\n}\n\nfunction runKeygen(args: ReadonlyArray<string>, opts: RunOpts): Promise<number> {\n const { values } = splitFlags(args);\n const bits = parseBits(values);\n if (typeof bits === \"string\") {\n opts.stderr(bits + \"\\n\");\n return Promise.resolve(1);\n }\n const format = parseKeygenFormat(values);\n if (isKeyFormatError(format)) {\n opts.stderr(format + \"\\n\");\n return Promise.resolve(1);\n }\n const bytes = new Uint8Array(bits / 8);\n crypto.getRandomValues(bytes);\n opts.stdout(encodeOpaqueKey(bytes, format) + \"\\n\");\n return Promise.resolve(0);\n}\n\nasync function loadOpaqueKey(opts: RunOpts, format: OpaqueKeyFormat): Promise<CryptoKey | string> {\n const env = opts.env ?? process.env;\n const raw = env.IDS_KEY;\n if (raw === undefined || raw === \"\") return \"missing IDS_KEY environment variable\";\n try {\n return importOpaqueKey(decodeOpaqueKey(raw, format));\n } catch (err) {\n return (err as Error).message;\n }\n}\n\nfunction parseCount(values: Map<string, string>): number | string {\n const raw = values.get(\"--count\") ?? values.get(\"-c\");\n if (raw === undefined) return 1;\n if (raw === \"\") return \"--count requires a value\";\n if (!/^[1-9][0-9]*$/.test(raw)) return `--count must be a positive integer, got '${raw}'`;\n return Number(raw);\n}\n\nfunction parseBits(values: Map<string, string>): number | string {\n const raw = values.get(\"--bits\");\n if (raw === undefined) return 256;\n if (raw === \"\") return \"--bits requires a value\";\n if (raw === \"128\") return 128;\n if (raw === \"192\") return 192;\n if (raw === \"256\") return 256;\n return `--bits must be 128, 192, or 256, got '${raw}'`;\n}\n\nfunction isKeyFormatError(result: OpaqueKeyFormat | string): result is string {\n return result !== \"hex\" && result !== \"base64url\";\n}\n\nfunction parseKeyFormatFlag(values: Map<string, string>): OpaqueKeyFormat | string | undefined {\n const fromFlag = values.get(\"--key-format\");\n if (fromFlag === undefined) return undefined;\n if (fromFlag === \"\") return \"--key-format requires a value\";\n if (fromFlag === \"hex\" || fromFlag === \"base64url\") return fromFlag;\n return `--key-format must be hex or base64url, got '${fromFlag}'`;\n}\n\nfunction parseKeygenFormat(values: Map<string, string>): OpaqueKeyFormat | string {\n const fromFlag = parseKeyFormatFlag(values);\n if (fromFlag === undefined) return \"hex\";\n return fromFlag;\n}\n\nfunction parseOpaqueKeyFormat(\n values: Map<string, string>,\n opts: RunOpts,\n): OpaqueKeyFormat | string {\n const fromFlag = parseKeyFormatFlag(values);\n if (fromFlag !== undefined) return fromFlag;\n const env = opts.env ?? process.env;\n const fromEnv = env.IDS_KEY_FORMAT;\n if (fromEnv === undefined || fromEnv === \"\") return \"hex\";\n if (fromEnv === \"hex\" || fromEnv === \"base64url\") return fromEnv;\n return `IDS_KEY_FORMAT must be hex or base64url, got '${fromEnv}'`;\n}\n\ntype ParsedFlags = {\n flags: Set<string>;\n values: Map<string, string>;\n positionals: string[];\n};\n\nfunction splitFlagToken(arg: string): { flag: string; inlineValue: string | undefined } {\n const eq = arg.indexOf(\"=\");\n if (eq <= 0) return { flag: arg, inlineValue: undefined };\n return { flag: arg.slice(0, eq), inlineValue: arg.slice(eq + 1) };\n}\n\nfunction splitFlags(args: ReadonlyArray<string>): ParsedFlags {\n const flags = new Set<string>();\n const values = new Map<string, string>();\n const positionals: string[] = [];\n const valueFlags = new Set([\"--count\", \"-c\", \"--bits\", \"--key-format\"]);\n for (let i = 0; i < args.length; i++) {\n const raw = args[i]!;\n const { flag, inlineValue } = splitFlagToken(raw);\n if (flag === \"--opaque\") {\n flags.add(flag);\n continue;\n }\n if (valueFlags.has(flag)) {\n if (inlineValue !== undefined) {\n flags.add(flag);\n values.set(flag, inlineValue);\n continue;\n }\n const value = args[i + 1];\n if (value === undefined || value.startsWith(\"-\")) {\n values.set(flag, \"\");\n continue;\n }\n flags.add(flag);\n values.set(flag, value);\n i++;\n continue;\n }\n if (flag.startsWith(\"-\")) {\n flags.add(flag);\n continue;\n }\n positionals.push(raw);\n }\n return { flags, values, positionals };\n}\n\nfunction codecOpts(opts: RunOpts): Partial<Options> {\n // CLI invocations are intentionally ephemeral — one codec per run, never\n // retained — so a repeated `createId(brand)` here is not the bundling/import\n // bug that the duplicate-brand warning is designed to catch.\n const o: Partial<Options> = { allowDuplicateBrand: true };\n if (opts.now !== undefined) o.now = opts.now;\n if (opts.rng !== undefined) o.rng = opts.rng;\n return o;\n}\n","#!/usr/bin/env node\nimport { run } from \"../src/cli.js\";\n\nprocess.exitCode = await run({\n argv: process.argv.slice(2),\n stdout: (s) => process.stdout.write(s),\n stderr: (s) => process.stderr.write(s),\n});\n"],"mappings":"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"}
@@ -21,6 +21,12 @@ const defaultOptions = {
21
21
  };
22
22
  const randomByteLength = 10;
23
23
  const timestampBase32Length = Math.ceil(48 / 5);
24
+ /**
25
+ * Creates a codec for `brand` (three lowercase a–z characters).
26
+ *
27
+ * @param brand - Entity type brand validated once at construction.
28
+ * @param opts - Optional `now`, `rng`, and `allowDuplicateBrand` overrides.
29
+ */
24
30
  function createId(brand, opts = {}) {
25
31
  validateBrand(brand);
26
32
  registerBrand(brand, opts.allowDuplicateBrand);
@@ -73,4 +79,4 @@ function extractTimestamp(prefix, id) {
73
79
  //#endregion
74
80
  export { createId as t };
75
81
 
76
- //# sourceMappingURL=id-B4GiFKYV.mjs.map
82
+ //# sourceMappingURL=id-BlQPohZp.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id-BlQPohZp.mjs","names":[],"sources":["../src/id.ts"],"sourcesContent":["import { decodeBase32, encodeBase32 } from \"./base32.js\";\nimport { registerBrand, validateBrand } from \"./registry.js\";\nimport {\n base32CharClass,\n is,\n parse,\n payloadBase32Length,\n payloadByteLength,\n readTimestampMs,\n safeParse,\n standardValidate,\n timestampByteLength,\n writeTimestamp,\n} from \"./shared.js\";\nimport type { Id, JsonSchema, ParseResult, Prefix, StandardSchemaProps } from \"./types.js\";\n\n/**\n * Configuration options for a codec instance.\n */\nexport type Options = {\n /** Returns the current timestamp in milliseconds. Defaults to `Date.now`. */\n now: () => number;\n /** Writes random bytes into `target` for ID generation. Defaults to a `crypto.randomUUID` fast path. */\n rng: (target: Uint8Array) => void;\n /** If true, silences the duplicate-brand warning in non-production environments. */\n allowDuplicateBrand?: boolean;\n};\n\n/**\n * A brand-scoped codec for generating and validating public-facing IDs.\n *\n * Wire format: `{brand}_` plus 26 lowercase Crockford base32 characters encoding a\n * 16-byte payload (6-byte ms timestamp + 10 random bytes). IDs sort by creation\n * time in ascending order.\n *\n * For encrypted IDs, use `createOpaqueId` from `@smonn/ids/opaque`.\n */\nexport type Codec<Brand extends string> = {\n /** Produces a new canonical ID using the codec's `now` and `rng`. */\n generate(): Id<Brand>;\n /** Produces a new canonical ID with timestamp bytes from `date` and a fresh random tail. Throws on invalid dates. */\n generateAt(date: Date): Id<Brand>;\n /**\n * Strict type guard: `true` only for already-canonical strings for this brand.\n * For untrusted input, use `safeParse()` or `parse()` instead. See ADR-0003.\n */\n is(value: unknown): value is Id<Brand>;\n /**\n * Lenient parse: normalises case and Crockford aliases, returns canonical `Id<Brand>`, or throws.\n */\n parse(value: unknown): Id<Brand>;\n /**\n * Lenient parse without throwing: normalises to canonical form, or returns `{ ok: false, error }`.\n */\n safeParse(value: unknown): ParseResult<Brand>;\n /**\n * Decodes the creation `Date` from an `Id<Brand>`. Trusts the type — use `safeParse()` at boundaries first. See ADR-0002.\n */\n extractTimestamp(id: Id<Brand>): Date;\n /** Tight lower bound for any ID generated at `date` (random portion `0x00`). Throws on invalid dates. */\n minIdForTime(date: Date): Id<Brand>;\n /** Tight upper bound for any ID generated at `date` (random portion `0xff`). Throws on invalid dates. */\n maxIdForTime(date: Date): Id<Brand>;\n /** JSON Schema for the canonical wire form (`pattern` is canonical-only). */\n toJsonSchema(): JsonSchema;\n /** Standard Schema validate entry point. */\n readonly \"~standard\": StandardSchemaProps<Brand>;\n};\n\n// hex charCode → 0–15 nibble, for decoding UUIDv4 strings into bytes.\n// Covers ['0'-'9' = 48–57] and ['a'-'f' = 97–102]; UUIDs are lowercase per spec.\nconst hexCharCodeToNibble = new Uint8Array(128);\nfor (let i = 0; i < 10; i++) hexCharCodeToNibble[48 + i] = i;\nfor (let i = 0; i < 6; i++) hexCharCodeToNibble[97 + i] = 10 + i;\n\nconst defaultOptions: Options = {\n now: Date.now,\n // crypto.randomUUID is ~7× faster than crypto.getRandomValues in Node 24\n // (~84 ns vs ~610 ns for a 16-byte fill — likely because the UUID path has\n // a tight fixed-format fast path). We use the 122 random bits of a UUIDv4\n // string as our entropy source, harvesting 10 fully-random bytes from\n // positions where no version (hex 12) or variant (hex 16) bits sit.\n // String layout: \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\" — bytes 0–5 are\n // string[0..7]+string[9..12], bytes 6–9 are string[24..31].\n rng: (target) => {\n const s = crypto.randomUUID();\n target[0] =\n (hexCharCodeToNibble[s.charCodeAt(0)]! << 4) | hexCharCodeToNibble[s.charCodeAt(1)]!;\n target[1] =\n (hexCharCodeToNibble[s.charCodeAt(2)]! << 4) | hexCharCodeToNibble[s.charCodeAt(3)]!;\n target[2] =\n (hexCharCodeToNibble[s.charCodeAt(4)]! << 4) | hexCharCodeToNibble[s.charCodeAt(5)]!;\n target[3] =\n (hexCharCodeToNibble[s.charCodeAt(6)]! << 4) | hexCharCodeToNibble[s.charCodeAt(7)]!;\n target[4] =\n (hexCharCodeToNibble[s.charCodeAt(9)]! << 4) | hexCharCodeToNibble[s.charCodeAt(10)]!;\n target[5] =\n (hexCharCodeToNibble[s.charCodeAt(11)]! << 4) | hexCharCodeToNibble[s.charCodeAt(12)]!;\n target[6] =\n (hexCharCodeToNibble[s.charCodeAt(24)]! << 4) | hexCharCodeToNibble[s.charCodeAt(25)]!;\n target[7] =\n (hexCharCodeToNibble[s.charCodeAt(26)]! << 4) | hexCharCodeToNibble[s.charCodeAt(27)]!;\n target[8] =\n (hexCharCodeToNibble[s.charCodeAt(28)]! << 4) | hexCharCodeToNibble[s.charCodeAt(29)]!;\n target[9] =\n (hexCharCodeToNibble[s.charCodeAt(30)]! << 4) | hexCharCodeToNibble[s.charCodeAt(31)]!;\n },\n};\n\nconst randomByteLength = payloadByteLength - timestampByteLength;\nconst timestampBase32Length = Math.ceil((timestampByteLength * 8) / 5);\n\n/**\n * Creates a codec for `brand` (three lowercase a–z characters).\n *\n * @param brand - Entity type brand validated once at construction.\n * @param opts - Optional `now`, `rng`, and `allowDuplicateBrand` overrides.\n */\nexport function createId<Brand extends string>(\n brand: Brand,\n opts: Partial<Options> = {},\n): Codec<Brand> {\n validateBrand(brand);\n registerBrand(brand, opts.allowDuplicateBrand);\n\n const options = {\n ...defaultOptions,\n ...opts,\n } satisfies Options;\n\n const prefix: Prefix<Brand> = `${brand}_`;\n // Per-codec scratch buffer. Shared across generate(), generateAt(),\n // minIdForTime(), and maxIdForTime() — all are synchronous and overwrite both\n // the timestamp and random slices before encoding, so successive callers see\n // their own freshly-written bytes. encodeBase32 reads the buffer and\n // returns an independent string, so the caller never sees the buffer itself.\n const buffer = new Uint8Array(payloadByteLength);\n const randomView = new Uint8Array(buffer.buffer, timestampByteLength, randomByteLength);\n\n return {\n generate: () => generate(prefix, options, buffer, randomView),\n generateAt: (date: Date) => generate(prefix, options, buffer, randomView, date.getTime()),\n is: (value: unknown) => is(prefix, value),\n parse: (value: unknown) => parse(prefix, value),\n safeParse: (value: unknown) => safeParse(prefix, value),\n extractTimestamp: (id: Id<Brand>) => extractTimestamp(prefix, id),\n minIdForTime: (date: Date) => sentinelIdForTime(prefix, date, 0x00, buffer, randomView),\n maxIdForTime: (date: Date) => sentinelIdForTime(prefix, date, 0xff, buffer, randomView),\n toJsonSchema: () => toJsonSchema(brand, prefix, options, buffer, randomView),\n \"~standard\": {\n version: 1,\n vendor: \"@smonn/ids\",\n validate: (value: unknown) => standardValidate(prefix, value),\n },\n };\n}\n\nfunction toJsonSchema<Brand extends string>(\n brand: Brand,\n prefix: Prefix<Brand>,\n options: Options,\n buffer: Uint8Array,\n randomView: Uint8Array,\n): JsonSchema {\n return {\n type: \"string\",\n pattern: `^${prefix}${base32CharClass}{${payloadBase32Length}}$`,\n description: `Branded ID for '${brand}'`,\n example: generate(prefix, options, buffer, randomView),\n };\n}\n\nfunction generate<Brand extends string>(\n prefix: Prefix<Brand>,\n options: Options,\n buffer: Uint8Array,\n randomView: Uint8Array,\n ms: number = options.now(),\n): Id<Brand> {\n writeTimestamp(ms, buffer);\n options.rng(randomView);\n return (prefix + encodeBase32(buffer)) as Id<Brand>;\n}\n\nfunction sentinelIdForTime<Brand extends string>(\n prefix: Prefix<Brand>,\n date: Date,\n fill: number,\n buffer: Uint8Array,\n randomView: Uint8Array,\n): Id<Brand> {\n writeTimestamp(date.getTime(), buffer);\n randomView.fill(fill);\n return (prefix + encodeBase32(buffer)) as Id<Brand>;\n}\n\nfunction extractTimestamp<Brand extends string>(prefix: Prefix<Brand>, id: Id<Brand>): Date {\n const base32 = id.slice(prefix.length, prefix.length + timestampBase32Length);\n return new Date(readTimestampMs(decodeBase32(base32)));\n}\n"],"mappings":"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"}
package/dist/index.d.mts CHANGED
@@ -1,23 +1,54 @@
1
- import { a as StandardSchemaProps, i as ParseResult, n as JsonSchema, r as ParseError, t as Id } from "./types-Dg2j_zlO.mjs";
1
+ import { a as StandardSchemaProps, i as ParseResult, n as JsonSchema, r as ParseError, t as Id } from "./types-Bv-63YK4.mjs";
2
2
 
3
3
  //#region src/id.d.ts
4
+ /**
5
+ * Configuration options for a codec instance.
6
+ */
4
7
  type Options = {
5
- now: () => number;
6
- rng: (target: Uint8Array) => void;
8
+ /** Returns the current timestamp in milliseconds. Defaults to `Date.now`. */now: () => number; /** Writes random bytes into `target` for ID generation. Defaults to a `crypto.randomUUID` fast path. */
9
+ rng: (target: Uint8Array) => void; /** If true, silences the duplicate-brand warning in non-production environments. */
7
10
  allowDuplicateBrand?: boolean;
8
11
  };
12
+ /**
13
+ * A brand-scoped codec for generating and validating public-facing IDs.
14
+ *
15
+ * Wire format: `{brand}_` plus 26 lowercase Crockford base32 characters encoding a
16
+ * 16-byte payload (6-byte ms timestamp + 10 random bytes). IDs sort by creation
17
+ * time in ascending order.
18
+ *
19
+ * For encrypted IDs, use `createOpaqueId` from `@smonn/ids/opaque`.
20
+ */
9
21
  type Codec<Brand extends string> = {
10
- generate(): Id<Brand>;
22
+ /** Produces a new canonical ID using the codec's `now` and `rng`. */generate(): Id<Brand>; /** Produces a new canonical ID with timestamp bytes from `date` and a fresh random tail. Throws on invalid dates. */
11
23
  generateAt(date: Date): Id<Brand>;
24
+ /**
25
+ * Strict type guard: `true` only for already-canonical strings for this brand.
26
+ * For untrusted input, use `safeParse()` or `parse()` instead. See ADR-0003.
27
+ */
12
28
  is(value: unknown): value is Id<Brand>;
29
+ /**
30
+ * Lenient parse: normalises case and Crockford aliases, returns canonical `Id<Brand>`, or throws.
31
+ */
13
32
  parse(value: unknown): Id<Brand>;
33
+ /**
34
+ * Lenient parse without throwing: normalises to canonical form, or returns `{ ok: false, error }`.
35
+ */
14
36
  safeParse(value: unknown): ParseResult<Brand>;
15
- extractTimestamp(id: Id<Brand>): Date;
16
- minIdForTime(date: Date): Id<Brand>;
17
- maxIdForTime(date: Date): Id<Brand>;
18
- toJsonSchema(): JsonSchema;
37
+ /**
38
+ * Decodes the creation `Date` from an `Id<Brand>`. Trusts the type — use `safeParse()` at boundaries first. See ADR-0002.
39
+ */
40
+ extractTimestamp(id: Id<Brand>): Date; /** Tight lower bound for any ID generated at `date` (random portion `0x00`). Throws on invalid dates. */
41
+ minIdForTime(date: Date): Id<Brand>; /** Tight upper bound for any ID generated at `date` (random portion `0xff`). Throws on invalid dates. */
42
+ maxIdForTime(date: Date): Id<Brand>; /** JSON Schema for the canonical wire form (`pattern` is canonical-only). */
43
+ toJsonSchema(): JsonSchema; /** Standard Schema validate entry point. */
19
44
  readonly "~standard": StandardSchemaProps<Brand>;
20
45
  };
46
+ /**
47
+ * Creates a codec for `brand` (three lowercase a–z characters).
48
+ *
49
+ * @param brand - Entity type brand validated once at construction.
50
+ * @param opts - Optional `now`, `rng`, and `allowDuplicateBrand` overrides.
51
+ */
21
52
  declare function createId<Brand extends string>(brand: Brand, opts?: Partial<Options>): Codec<Brand>;
22
53
  //#endregion
23
54
  export { type Codec, type Id, type JsonSchema, type Options, type ParseError, type ParseResult, createId };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/id.ts"],"mappings":";;;KAgBY,OAAA;EACV,GAAA;EACA,GAAA,GAAM,MAAA,EAAQ,UAAA;EACd,mBAAA;AAAA;AAAA,KAGU,KAAA;EACV,QAAA,IAAY,EAAA,CAAG,KAAA;EACf,UAAA,CAAW,IAAA,EAAM,IAAA,GAAO,EAAA,CAAG,KAAA;EAC3B,EAAA,CAAG,KAAA,YAAiB,KAAA,IAAS,EAAA,CAAG,KAAA;EAChC,KAAA,CAAM,KAAA,YAAiB,EAAA,CAAG,KAAA;EAC1B,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;EACvC,gBAAA,CAAiB,EAAA,EAAI,EAAA,CAAG,KAAA,IAAS,IAAA;EACjC,YAAA,CAAa,IAAA,EAAM,IAAA,GAAO,EAAA,CAAG,KAAA;EAC7B,YAAA,CAAa,IAAA,EAAM,IAAA,GAAO,EAAA,CAAG,KAAA;EAC7B,YAAA,IAAgB,UAAA;EAAA,SACP,WAAA,EAAa,mBAAA,CAAoB,KAAA;AAAA;AAAA,iBA8C5B,QAAA,uBACd,KAAA,EAAO,KAAA,EACP,IAAA,GAAM,OAAA,CAAQ,OAAA,IACb,KAAA,CAAM,KAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/id.ts"],"mappings":";;;;;AAmBA;KAAY,OAAA;+EAEV,GAAA;EAEA,GAAA,GAAM,MAAA,EAAQ,UAAA;EAEd,mBAAA;AAAA;;AAAA;AAYF;;;;;;;KAAY,KAAA;uEAEV,QAAA,IAAY,EAAA,CAAG,KAAA;EAEf,UAAA,CAAW,IAAA,EAAM,IAAA,GAAO,EAAA,CAAG,KAAA;;;;;EAK3B,EAAA,CAAG,KAAA,YAAiB,KAAA,IAAS,EAAA,CAAG,KAAA;;;;EAIhC,KAAA,CAAM,KAAA,YAAiB,EAAA,CAAG,KAAA;;;;EAI1B,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;;;;EAIvC,gBAAA,CAAiB,EAAA,EAAI,EAAA,CAAG,KAAA,IAAS,IAAA;EAEjC,YAAA,CAAa,IAAA,EAAM,IAAA,GAAO,EAAA,CAAG,KAAA;EAE7B,YAAA,CAAa,IAAA,EAAM,IAAA,GAAO,EAAA,CAAG,KAAA;EAE7B,YAAA,IAAgB,UAAA;WAEP,WAAA,EAAa,mBAAA,CAAoB,KAAA;AAAA;;;;;;;iBAoD5B,QAAA,uBACd,KAAA,EAAO,KAAA,EACP,IAAA,GAAM,OAAA,CAAQ,OAAA,IACb,KAAA,CAAM,KAAA"}
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as createId } from "./id-B4GiFKYV.mjs";
1
+ import { t as createId } from "./id-BlQPohZp.mjs";
2
2
  export { createId };
@@ -0,0 +1,172 @@
1
+ import { a as readTimestampMs, c as writeTimestamp, d as decodeBase32, f as encodeBase32, i as payloadBase32Length, l as registerBrand, n as is, o as safeParse, r as parse, s as standardValidate, t as base32CharClass, u as validateBrand } from "./shared-CmbAeUdM.mjs";
2
+ //#region src/bytes.ts
3
+ const hexDigits = "0123456789abcdef";
4
+ const invalidNibble = 255;
5
+ const hexCharCodeToNibble = new Uint8Array(128).fill(invalidNibble);
6
+ for (let i = 0; i < 10; i++) hexCharCodeToNibble[48 + i] = i;
7
+ for (let i = 0; i < 6; i++) {
8
+ hexCharCodeToNibble[97 + i] = 10 + i;
9
+ hexCharCodeToNibble[65 + i] = 10 + i;
10
+ }
11
+ /** Lowercase hex encoding of raw bytes. */
12
+ function encodeHex(bytes) {
13
+ const codes = new Array(bytes.length * 2);
14
+ for (let i = 0; i < bytes.length; i++) {
15
+ const b = bytes[i];
16
+ codes[i * 2] = hexDigits.charCodeAt(b >>> 4);
17
+ codes[i * 2 + 1] = hexDigits.charCodeAt(b & 15);
18
+ }
19
+ return String.fromCharCode(...codes);
20
+ }
21
+ /** Decodes a hex string to raw bytes. Throws on non-hex input. */
22
+ function decodeHex(encoded) {
23
+ if (encoded.length % 2 !== 0) throw new Error("invalid hex");
24
+ const out = new Uint8Array(encoded.length / 2);
25
+ for (let i = 0; i < out.length; i++) {
26
+ const hiCode = encoded.charCodeAt(i * 2);
27
+ const loCode = encoded.charCodeAt(i * 2 + 1);
28
+ if (hiCode >= hexCharCodeToNibble.length || loCode >= hexCharCodeToNibble.length) throw new Error("invalid hex");
29
+ const hi = hexCharCodeToNibble[hiCode];
30
+ const lo = hexCharCodeToNibble[loCode];
31
+ if (hi === invalidNibble || lo === invalidNibble) throw new Error("invalid hex");
32
+ out[i] = hi << 4 | lo;
33
+ }
34
+ return out;
35
+ }
36
+ /** Base64url encoding without padding. */
37
+ function encodeBase64Url(bytes) {
38
+ let binary = "";
39
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
40
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
41
+ }
42
+ /** Decodes a base64url string to raw bytes. Throws on invalid input. */
43
+ function decodeBase64Url(encoded) {
44
+ const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
45
+ const pad = (4 - base64.length % 4) % 4;
46
+ const binary = atob(base64 + "=".repeat(pad));
47
+ const out = new Uint8Array(binary.length);
48
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
49
+ return out;
50
+ }
51
+ //#endregion
52
+ //#region src/opaque-key.ts
53
+ const validAesKeyByteLengths = new Set([
54
+ 16,
55
+ 24,
56
+ 32
57
+ ]);
58
+ /**
59
+ * Encodes raw AES key bytes for storage in env vars or secret managers.
60
+ *
61
+ * @param bytes - 16, 24, or 32 raw key bytes (AES-128/192/256).
62
+ * @param format - `hex` (lowercase) or `base64url`.
63
+ */
64
+ function encodeOpaqueKey(bytes, format) {
65
+ assertValidAesKeyByteLength(bytes.length);
66
+ if (format === "hex") return encodeHex(bytes);
67
+ return encodeBase64Url(bytes);
68
+ }
69
+ /**
70
+ * Decodes key material emitted by `encodeOpaqueKey` (or `ids keygen`) back to raw bytes.
71
+ *
72
+ * @param encoded - Hex or base64url string.
73
+ * @param format - Must match how the string was encoded.
74
+ */
75
+ function decodeOpaqueKey(encoded, format) {
76
+ let bytes;
77
+ if (format === "hex") {
78
+ if (encoded.length === 0 || encoded.length % 2 !== 0) throw new Error("invalid hex key: length must be a positive even number of characters");
79
+ if (!/^[0-9a-fA-F]+$/.test(encoded)) throw new Error("invalid hex key: expected [0-9a-fA-F] only");
80
+ bytes = decodeHex(encoded);
81
+ } else try {
82
+ bytes = decodeBase64Url(encoded);
83
+ } catch {
84
+ throw new Error("invalid base64url key");
85
+ }
86
+ assertValidAesKeyByteLength(bytes.length);
87
+ return bytes;
88
+ }
89
+ function assertValidAesKeyByteLength(byteLength) {
90
+ if (!validAesKeyByteLengths.has(byteLength)) throw new Error(`invalid AES key length: expected 16, 24, or 32 bytes, got ${byteLength}`);
91
+ }
92
+ //#endregion
93
+ //#region src/opaque.ts
94
+ const zeroIv = new Uint8Array(16);
95
+ const pkcsPad = 16;
96
+ function defaultRng(target) {
97
+ crypto.getRandomValues(target);
98
+ }
99
+ /**
100
+ * Imports a raw AES key for use with the Opaque codec.
101
+ *
102
+ * @param bytes - Raw key bytes (16, 24, or 32 bytes for AES-128/192/256).
103
+ */
104
+ function importOpaqueKey(bytes) {
105
+ return crypto.subtle.importKey("raw", bytes, "AES-CBC", false, ["encrypt", "decrypt"]);
106
+ }
107
+ /**
108
+ * Creates an Opaque codec for `brand` (three lowercase a–z characters).
109
+ *
110
+ * @param brand - Entity type brand validated once at construction.
111
+ * @param opts - Required `key` plus optional `now`, `rng`, and `allowDuplicateBrand` overrides.
112
+ */
113
+ function createOpaqueId(brand, opts) {
114
+ validateBrand(brand);
115
+ registerBrand(brand, opts.allowDuplicateBrand);
116
+ const key = opts.key;
117
+ const now = opts.now ?? Date.now;
118
+ const rng = opts.rng ?? defaultRng;
119
+ const prefix = `${brand}_`;
120
+ return {
121
+ generate: () => generate(prefix, key, rng, now()),
122
+ generateAt: (date) => generate(prefix, key, rng, date.getTime()),
123
+ is: (value) => is(prefix, value),
124
+ parse: (value) => parse(prefix, value),
125
+ safeParse: (value) => safeParse(prefix, value),
126
+ extractTimestamp: (id) => extractTimestamp(prefix, key, id),
127
+ toJsonSchema: () => toJsonSchema(brand, prefix),
128
+ "~standard": {
129
+ version: 1,
130
+ vendor: "@smonn/ids",
131
+ validate: (value) => standardValidate(prefix, value)
132
+ }
133
+ };
134
+ }
135
+ async function generate(prefix, key, rng, ms) {
136
+ const plaintext = new Uint8Array(16);
137
+ writeTimestamp(ms, plaintext);
138
+ rng(plaintext.subarray(6, 16));
139
+ return prefix + encodeBase32(new Uint8Array(await crypto.subtle.encrypt({
140
+ name: "AES-CBC",
141
+ iv: zeroIv
142
+ }, key, plaintext)).subarray(0, 16));
143
+ }
144
+ async function extractTimestamp(prefix, key, id) {
145
+ const c1 = decodeBase32(id.slice(prefix.length));
146
+ const c2Input = new Uint8Array(16);
147
+ for (let i = 0; i < 16; i++) c2Input[i] = pkcsPad ^ c1[i];
148
+ const c2Encrypted = new Uint8Array(await crypto.subtle.encrypt({
149
+ name: "AES-CBC",
150
+ iv: zeroIv
151
+ }, key, c2Input));
152
+ const ciphertext = new Uint8Array(32);
153
+ ciphertext.set(c1, 0);
154
+ ciphertext.set(c2Encrypted.subarray(0, 16), 16);
155
+ const plaintext = new Uint8Array(await crypto.subtle.decrypt({
156
+ name: "AES-CBC",
157
+ iv: zeroIv
158
+ }, key, ciphertext));
159
+ return new Date(readTimestampMs(plaintext));
160
+ }
161
+ function toJsonSchema(brand, prefix) {
162
+ return {
163
+ type: "string",
164
+ pattern: `^${prefix}${base32CharClass}{${payloadBase32Length}}$`,
165
+ description: `Branded ID for '${brand}'`,
166
+ example: prefix + "0".repeat(payloadBase32Length)
167
+ };
168
+ }
169
+ //#endregion
170
+ export { encodeOpaqueKey as i, importOpaqueKey as n, decodeOpaqueKey as r, createOpaqueId as t };
171
+
172
+ //# sourceMappingURL=opaque-ChnxvPm5.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opaque-ChnxvPm5.mjs","names":[],"sources":["../src/bytes.ts","../src/opaque-key.ts","../src/opaque.ts"],"sourcesContent":["const hexDigits = \"0123456789abcdef\";\n\nconst invalidNibble = 0xff;\nconst hexCharCodeToNibble = new Uint8Array(128).fill(invalidNibble);\nfor (let i = 0; i < 10; i++) hexCharCodeToNibble[48 + i] = i;\nfor (let i = 0; i < 6; i++) {\n hexCharCodeToNibble[97 + i] = 10 + i;\n hexCharCodeToNibble[65 + i] = 10 + i;\n}\n\n/** Lowercase hex encoding of raw bytes. */\nexport function encodeHex(bytes: Uint8Array): string {\n // oxlint-disable-next-line no-new-array\n const codes = new Array<number>(bytes.length * 2);\n for (let i = 0; i < bytes.length; i++) {\n const b = bytes[i]!;\n codes[i * 2] = hexDigits.charCodeAt(b >>> 4);\n codes[i * 2 + 1] = hexDigits.charCodeAt(b & 0x0f);\n }\n return String.fromCharCode(...codes);\n}\n\n/** Decodes a hex string to raw bytes. Throws on non-hex input. */\nexport function decodeHex(encoded: string): Uint8Array {\n if (encoded.length % 2 !== 0) throw new Error(\"invalid hex\");\n const out = new Uint8Array(encoded.length / 2);\n for (let i = 0; i < out.length; i++) {\n const hiCode = encoded.charCodeAt(i * 2);\n const loCode = encoded.charCodeAt(i * 2 + 1);\n if (hiCode >= hexCharCodeToNibble.length || loCode >= hexCharCodeToNibble.length) {\n throw new Error(\"invalid hex\");\n }\n const hi = hexCharCodeToNibble[hiCode]!;\n const lo = hexCharCodeToNibble[loCode]!;\n if (hi === invalidNibble || lo === invalidNibble) {\n throw new Error(\"invalid hex\");\n }\n out[i] = (hi << 4) | lo;\n }\n return out;\n}\n\n/** Base64url encoding without padding. */\nexport function encodeBase64Url(bytes: Uint8Array): string {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** Decodes a base64url string to raw bytes. Throws on invalid input. */\nexport function decodeBase64Url(encoded: string): Uint8Array {\n const base64 = encoded.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const pad = (4 - (base64.length % 4)) % 4;\n const binary = atob(base64 + \"=\".repeat(pad));\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n}\n","import { decodeBase64Url, decodeHex, encodeBase64Url, encodeHex } from \"./bytes.js\";\n\n/** Wire encoding for opaque AES key material (not Crockford base32). */\nexport type OpaqueKeyFormat = \"hex\" | \"base64url\";\n\nconst validAesKeyByteLengths = new Set([16, 24, 32]);\n\n/**\n * Encodes raw AES key bytes for storage in env vars or secret managers.\n *\n * @param bytes - 16, 24, or 32 raw key bytes (AES-128/192/256).\n * @param format - `hex` (lowercase) or `base64url`.\n */\nexport function encodeOpaqueKey(bytes: Uint8Array, format: OpaqueKeyFormat): string {\n assertValidAesKeyByteLength(bytes.length);\n if (format === \"hex\") return encodeHex(bytes);\n return encodeBase64Url(bytes);\n}\n\n/**\n * Decodes key material emitted by `encodeOpaqueKey` (or `ids keygen`) back to raw bytes.\n *\n * @param encoded - Hex or base64url string.\n * @param format - Must match how the string was encoded.\n */\nexport function decodeOpaqueKey(encoded: string, format: OpaqueKeyFormat): Uint8Array {\n let bytes: Uint8Array;\n if (format === \"hex\") {\n if (encoded.length === 0 || encoded.length % 2 !== 0) {\n throw new Error(\"invalid hex key: length must be a positive even number of characters\");\n }\n if (!/^[0-9a-fA-F]+$/.test(encoded)) {\n throw new Error(\"invalid hex key: expected [0-9a-fA-F] only\");\n }\n bytes = decodeHex(encoded);\n } else {\n try {\n bytes = decodeBase64Url(encoded);\n } catch {\n throw new Error(\"invalid base64url key\");\n }\n }\n assertValidAesKeyByteLength(bytes.length);\n return bytes;\n}\n\nfunction assertValidAesKeyByteLength(byteLength: number): void {\n if (!validAesKeyByteLengths.has(byteLength)) {\n throw new Error(`invalid AES key length: expected 16, 24, or 32 bytes, got ${byteLength}`);\n }\n}\n","import { decodeBase32, encodeBase32 } from \"./base32.js\";\nimport { registerBrand, validateBrand } from \"./registry.js\";\nimport {\n base32CharClass,\n is,\n parse,\n payloadBase32Length,\n payloadByteLength,\n readTimestampMs,\n safeParse,\n standardValidate,\n timestampByteLength,\n writeTimestamp,\n} from \"./shared.js\";\nimport type { Id, JsonSchema, ParseResult, Prefix, StandardSchemaProps } from \"./types.js\";\n\nexport { decodeOpaqueKey, encodeOpaqueKey, type OpaqueKeyFormat } from \"./opaque-key.js\";\n\n/**\n * Configuration options for an Opaque codec instance.\n */\nexport type OpaqueOptions = {\n /** AES-CBC key used for encryption and decryption. */\n key: CryptoKey;\n /** Returns the current timestamp in milliseconds. Defaults to `Date.now`. */\n now: () => number;\n /** Writes random bytes into `target` for ID generation. Defaults to `crypto.getRandomValues`. */\n rng: (target: Uint8Array) => void;\n /** If true, silences the duplicate-brand warning in non-production environments. */\n allowDuplicateBrand?: boolean;\n};\n\n/**\n * A brand-scoped codec for generating and validating encrypted (opaque) IDs.\n *\n * Same wire shape as the Timestamp codec (`{brand}_` + 26 base32 chars) but the\n * payload is AES-CBC encrypted. `generate`, `generateAt`, and `extractTimestamp`\n * are async; parsing methods are sync. No `minIdForTime` / `maxIdForTime` —\n * encrypted payloads do not sort by creation time.\n */\nexport type OpaqueCodec<Brand extends string> = {\n /** Produces a new canonical encrypted ID using the codec's `now` and `rng`. */\n generate(): Promise<Id<Brand>>;\n /** Produces a new canonical encrypted ID with timestamp bytes from `date`. Throws on invalid dates. */\n generateAt(date: Date): Promise<Id<Brand>>;\n /**\n * Strict type guard: `true` only for already-canonical strings for this brand.\n * For untrusted input, use `safeParse()` or `parse()` instead. See ADR-0003.\n */\n is(value: unknown): value is Id<Brand>;\n /**\n * Lenient parse: normalises case and Crockford aliases, returns canonical `Id<Brand>`, or throws.\n */\n parse(value: unknown): Id<Brand>;\n /**\n * Lenient parse without throwing: normalises to canonical form, or returns `{ ok: false, error }`.\n */\n safeParse(value: unknown): ParseResult<Brand>;\n /**\n * Decrypts and decodes the creation `Date` from an `Id<Brand>`. Trusts the type — use `safeParse()` at boundaries first. See ADR-0002.\n */\n extractTimestamp(id: Id<Brand>): Promise<Date>;\n /** JSON Schema for the canonical wire form (`example` is a structural placeholder). */\n toJsonSchema(): JsonSchema;\n /** Standard Schema validate entry point. */\n readonly \"~standard\": StandardSchemaProps<Brand>;\n};\n\nconst zeroIv = new Uint8Array(payloadByteLength);\nconst pkcsPad = 0x10;\n\nfunction defaultRng(target: Uint8Array): void {\n crypto.getRandomValues(target as Uint8Array<ArrayBuffer>);\n}\n\n/**\n * Imports a raw AES key for use with the Opaque codec.\n *\n * @param bytes - Raw key bytes (16, 24, or 32 bytes for AES-128/192/256).\n */\nexport function importOpaqueKey(bytes: Uint8Array): Promise<CryptoKey> {\n return crypto.subtle.importKey(\"raw\", bytes as Uint8Array<ArrayBuffer>, \"AES-CBC\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n}\n\n/**\n * Creates an Opaque codec for `brand` (three lowercase a–z characters).\n *\n * @param brand - Entity type brand validated once at construction.\n * @param opts - Required `key` plus optional `now`, `rng`, and `allowDuplicateBrand` overrides.\n */\nexport function createOpaqueId<Brand extends string>(\n brand: Brand,\n opts: { key: CryptoKey } & Partial<Omit<OpaqueOptions, \"key\">>,\n): OpaqueCodec<Brand> {\n validateBrand(brand);\n registerBrand(brand, opts.allowDuplicateBrand);\n\n const key = opts.key;\n const now = opts.now ?? Date.now;\n const rng = opts.rng ?? defaultRng;\n const prefix: Prefix<Brand> = `${brand}_`;\n\n return {\n generate: () => generate(prefix, key, rng, now()),\n generateAt: (date: Date) => generate(prefix, key, rng, date.getTime()),\n is: (value: unknown) => is(prefix, value),\n parse: (value: unknown) => parse(prefix, value),\n safeParse: (value: unknown) => safeParse(prefix, value),\n extractTimestamp: (id: Id<Brand>) => extractTimestamp(prefix, key, id),\n toJsonSchema: () => toJsonSchema(brand, prefix),\n \"~standard\": {\n version: 1,\n vendor: \"@smonn/ids\",\n validate: (value: unknown) => standardValidate(prefix, value),\n },\n };\n}\n\n// Per-call buffers, unlike id.ts's codec-shared scratch. Reuse would be safe\n// (subtle.encrypt/decrypt snapshot inputs synchronously, per WebCrypto step 2\n// before the Promise returns) but subtle dominates this path — the allocation\n// is <1% of total cost, not worth pinning the design to that spec detail.\nasync function generate<Brand extends string>(\n prefix: Prefix<Brand>,\n key: CryptoKey,\n rng: (target: Uint8Array) => void,\n ms: number,\n): Promise<Id<Brand>> {\n const plaintext = new Uint8Array(payloadByteLength);\n writeTimestamp(ms, plaintext);\n rng(plaintext.subarray(timestampByteLength, payloadByteLength));\n const encrypted = new Uint8Array(\n await crypto.subtle.encrypt({ name: \"AES-CBC\", iv: zeroIv }, key, plaintext),\n );\n return (prefix + encodeBase32(encrypted.subarray(0, payloadByteLength))) as Id<Brand>;\n}\n\nasync function extractTimestamp<Brand extends string>(\n prefix: Prefix<Brand>,\n key: CryptoKey,\n id: Id<Brand>,\n): Promise<Date> {\n const c1 = decodeBase32(id.slice(prefix.length));\n // Reconstruct C2 = AES_K(P2 XOR C1) where P2 is the PKCS#7 pad block (0x10×16).\n // CBC encrypt of (P2 XOR C1) with IV=0 yields AES_K(P2 XOR C1) as the first 16 bytes.\n const c2Input = new Uint8Array(payloadByteLength);\n for (let i = 0; i < payloadByteLength; i++) c2Input[i] = pkcsPad ^ c1[i]!;\n const c2Encrypted = new Uint8Array(\n await crypto.subtle.encrypt({ name: \"AES-CBC\", iv: zeroIv }, key, c2Input),\n );\n const ciphertext = new Uint8Array(payloadByteLength * 2);\n ciphertext.set(c1, 0);\n ciphertext.set(c2Encrypted.subarray(0, payloadByteLength), payloadByteLength);\n const plaintext = new Uint8Array(\n await crypto.subtle.decrypt({ name: \"AES-CBC\", iv: zeroIv }, key, ciphertext),\n );\n return new Date(readTimestampMs(plaintext));\n}\n\nfunction toJsonSchema<Brand extends string>(brand: Brand, prefix: Prefix<Brand>): JsonSchema {\n return {\n type: \"string\",\n pattern: `^${prefix}${base32CharClass}{${payloadBase32Length}}$`,\n description: `Branded ID for '${brand}'`,\n // The Opaque codec cannot synchronously produce a real example (encrypt is\n // async). A deterministic structurally-valid placeholder satisfies the\n // JSON Schema contract without requiring the key at schema time.\n example: prefix + \"0\".repeat(payloadBase32Length),\n };\n}\n"],"mappings":"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"}
package/dist/opaque.d.mts CHANGED
@@ -1,26 +1,79 @@
1
- import { a as StandardSchemaProps, i as ParseResult, n as JsonSchema, t as Id } from "./types-Dg2j_zlO.mjs";
1
+ import { a as StandardSchemaProps, i as ParseResult, n as JsonSchema, t as Id } from "./types-Bv-63YK4.mjs";
2
2
 
3
+ //#region src/opaque-key.d.ts
4
+ /** Wire encoding for opaque AES key material (not Crockford base32). */
5
+ type OpaqueKeyFormat = "hex" | "base64url";
6
+ /**
7
+ * Encodes raw AES key bytes for storage in env vars or secret managers.
8
+ *
9
+ * @param bytes - 16, 24, or 32 raw key bytes (AES-128/192/256).
10
+ * @param format - `hex` (lowercase) or `base64url`.
11
+ */
12
+ declare function encodeOpaqueKey(bytes: Uint8Array, format: OpaqueKeyFormat): string;
13
+ /**
14
+ * Decodes key material emitted by `encodeOpaqueKey` (or `ids keygen`) back to raw bytes.
15
+ *
16
+ * @param encoded - Hex or base64url string.
17
+ * @param format - Must match how the string was encoded.
18
+ */
19
+ declare function decodeOpaqueKey(encoded: string, format: OpaqueKeyFormat): Uint8Array;
20
+ //#endregion
3
21
  //#region src/opaque.d.ts
22
+ /**
23
+ * Configuration options for an Opaque codec instance.
24
+ */
4
25
  type OpaqueOptions = {
5
- key: CryptoKey;
6
- now: () => number;
7
- rng: (target: Uint8Array) => void;
26
+ /** AES-CBC key used for encryption and decryption. */key: CryptoKey; /** Returns the current timestamp in milliseconds. Defaults to `Date.now`. */
27
+ now: () => number; /** Writes random bytes into `target` for ID generation. Defaults to `crypto.getRandomValues`. */
28
+ rng: (target: Uint8Array) => void; /** If true, silences the duplicate-brand warning in non-production environments. */
8
29
  allowDuplicateBrand?: boolean;
9
30
  };
31
+ /**
32
+ * A brand-scoped codec for generating and validating encrypted (opaque) IDs.
33
+ *
34
+ * Same wire shape as the Timestamp codec (`{brand}_` + 26 base32 chars) but the
35
+ * payload is AES-CBC encrypted. `generate`, `generateAt`, and `extractTimestamp`
36
+ * are async; parsing methods are sync. No `minIdForTime` / `maxIdForTime` —
37
+ * encrypted payloads do not sort by creation time.
38
+ */
10
39
  type OpaqueCodec<Brand extends string> = {
11
- generate(): Promise<Id<Brand>>;
40
+ /** Produces a new canonical encrypted ID using the codec's `now` and `rng`. */generate(): Promise<Id<Brand>>; /** Produces a new canonical encrypted ID with timestamp bytes from `date`. Throws on invalid dates. */
12
41
  generateAt(date: Date): Promise<Id<Brand>>;
42
+ /**
43
+ * Strict type guard: `true` only for already-canonical strings for this brand.
44
+ * For untrusted input, use `safeParse()` or `parse()` instead. See ADR-0003.
45
+ */
13
46
  is(value: unknown): value is Id<Brand>;
47
+ /**
48
+ * Lenient parse: normalises case and Crockford aliases, returns canonical `Id<Brand>`, or throws.
49
+ */
14
50
  parse(value: unknown): Id<Brand>;
51
+ /**
52
+ * Lenient parse without throwing: normalises to canonical form, or returns `{ ok: false, error }`.
53
+ */
15
54
  safeParse(value: unknown): ParseResult<Brand>;
16
- extractTimestamp(id: Id<Brand>): Promise<Date>;
17
- toJsonSchema(): JsonSchema;
55
+ /**
56
+ * Decrypts and decodes the creation `Date` from an `Id<Brand>`. Trusts the type — use `safeParse()` at boundaries first. See ADR-0002.
57
+ */
58
+ extractTimestamp(id: Id<Brand>): Promise<Date>; /** JSON Schema for the canonical wire form (`example` is a structural placeholder). */
59
+ toJsonSchema(): JsonSchema; /** Standard Schema validate entry point. */
18
60
  readonly "~standard": StandardSchemaProps<Brand>;
19
61
  };
62
+ /**
63
+ * Imports a raw AES key for use with the Opaque codec.
64
+ *
65
+ * @param bytes - Raw key bytes (16, 24, or 32 bytes for AES-128/192/256).
66
+ */
20
67
  declare function importOpaqueKey(bytes: Uint8Array): Promise<CryptoKey>;
68
+ /**
69
+ * Creates an Opaque codec for `brand` (three lowercase a–z characters).
70
+ *
71
+ * @param brand - Entity type brand validated once at construction.
72
+ * @param opts - Required `key` plus optional `now`, `rng`, and `allowDuplicateBrand` overrides.
73
+ */
21
74
  declare function createOpaqueId<Brand extends string>(brand: Brand, opts: {
22
75
  key: CryptoKey;
23
76
  } & Partial<Omit<OpaqueOptions, "key">>): OpaqueCodec<Brand>;
24
77
  //#endregion
25
- export { OpaqueCodec, OpaqueOptions, createOpaqueId, importOpaqueKey };
78
+ export { OpaqueCodec, type OpaqueKeyFormat, OpaqueOptions, createOpaqueId, decodeOpaqueKey, encodeOpaqueKey, importOpaqueKey };
26
79
  //# sourceMappingURL=opaque.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"opaque.d.mts","names":[],"sources":["../src/opaque.ts"],"mappings":";;;KAgBY,aAAA;EACV,GAAA,EAAK,SAAA;EACL,GAAA;EACA,GAAA,GAAM,MAAA,EAAQ,UAAA;EACd,mBAAA;AAAA;AAAA,KAGU,WAAA;EACV,QAAA,IAAY,OAAA,CAAQ,EAAA,CAAG,KAAA;EACvB,UAAA,CAAW,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,EAAA,CAAG,KAAA;EACnC,EAAA,CAAG,KAAA,YAAiB,KAAA,IAAS,EAAA,CAAG,KAAA;EAChC,KAAA,CAAM,KAAA,YAAiB,EAAA,CAAG,KAAA;EAC1B,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;EACvC,gBAAA,CAAiB,EAAA,EAAI,EAAA,CAAG,KAAA,IAAS,OAAA,CAAQ,IAAA;EACzC,YAAA,IAAgB,UAAA;EAAA,SACP,WAAA,EAAa,mBAAA,CAAoB,KAAA;AAAA;AAAA,iBAU5B,eAAA,CAAgB,KAAA,EAAO,UAAA,GAAa,OAAA,CAAQ,SAAA;AAAA,iBAO5C,cAAA,uBACd,KAAA,EAAO,KAAA,EACP,IAAA;EAAQ,GAAA,EAAK,SAAA;AAAA,IAAc,OAAA,CAAQ,IAAA,CAAK,aAAA,YACvC,WAAA,CAAY,KAAA"}
1
+ {"version":3,"file":"opaque.d.mts","names":[],"sources":["../src/opaque-key.ts","../src/opaque.ts"],"mappings":";;;;KAGY,eAAA;;AAAZ;;;;AAAY;iBAUI,eAAA,CAAgB,KAAA,EAAO,UAAA,EAAY,MAAA,EAAQ,eAAA;;;;;;;iBAY3C,eAAA,CAAgB,OAAA,UAAiB,MAAA,EAAQ,eAAA,GAAkB,UAAA;;;;AAtB3E;;KCkBY,aAAA;EDlBA,sDCoBV,GAAA,EAAK,SAAA,EDVP;ECYE,GAAA;EAEA,GAAA,GAAM,MAAA,EAAQ,UAAA;EAEd,mBAAA;AAAA;;;ADhByD;AAY3D;;;;;KCeY,WAAA;iFAEV,QAAA,IAAY,OAAA,CAAQ,EAAA,CAAG,KAAA,IDjBkD;ECmBzE,UAAA,CAAW,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,EAAA,CAAG,KAAA;;;;AAvBrC;EA4BE,EAAA,CAAG,KAAA,YAAiB,KAAA,IAAS,EAAA,CAAG,KAAA;;;;EAIhC,KAAA,CAAM,KAAA,YAAiB,EAAA,CAAG,KAAA;;;;EAI1B,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;;;AA5BvC;EAgCA,gBAAA,CAAiB,EAAA,EAAI,EAAA,CAAG,KAAA,IAAS,OAAA,CAAQ,IAAA,GArB/B;EAuBV,YAAA,IAAgB,UAAA;WAEP,WAAA,EAAa,mBAAA,CAAoB,KAAA;AAAA;;;;;;iBAe5B,eAAA,CAAgB,KAAA,EAAO,UAAA,GAAa,OAAA,CAAQ,SAAA;;;;;;;iBAa5C,cAAA,uBACd,KAAA,EAAO,KAAA,EACP,IAAA;EAAQ,GAAA,EAAK,SAAA;AAAA,IAAc,OAAA,CAAQ,IAAA,CAAK,aAAA,YACvC,WAAA,CAAY,KAAA"}
package/dist/opaque.mjs CHANGED
@@ -1,70 +1,2 @@
1
- import { a as readTimestampMs, c as writeTimestamp, d as decodeBase32, f as encodeBase32, i as payloadBase32Length, l as registerBrand, n as is, o as safeParse, r as parse, s as standardValidate, t as base32CharClass, u as validateBrand } from "./shared-CmbAeUdM.mjs";
2
- //#region src/opaque.ts
3
- const zeroIv = new Uint8Array(16);
4
- const pkcsPad = 16;
5
- function defaultRng(target) {
6
- crypto.getRandomValues(target);
7
- }
8
- function importOpaqueKey(bytes) {
9
- return crypto.subtle.importKey("raw", bytes, "AES-CBC", false, ["encrypt", "decrypt"]);
10
- }
11
- function createOpaqueId(brand, opts) {
12
- validateBrand(brand);
13
- registerBrand(brand, opts.allowDuplicateBrand);
14
- const key = opts.key;
15
- const now = opts.now ?? Date.now;
16
- const rng = opts.rng ?? defaultRng;
17
- const prefix = `${brand}_`;
18
- return {
19
- generate: () => generate(prefix, key, rng, now()),
20
- generateAt: (date) => generate(prefix, key, rng, date.getTime()),
21
- is: (value) => is(prefix, value),
22
- parse: (value) => parse(prefix, value),
23
- safeParse: (value) => safeParse(prefix, value),
24
- extractTimestamp: (id) => extractTimestamp(prefix, key, id),
25
- toJsonSchema: () => toJsonSchema(brand, prefix),
26
- "~standard": {
27
- version: 1,
28
- vendor: "@smonn/ids",
29
- validate: (value) => standardValidate(prefix, value)
30
- }
31
- };
32
- }
33
- async function generate(prefix, key, rng, ms) {
34
- const plaintext = new Uint8Array(16);
35
- writeTimestamp(ms, plaintext);
36
- rng(plaintext.subarray(6, 16));
37
- return prefix + encodeBase32(new Uint8Array(await crypto.subtle.encrypt({
38
- name: "AES-CBC",
39
- iv: zeroIv
40
- }, key, plaintext)).subarray(0, 16));
41
- }
42
- async function extractTimestamp(prefix, key, id) {
43
- const c1 = decodeBase32(id.slice(prefix.length));
44
- const c2Input = new Uint8Array(16);
45
- for (let i = 0; i < 16; i++) c2Input[i] = pkcsPad ^ c1[i];
46
- const c2Encrypted = new Uint8Array(await crypto.subtle.encrypt({
47
- name: "AES-CBC",
48
- iv: zeroIv
49
- }, key, c2Input));
50
- const ciphertext = new Uint8Array(32);
51
- ciphertext.set(c1, 0);
52
- ciphertext.set(c2Encrypted.subarray(0, 16), 16);
53
- const plaintext = new Uint8Array(await crypto.subtle.decrypt({
54
- name: "AES-CBC",
55
- iv: zeroIv
56
- }, key, ciphertext));
57
- return new Date(readTimestampMs(plaintext));
58
- }
59
- function toJsonSchema(brand, prefix) {
60
- return {
61
- type: "string",
62
- pattern: `^${prefix}${base32CharClass}{${payloadBase32Length}}$`,
63
- description: `Branded ID for '${brand}'`,
64
- example: prefix + "0".repeat(payloadBase32Length)
65
- };
66
- }
67
- //#endregion
68
- export { createOpaqueId, importOpaqueKey };
69
-
70
- //# sourceMappingURL=opaque.mjs.map
1
+ import { i as encodeOpaqueKey, n as importOpaqueKey, r as decodeOpaqueKey, t as createOpaqueId } from "./opaque-ChnxvPm5.mjs";
2
+ export { createOpaqueId, decodeOpaqueKey, encodeOpaqueKey, importOpaqueKey };
@@ -1,9 +1,13 @@
1
1
  //#region src/types.d.ts
2
+ /** The brand plus trailing separator — e.g. `usr_` for brand `usr`. */
2
3
  type Prefix<Brand extends string> = `${Brand}_`;
4
+ /** A canonical branded ID string for `Brand`. Produced by `generate()` and `safeParse()`. */
3
5
  type Id<Brand extends string> = `${Prefix<Brand>}${string}` & {
4
6
  readonly __brand: Brand;
5
7
  };
8
+ /** Parse failure reason returned by `safeParse()`. */
6
9
  type ParseError = "not_string" | "invalid_prefix" | "invalid_base32";
10
+ /** Result of `safeParse()`: canonical `Id<Brand>` or a `ParseError`. */
7
11
  type ParseResult<Brand extends string> = {
8
12
  ok: true;
9
13
  id: Id<Brand>;
@@ -11,12 +15,14 @@ type ParseResult<Brand extends string> = {
11
15
  ok: false;
12
16
  error: ParseError;
13
17
  };
18
+ /** JSON Schema for the canonical wire form returned by `toJsonSchema()`. */
14
19
  type JsonSchema = {
15
20
  readonly type: "string";
16
21
  readonly pattern: string;
17
22
  readonly description: string;
18
23
  readonly example: string;
19
24
  };
25
+ /** Standard Schema validate entry point exposed on `Codec["~standard"]`. */
20
26
  type StandardSchemaProps<Brand extends string> = {
21
27
  readonly version: 1;
22
28
  readonly vendor: "@smonn/ids";
@@ -37,4 +43,4 @@ type StandardSchemaProps<Brand extends string> = {
37
43
  };
38
44
  //#endregion
39
45
  export { StandardSchemaProps as a, ParseResult as i, JsonSchema as n, ParseError as r, Id as t };
40
- //# sourceMappingURL=types-Dg2j_zlO.d.mts.map
46
+ //# sourceMappingURL=types-Bv-63YK4.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-Bv-63YK4.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;KACY,MAAA,4BAAkC,KAAA;;KAGlC,EAAA,4BAA8B,MAAA,CAAO,KAAA;EAAA,SACtC,OAAA,EAAS,KAAA;AAAA;AADpB;AAAA,KAKY,UAAA;;KAGA,WAAA;EACN,EAAA;EAAU,EAAA,EAAI,EAAA,CAAG,KAAA;AAAA;EACjB,EAAA;EAAW,KAAA,EAAO,UAAA;AAAA;;KAGZ,UAAA;EAAA,SACD,IAAA;EAAA,SACA,OAAA;EAAA,SACA,WAAA;EAAA,SACA,OAAA;AAAA;;KAIC,mBAAA;EAAA,SACD,OAAA;EAAA,SACA,MAAA;EAAA,SACA,QAAA,GACP,KAAA,WACA,OAAA;IAAA,SAAqB,cAAA,GAAiB,MAAA;EAAA;IAAA,SAEzB,KAAA,EAAO,EAAA,CAAG,KAAA;IAAA,SAAiB,MAAA;EAAA;IAAA,SAC3B,MAAA,EAAQ,aAAA;MAAA,SAAyB,OAAA;IAAA;EAAA;EAAA,SACvC,KAAA;IAAA,SAAmB,KAAA;IAAA,SAAyB,MAAA,EAAQ,EAAA,CAAG,KAAA;EAAA;AAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smonn/ids",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "license": "MIT",
5
5
  "author": "Simon Ingeson (https://github.com/smonn)",
6
6
  "repository": {
@@ -1 +0,0 @@
1
- {"version":3,"file":"id-B4GiFKYV.mjs","names":[],"sources":["../src/id.ts"],"sourcesContent":["import { decodeBase32, encodeBase32 } from \"./base32.js\";\nimport { registerBrand, validateBrand } from \"./registry.js\";\nimport {\n base32CharClass,\n is,\n parse,\n payloadBase32Length,\n payloadByteLength,\n readTimestampMs,\n safeParse,\n standardValidate,\n timestampByteLength,\n writeTimestamp,\n} from \"./shared.js\";\nimport type { Id, JsonSchema, ParseResult, Prefix, StandardSchemaProps } from \"./types.js\";\n\nexport type Options = {\n now: () => number;\n rng: (target: Uint8Array) => void;\n allowDuplicateBrand?: boolean;\n};\n\nexport type Codec<Brand extends string> = {\n generate(): Id<Brand>;\n generateAt(date: Date): Id<Brand>;\n is(value: unknown): value is Id<Brand>;\n parse(value: unknown): Id<Brand>;\n safeParse(value: unknown): ParseResult<Brand>;\n extractTimestamp(id: Id<Brand>): Date;\n minIdForTime(date: Date): Id<Brand>;\n maxIdForTime(date: Date): Id<Brand>;\n toJsonSchema(): JsonSchema;\n readonly \"~standard\": StandardSchemaProps<Brand>;\n};\n\n// hex charCode → 0–15 nibble, for decoding UUIDv4 strings into bytes.\n// Covers ['0'-'9' = 48–57] and ['a'-'f' = 97–102]; UUIDs are lowercase per spec.\nconst hexCharCodeToNibble = new Uint8Array(128);\nfor (let i = 0; i < 10; i++) hexCharCodeToNibble[48 + i] = i;\nfor (let i = 0; i < 6; i++) hexCharCodeToNibble[97 + i] = 10 + i;\n\nconst defaultOptions: Options = {\n now: Date.now,\n // crypto.randomUUID is ~7× faster than crypto.getRandomValues in Node 24\n // (~84 ns vs ~610 ns for a 16-byte fill — likely because the UUID path has\n // a tight fixed-format fast path). We use the 122 random bits of a UUIDv4\n // string as our entropy source, harvesting 10 fully-random bytes from\n // positions where no version (hex 12) or variant (hex 16) bits sit.\n // String layout: \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\" — bytes 0–5 are\n // string[0..7]+string[9..12], bytes 6–9 are string[24..31].\n rng: (target) => {\n const s = crypto.randomUUID();\n target[0] =\n (hexCharCodeToNibble[s.charCodeAt(0)]! << 4) | hexCharCodeToNibble[s.charCodeAt(1)]!;\n target[1] =\n (hexCharCodeToNibble[s.charCodeAt(2)]! << 4) | hexCharCodeToNibble[s.charCodeAt(3)]!;\n target[2] =\n (hexCharCodeToNibble[s.charCodeAt(4)]! << 4) | hexCharCodeToNibble[s.charCodeAt(5)]!;\n target[3] =\n (hexCharCodeToNibble[s.charCodeAt(6)]! << 4) | hexCharCodeToNibble[s.charCodeAt(7)]!;\n target[4] =\n (hexCharCodeToNibble[s.charCodeAt(9)]! << 4) | hexCharCodeToNibble[s.charCodeAt(10)]!;\n target[5] =\n (hexCharCodeToNibble[s.charCodeAt(11)]! << 4) | hexCharCodeToNibble[s.charCodeAt(12)]!;\n target[6] =\n (hexCharCodeToNibble[s.charCodeAt(24)]! << 4) | hexCharCodeToNibble[s.charCodeAt(25)]!;\n target[7] =\n (hexCharCodeToNibble[s.charCodeAt(26)]! << 4) | hexCharCodeToNibble[s.charCodeAt(27)]!;\n target[8] =\n (hexCharCodeToNibble[s.charCodeAt(28)]! << 4) | hexCharCodeToNibble[s.charCodeAt(29)]!;\n target[9] =\n (hexCharCodeToNibble[s.charCodeAt(30)]! << 4) | hexCharCodeToNibble[s.charCodeAt(31)]!;\n },\n};\n\nconst randomByteLength = payloadByteLength - timestampByteLength;\nconst timestampBase32Length = Math.ceil((timestampByteLength * 8) / 5);\n\nexport function createId<Brand extends string>(\n brand: Brand,\n opts: Partial<Options> = {},\n): Codec<Brand> {\n validateBrand(brand);\n registerBrand(brand, opts.allowDuplicateBrand);\n\n const options = {\n ...defaultOptions,\n ...opts,\n } satisfies Options;\n\n const prefix: Prefix<Brand> = `${brand}_`;\n // Per-codec scratch buffer. Shared across generate(), generateAt(),\n // minIdForTime(), and maxIdForTime() — all are synchronous and overwrite both\n // the timestamp and random slices before encoding, so successive callers see\n // their own freshly-written bytes. encodeBase32 reads the buffer and\n // returns an independent string, so the caller never sees the buffer itself.\n const buffer = new Uint8Array(payloadByteLength);\n const randomView = new Uint8Array(buffer.buffer, timestampByteLength, randomByteLength);\n\n return {\n generate: () => generate(prefix, options, buffer, randomView),\n generateAt: (date: Date) => generate(prefix, options, buffer, randomView, date.getTime()),\n is: (value: unknown) => is(prefix, value),\n parse: (value: unknown) => parse(prefix, value),\n safeParse: (value: unknown) => safeParse(prefix, value),\n extractTimestamp: (id: Id<Brand>) => extractTimestamp(prefix, id),\n minIdForTime: (date: Date) => sentinelIdForTime(prefix, date, 0x00, buffer, randomView),\n maxIdForTime: (date: Date) => sentinelIdForTime(prefix, date, 0xff, buffer, randomView),\n toJsonSchema: () => toJsonSchema(brand, prefix, options, buffer, randomView),\n \"~standard\": {\n version: 1,\n vendor: \"@smonn/ids\",\n validate: (value: unknown) => standardValidate(prefix, value),\n },\n };\n}\n\nfunction toJsonSchema<Brand extends string>(\n brand: Brand,\n prefix: Prefix<Brand>,\n options: Options,\n buffer: Uint8Array,\n randomView: Uint8Array,\n): JsonSchema {\n return {\n type: \"string\",\n pattern: `^${prefix}${base32CharClass}{${payloadBase32Length}}$`,\n description: `Branded ID for '${brand}'`,\n example: generate(prefix, options, buffer, randomView),\n };\n}\n\nfunction generate<Brand extends string>(\n prefix: Prefix<Brand>,\n options: Options,\n buffer: Uint8Array,\n randomView: Uint8Array,\n ms: number = options.now(),\n): Id<Brand> {\n writeTimestamp(ms, buffer);\n options.rng(randomView);\n return (prefix + encodeBase32(buffer)) as Id<Brand>;\n}\n\nfunction sentinelIdForTime<Brand extends string>(\n prefix: Prefix<Brand>,\n date: Date,\n fill: number,\n buffer: Uint8Array,\n randomView: Uint8Array,\n): Id<Brand> {\n writeTimestamp(date.getTime(), buffer);\n randomView.fill(fill);\n return (prefix + encodeBase32(buffer)) as Id<Brand>;\n}\n\nfunction extractTimestamp<Brand extends string>(prefix: Prefix<Brand>, id: Id<Brand>): Date {\n const base32 = id.slice(prefix.length, prefix.length + timestampBase32Length);\n return new Date(readTimestampMs(decodeBase32(base32)));\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"opaque.mjs","names":[],"sources":["../src/opaque.ts"],"sourcesContent":["import { decodeBase32, encodeBase32 } from \"./base32.js\";\nimport { registerBrand, validateBrand } from \"./registry.js\";\nimport {\n base32CharClass,\n is,\n parse,\n payloadBase32Length,\n payloadByteLength,\n readTimestampMs,\n safeParse,\n standardValidate,\n timestampByteLength,\n writeTimestamp,\n} from \"./shared.js\";\nimport type { Id, JsonSchema, ParseResult, Prefix, StandardSchemaProps } from \"./types.js\";\n\nexport type OpaqueOptions = {\n key: CryptoKey;\n now: () => number;\n rng: (target: Uint8Array) => void;\n allowDuplicateBrand?: boolean;\n};\n\nexport type OpaqueCodec<Brand extends string> = {\n generate(): Promise<Id<Brand>>;\n generateAt(date: Date): Promise<Id<Brand>>;\n is(value: unknown): value is Id<Brand>;\n parse(value: unknown): Id<Brand>;\n safeParse(value: unknown): ParseResult<Brand>;\n extractTimestamp(id: Id<Brand>): Promise<Date>;\n toJsonSchema(): JsonSchema;\n readonly \"~standard\": StandardSchemaProps<Brand>;\n};\n\nconst zeroIv = new Uint8Array(payloadByteLength);\nconst pkcsPad = 0x10;\n\nfunction defaultRng(target: Uint8Array): void {\n crypto.getRandomValues(target as Uint8Array<ArrayBuffer>);\n}\n\nexport function importOpaqueKey(bytes: Uint8Array): Promise<CryptoKey> {\n return crypto.subtle.importKey(\"raw\", bytes as Uint8Array<ArrayBuffer>, \"AES-CBC\", false, [\n \"encrypt\",\n \"decrypt\",\n ]);\n}\n\nexport function createOpaqueId<Brand extends string>(\n brand: Brand,\n opts: { key: CryptoKey } & Partial<Omit<OpaqueOptions, \"key\">>,\n): OpaqueCodec<Brand> {\n validateBrand(brand);\n registerBrand(brand, opts.allowDuplicateBrand);\n\n const key = opts.key;\n const now = opts.now ?? Date.now;\n const rng = opts.rng ?? defaultRng;\n const prefix: Prefix<Brand> = `${brand}_`;\n\n return {\n generate: () => generate(prefix, key, rng, now()),\n generateAt: (date: Date) => generate(prefix, key, rng, date.getTime()),\n is: (value: unknown) => is(prefix, value),\n parse: (value: unknown) => parse(prefix, value),\n safeParse: (value: unknown) => safeParse(prefix, value),\n extractTimestamp: (id: Id<Brand>) => extractTimestamp(prefix, key, id),\n toJsonSchema: () => toJsonSchema(brand, prefix),\n \"~standard\": {\n version: 1,\n vendor: \"@smonn/ids\",\n validate: (value: unknown) => standardValidate(prefix, value),\n },\n };\n}\n\n// Per-call buffers, unlike id.ts's codec-shared scratch. Reuse would be safe\n// (subtle.encrypt/decrypt snapshot inputs synchronously, per WebCrypto step 2\n// before the Promise returns) but subtle dominates this path — the allocation\n// is <1% of total cost, not worth pinning the design to that spec detail.\nasync function generate<Brand extends string>(\n prefix: Prefix<Brand>,\n key: CryptoKey,\n rng: (target: Uint8Array) => void,\n ms: number,\n): Promise<Id<Brand>> {\n const plaintext = new Uint8Array(payloadByteLength);\n writeTimestamp(ms, plaintext);\n rng(plaintext.subarray(timestampByteLength, payloadByteLength));\n const encrypted = new Uint8Array(\n await crypto.subtle.encrypt({ name: \"AES-CBC\", iv: zeroIv }, key, plaintext),\n );\n return (prefix + encodeBase32(encrypted.subarray(0, payloadByteLength))) as Id<Brand>;\n}\n\nasync function extractTimestamp<Brand extends string>(\n prefix: Prefix<Brand>,\n key: CryptoKey,\n id: Id<Brand>,\n): Promise<Date> {\n const c1 = decodeBase32(id.slice(prefix.length));\n // Reconstruct C2 = AES_K(P2 XOR C1) where P2 is the PKCS#7 pad block (0x10×16).\n // CBC encrypt of (P2 XOR C1) with IV=0 yields AES_K(P2 XOR C1) as the first 16 bytes.\n const c2Input = new Uint8Array(payloadByteLength);\n for (let i = 0; i < payloadByteLength; i++) c2Input[i] = pkcsPad ^ c1[i]!;\n const c2Encrypted = new Uint8Array(\n await crypto.subtle.encrypt({ name: \"AES-CBC\", iv: zeroIv }, key, c2Input),\n );\n const ciphertext = new Uint8Array(payloadByteLength * 2);\n ciphertext.set(c1, 0);\n ciphertext.set(c2Encrypted.subarray(0, payloadByteLength), payloadByteLength);\n const plaintext = new Uint8Array(\n await crypto.subtle.decrypt({ name: \"AES-CBC\", iv: zeroIv }, key, ciphertext),\n );\n return new Date(readTimestampMs(plaintext));\n}\n\nfunction toJsonSchema<Brand extends string>(brand: Brand, prefix: Prefix<Brand>): JsonSchema {\n return {\n type: \"string\",\n pattern: `^${prefix}${base32CharClass}{${payloadBase32Length}}$`,\n description: `Branded ID for '${brand}'`,\n // The Opaque codec cannot synchronously produce a real example (encrypt is\n // async). A deterministic structurally-valid placeholder satisfies the\n // JSON Schema contract without requiring the key at schema time.\n example: prefix + \"0\".repeat(payloadBase32Length),\n };\n}\n"],"mappings":";;AAkCA,MAAM,SAAS,IAAI,WAAA,EAA4B;AAC/C,MAAM,UAAU;AAEhB,SAAS,WAAW,QAA0B;CAC5C,OAAO,gBAAgB,MAAiC;AAC1D;AAEA,SAAgB,gBAAgB,OAAuC;CACrE,OAAO,OAAO,OAAO,UAAU,OAAO,OAAkC,WAAW,OAAO,CACxF,WACA,SACF,CAAC;AACH;AAEA,SAAgB,eACd,OACA,MACoB;CACpB,cAAc,KAAK;CACnB,cAAc,OAAO,KAAK,mBAAmB;CAE7C,MAAM,MAAM,KAAK;CACjB,MAAM,MAAM,KAAK,OAAO,KAAK;CAC7B,MAAM,MAAM,KAAK,OAAO;CACxB,MAAM,SAAwB,GAAG,MAAM;CAEvC,OAAO;EACL,gBAAgB,SAAS,QAAQ,KAAK,KAAK,IAAI,CAAC;EAChD,aAAa,SAAe,SAAS,QAAQ,KAAK,KAAK,KAAK,QAAQ,CAAC;EACrE,KAAK,UAAmB,GAAG,QAAQ,KAAK;EACxC,QAAQ,UAAmB,MAAM,QAAQ,KAAK;EAC9C,YAAY,UAAmB,UAAU,QAAQ,KAAK;EACtD,mBAAmB,OAAkB,iBAAiB,QAAQ,KAAK,EAAE;EACrE,oBAAoB,aAAa,OAAO,MAAM;EAC9C,aAAa;GACX,SAAS;GACT,QAAQ;GACR,WAAW,UAAmB,iBAAiB,QAAQ,KAAK;EAC9D;CACF;AACF;AAMA,eAAe,SACb,QACA,KACA,KACA,IACoB;CACpB,MAAM,YAAY,IAAI,WAAA,EAA4B;CAClD,eAAe,IAAI,SAAS;CAC5B,IAAI,UAAU,SAAA,GAAA,EAA+C,CAAC;CAI9D,OAAQ,SAAS,aAAa,IAHR,WACpB,MAAM,OAAO,OAAO,QAAQ;EAAE,MAAM;EAAW,IAAI;CAAO,GAAG,KAAK,SAAS,CAEvC,EAAE,SAAS,GAAA,EAAoB,CAAC;AACxE;AAEA,eAAe,iBACb,QACA,KACA,IACe;CACf,MAAM,KAAK,aAAa,GAAG,MAAM,OAAO,MAAM,CAAC;CAG/C,MAAM,UAAU,IAAI,WAAA,EAA4B;CAChD,KAAK,IAAI,IAAI,GAAG,IAAA,IAAuB,KAAK,QAAQ,KAAK,UAAU,GAAG;CACtE,MAAM,cAAc,IAAI,WACtB,MAAM,OAAO,OAAO,QAAQ;EAAE,MAAM;EAAW,IAAI;CAAO,GAAG,KAAK,OAAO,CAC3E;CACA,MAAM,aAAa,IAAI,WAAA,EAAgC;CACvD,WAAW,IAAI,IAAI,CAAC;CACpB,WAAW,IAAI,YAAY,SAAS,GAAA,EAAoB,GAAA,EAAoB;CAC5E,MAAM,YAAY,IAAI,WACpB,MAAM,OAAO,OAAO,QAAQ;EAAE,MAAM;EAAW,IAAI;CAAO,GAAG,KAAK,UAAU,CAC9E;CACA,OAAO,IAAI,KAAK,gBAAgB,SAAS,CAAC;AAC5C;AAEA,SAAS,aAAmC,OAAc,QAAmC;CAC3F,OAAO;EACL,MAAM;EACN,SAAS,IAAI,SAAS,gBAAgB,GAAG,oBAAoB;EAC7D,aAAa,mBAAmB,MAAM;EAItC,SAAS,SAAS,IAAI,OAAO,mBAAmB;CAClD;AACF"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"types-Dg2j_zlO.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";KAAY,MAAA,4BAAkC,KAAA;AAAA,KAElC,EAAA,4BAA8B,MAAA,CAAO,KAAA;EAAA,SACtC,OAAA,EAAS,KAAA;AAAA;AAAA,KAGR,UAAA;AAAA,KAEA,WAAA;EACN,EAAA;EAAU,EAAA,EAAI,EAAA,CAAG,KAAA;AAAA;EACjB,EAAA;EAAW,KAAA,EAAO,UAAA;AAAA;AAAA,KAEZ,UAAA;EAAA,SACD,IAAA;EAAA,SACA,OAAA;EAAA,SACA,WAAA;EAAA,SACA,OAAA;AAAA;AAAA,KAGC,mBAAA;EAAA,SACD,OAAA;EAAA,SACA,MAAA;EAAA,SACA,QAAA,GACP,KAAA,WACA,OAAA;IAAA,SAAqB,cAAA,GAAiB,MAAA;EAAA;IAAA,SAEzB,KAAA,EAAO,EAAA,CAAG,KAAA;IAAA,SAAiB,MAAA;EAAA;IAAA,SAC3B,MAAA,EAAQ,aAAA;MAAA,SAAyB,OAAA;IAAA;EAAA;EAAA,SACvC,KAAA;IAAA,SAAmB,KAAA;IAAA,SAAyB,MAAA,EAAQ,EAAA,CAAG,KAAA;EAAA;AAAA"}