@smonn/ids 0.8.0 → 0.9.1

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.
Files changed (71) hide show
  1. package/README.md +210 -32
  2. package/dist/adapter-types-BY-wrYYB.mjs +27 -0
  3. package/dist/adapter-types-BY-wrYYB.mjs.map +1 -0
  4. package/dist/adapter-types-unUcmMXC.d.mts +20 -0
  5. package/dist/adapter-types-unUcmMXC.d.mts.map +1 -0
  6. package/dist/cli.mjs +342 -71
  7. package/dist/cli.mjs.map +1 -1
  8. package/dist/{codec-shell-DH-UO4UR.mjs → codec-shell-CW2sD6BU.mjs} +6 -5
  9. package/dist/codec-shell-CW2sD6BU.mjs.map +1 -0
  10. package/dist/drizzle.d.mts +33 -2
  11. package/dist/drizzle.d.mts.map +1 -0
  12. package/dist/drizzle.mjs +2 -3
  13. package/dist/drizzle.mjs.map +1 -1
  14. package/dist/express.d.mts +2 -5
  15. package/dist/express.d.mts.map +1 -1
  16. package/dist/express.mjs +3 -8
  17. package/dist/express.mjs.map +1 -1
  18. package/dist/fastify.d.mts +2 -5
  19. package/dist/fastify.d.mts.map +1 -1
  20. package/dist/fastify.mjs +3 -8
  21. package/dist/fastify.mjs.map +1 -1
  22. package/dist/hono.d.mts +2 -5
  23. package/dist/hono.d.mts.map +1 -1
  24. package/dist/hono.mjs +3 -8
  25. package/dist/hono.mjs.map +1 -1
  26. package/dist/index.mjs +1 -1
  27. package/dist/key-material-gOnqTNoV.mjs +137 -0
  28. package/dist/key-material-gOnqTNoV.mjs.map +1 -0
  29. package/dist/kysely.d.mts +1 -1
  30. package/dist/kysely.mjs +2 -3
  31. package/dist/kysely.mjs.map +1 -1
  32. package/dist/{opaque-uvjOFY_0.mjs → opaque-BpqxV8oB.mjs} +12 -48
  33. package/dist/opaque-BpqxV8oB.mjs.map +1 -0
  34. package/dist/opaque.d.mts +8 -0
  35. package/dist/opaque.d.mts.map +1 -1
  36. package/dist/opaque.mjs +1 -1
  37. package/dist/prisma.d.mts +4 -18
  38. package/dist/prisma.d.mts.map +1 -1
  39. package/dist/prisma.mjs +3 -5
  40. package/dist/prisma.mjs.map +1 -1
  41. package/dist/{reverse-BgFU6JHw.mjs → reverse-d5uEoIET.mjs} +5 -7
  42. package/dist/reverse-d5uEoIET.mjs.map +1 -0
  43. package/dist/reverse.d.mts.map +1 -1
  44. package/dist/reverse.mjs +1 -1
  45. package/dist/rng-CPJOx_nE.mjs +9 -0
  46. package/dist/rng-CPJOx_nE.mjs.map +1 -0
  47. package/dist/signed-BnRSC03a.mjs +207 -0
  48. package/dist/signed-BnRSC03a.mjs.map +1 -0
  49. package/dist/signed.d.mts.map +1 -1
  50. package/dist/signed.mjs +1 -255
  51. package/dist/{timestamp-B5_UCzc6.mjs → timestamp-BbZL8hwg.mjs} +5 -5
  52. package/dist/{timestamp-B5_UCzc6.mjs.map → timestamp-BbZL8hwg.mjs.map} +1 -1
  53. package/dist/{timestamp-bytes-BBY7JI33.mjs → timestamp-bytes-DoFjLjDp.mjs} +3 -2
  54. package/dist/timestamp-bytes-DoFjLjDp.mjs.map +1 -0
  55. package/dist/{wrapped-0vL72Nje.mjs → wrapped-BI9UXnAm.mjs} +33 -62
  56. package/dist/wrapped-BI9UXnAm.mjs.map +1 -0
  57. package/dist/wrapped.d.mts.map +1 -1
  58. package/dist/wrapped.mjs +1 -1
  59. package/package.json +5 -5
  60. package/dist/adapter-types-oHCCSgOO.d.mts +0 -12
  61. package/dist/adapter-types-oHCCSgOO.d.mts.map +0 -1
  62. package/dist/bytes-lhzKVaBV.mjs +0 -53
  63. package/dist/bytes-lhzKVaBV.mjs.map +0 -1
  64. package/dist/codec-shell-DH-UO4UR.mjs.map +0 -1
  65. package/dist/drizzle-CeSni5PB.d.mts +0 -44
  66. package/dist/drizzle-CeSni5PB.d.mts.map +0 -1
  67. package/dist/opaque-uvjOFY_0.mjs.map +0 -1
  68. package/dist/reverse-BgFU6JHw.mjs.map +0 -1
  69. package/dist/signed.mjs.map +0 -1
  70. package/dist/timestamp-bytes-BBY7JI33.mjs.map +0 -1
  71. package/dist/wrapped-0vL72Nje.mjs.map +0 -1
package/README.md CHANGED
@@ -105,7 +105,7 @@ try {
105
105
  | `empty_keyring` | the wrapping keyring is empty | `createWrappedKeyId({ keys })` | Supply at least one `WrappingKey` |
106
106
  | `duplicate_keyring_entry` | two keyring entries share the same raw secret | `createWrappedKeyId({ keys })` | Deduplicate the key list |
107
107
  | `invalid_lookup_key` | lookup key is out of range or the wrong JS type | `wrap(lookupKey)` | Check the kind's range and JS type |
108
- | `verification_failed` | no keyring entry verifies the payload tag | `unwrap(id)` | Check keyring; tamper or wrong key |
108
+ | `verification_failed` | no keyring entry verifies the payload tag | `unwrap(id)`, `verify(id)` | Check keyring; tamper or wrong key |
109
109
  | `invalid_id` | string is not a valid ID for this brand | `parse()`, ORM adapter read paths | Use `safeParse()` for untrusted input |
110
110
 
111
111
  `invalid_id` carries the originating `ParseError` string on `cause` — check `err.cause` for `"not_string"`, `"invalid_prefix"`, or `"invalid_base32"` when you need to distinguish the failure mode.
@@ -391,6 +391,28 @@ Encryption is AES-CBC with a zero IV. That's deliberately safe here because the
391
391
 
392
392
  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)).
393
393
 
394
+ **Rotating the Opaque key.** Rotation is **forward-only and caller-tracked** — the codec deliberately has no key ring. The key feeds only `generate` and `extractTimestamp`; `parse`, `safeParse`, `is`, and `toJsonSchema` work on the wire form and never touch it, so rotating forward is nearly free: point new writes at a new key and keep the old key only to read old IDs' timestamps. Because the payload is unauthenticated ([ADR-0004](./docs/adr/0004-aes-cbc-strip-trick.md)) and carries no key id ([ADR-0007](./docs/adr/0007-wire-indistinguishable-codec-variants.md)), the library **cannot** trial a ring to pick the right key for you — a wrong key yields a plausible but wrong timestamp, never an error. You hold one codec per _key epoch_ and select it from your own record of which epoch minted each ID:
395
+
396
+ ```ts
397
+ // One codec instance per key epoch. You — not the library — track which epoch
398
+ // minted each ID (a key-epoch column, tenant→key map, created-at cutover). The
399
+ // epoch CANNOT be read from the ID itself. `allowDuplicateBrand` silences the
400
+ // per-brand registry warning (ADR-0007): multiple instances of one brand is the
401
+ // legitimate case that flag exists for.
402
+ const codecs = new Map([
403
+ [1, createOpaqueTimestampId("inv", { key: keyV1, allowDuplicateBrand: true })],
404
+ [2, createOpaqueTimestampId("inv", { key: keyV2, allowDuplicateBrand: true })], // current epoch
405
+ ]);
406
+
407
+ const id = await codecs.get(2)!.generate(); // new IDs use the current epoch's key
408
+
409
+ // Reading an old ID: look up its epoch from your records, pick that codec.
410
+ const epoch = await db.keyEpochFor(someOldId); // your bookkeeping, not the ID
411
+ await codecs.get(epoch)!.extractTimestamp(someOldId);
412
+ ```
413
+
414
+ If you need transparent, correctness-grade rotation where the library trials a key ring and a wrong key is _rejected_, that's the Signed Timestamp codec's job ([ADR-0012](./docs/adr/0012-signed-timestamp-construction.md)) — its HMAC tag gives a verifiable ring. The Opaque codec trades that away for confidentiality. See [ADR-0013](./docs/adr/0013-opaque-key-rotation.md).
415
+
394
416
  ### "Newest-first IDs for descending range scans"
395
417
 
396
418
  Most KV stores (DynamoDB, Cloud Datastore, range-scan KV) only support forward lexicographic scans natively — reading the most recent entries first would otherwise require a full reverse scan or a separate sort step. The Reverse Timestamp codec solves this by bitwise-inverting the 48-bit timestamp field before encoding, so newer IDs sort lexicographically before older ones.
@@ -425,12 +447,13 @@ No key material is required. The inversion is a deterministic byte transform; `g
425
447
 
426
448
  ## Choosing a codec variant
427
449
 
428
- | Codec | Import | Sort direction | Key required | Timestamp extractable | Range query support |
429
- | ----------------- | -------------------- | ------------------------- | ------------------ | -------------------------- | -------------------------------------------------------------- |
430
- | Timestamp | `@smonn/ids` | Ascending (oldest-first) | No | Always | `minIdForTime(t_old)` → `maxIdForTime(t_new)` |
431
- | Reverse Timestamp | `@smonn/ids/reverse` | Descending (newest-first) | No | Always | `minIdForTime(t_new)` → `maxIdForTime(t_old)` (bounds flipped) |
432
- | Opaque Timestamp | `@smonn/ids/opaque` | None (encrypted) | Yes (AES key) | With key only | None encrypted payloads do not sort by time |
433
- | Wrapped key | `@smonn/ids/wrapped` | None | Yes (wrapping key) | N/A — not timestamp-family | None |
450
+ | Codec | Import | Sort direction | Key required | Timestamp extractable | Range query support |
451
+ | ----------------- | -------------------- | ------------------------- | ------------------ | -------------------------- | ---------------------------------------------------------------------------- |
452
+ | Timestamp | `@smonn/ids` | Ascending (oldest-first) | No | Always (plaintext) | `minIdForTime(t_old)` → `maxIdForTime(t_new)` |
453
+ | Reverse Timestamp | `@smonn/ids/reverse` | Descending (newest-first) | No | Always (plaintext) | `minIdForTime(t_new)` → `maxIdForTime(t_old)` (bounds flipped) |
454
+ | Signed Timestamp | `@smonn/ids/signed` | Ascending (oldest-first) | Yes (signing key) | Always (plaintext) | `minIdForTime(t_old)` `maxIdForTime(t_new)` (sentinels carry no valid tag) |
455
+ | Opaque Timestamp | `@smonn/ids/opaque` | None (encrypted) | Yes (AES key) | With key only | Noneencrypted payloads do not sort by time |
456
+ | Wrapped key | `@smonn/ids/wrapped` | None | Yes (wrapping key) | N/A — not timestamp-family | None |
434
457
 
435
458
  ## What this is **not** for
436
459
 
@@ -553,6 +576,84 @@ import {
553
576
  } from "@smonn/ids/fastify";
554
577
  ```
555
578
 
579
+ `@smonn/ids/signed` ships the Signed Timestamp codec — it keeps the 48-bit timestamp **readable and sortable** like the Timestamp codec, but replaces half of the random tail with a truncated HMAC tag, making IDs **tamper-evident and verifiable without a database lookup**. This adds **integrity, not confidentiality** — the opposite security axis from the Opaque Timestamp codec, which hides the timestamp but has no auth tag.
580
+
581
+ The canonical use case is **share links**: embed a Signed Timestamp ID in a share URL and verify it on receipt without a database roundtrip.
582
+
583
+ ```ts
584
+ import { createSignedTimestampId, importSigningKey } from "@smonn/ids/signed";
585
+
586
+ const key = await importSigningKey(new Uint8Array(32));
587
+ const shares = createSignedTimestampId("shr", { keys: [key] });
588
+
589
+ const id = await shares.generate(); // "shr_…", timestamp readable and sortable
590
+ shares.extractTimestamp(id); // Date — sync, timestamp is plaintext
591
+
592
+ await shares.verify(id); // passes; throws IdsError verification_failed on tamper
593
+ ```
594
+
595
+ The non-throwing `safeVerify` path accepts untrusted input, structurally parses first, then verifies — without throwing:
596
+
597
+ ```ts
598
+ const result = await shares.safeVerify(req.params.shareId);
599
+
600
+ if (!result.ok) {
601
+ if (result.error === "verification_failed") return 403; // tampered or wrong key
602
+ return 400; // malformed ID
603
+ }
604
+
605
+ const { id } = result; // Id<"shr">, canonical
606
+ ```
607
+
608
+ `safeVerify` returns one of:
609
+
610
+ ```ts
611
+ // Success
612
+ {
613
+ ok: true;
614
+ id: Id<Brand>;
615
+ }
616
+
617
+ // Structural parse failure (wrong brand, invalid base32, etc.)
618
+ {
619
+ ok: false;
620
+ error: "not_string" | "invalid_prefix" | "invalid_base32";
621
+ }
622
+
623
+ // Structurally valid but tag mismatch (tampered, wrong keyring, or revoked key)
624
+ {
625
+ ok: false;
626
+ error: "verification_failed";
627
+ }
628
+ ```
629
+
630
+ **False-accept bound.** With a signing keyring of `n` entries, an attacker's per-`verify` success probability is approximately `n / 2⁴⁰`. Verification is online-only — the signing key lives server-side, so offline guessing is not possible.
631
+
632
+ **Key handling.** Import signing key material via `importSigningKey(bytes)` from raw bytes (16, 24, or 32 bytes). Signing-key material is a **separate secret domain** from Opaque keys and Wrapping keys — same `hex` / `base64url` encoded-format conventions, but a distinct `SigningKey` handle and HKDF label so one raw secret cannot silently serve multiple codecs.
633
+
634
+ ```ts
635
+ import { encodeSigningKey, decodeSigningKey } from "@smonn/ids/signed";
636
+
637
+ const encoded = encodeSigningKey(rawBytes, "base64url"); // string
638
+ const decoded = decodeSigningKey(encoded, "base64url"); // Uint8Array
639
+ ```
640
+
641
+ **Managing the signing keyring.** Pass a non-empty ordered list of signing keys at construction. The first entry is the _current_ key — the only one `generate` / `generateAt` sign with. `verify` / `safeVerify` trial every entry in order until the tag matches, so IDs signed under any listed key remain verifiable. Removing an entry from the list revokes all IDs signed under it.
642
+
643
+ ```ts
644
+ const oldKey = await importSigningKey(rawOldSecret);
645
+ const newKey = await importSigningKey(rawNewSecret);
646
+
647
+ // Before rotation: only oldKey in the ring
648
+ const legacy = createSignedTimestampId("shr", { keys: [oldKey] });
649
+ const id = await legacy.generate();
650
+
651
+ // After rotation: newKey is current; oldKey is still accepted on verify
652
+ const rotated = createSignedTimestampId("shr", { keys: [newKey, oldKey] });
653
+ await rotated.verify(id); // succeeds — tried oldKey and matched
654
+ await rotated.generate(); // signs with newKey
655
+ ```
656
+
556
657
  `@smonn/ids/wrapped` ships the Wrapped key codec for `u32`, `i32`, `u64`, and `i64` lookup keys. `wrap(lookupKey)` returns a public ID; `unwrap(id)` verifies the payload and returns the lookup key; `safeUnwrap(input)` is the non-throwing path for untrusted input.
557
658
 
558
659
  **Integer kinds and value types.** The 32-bit kinds (`u32`, `i32`) use safe JavaScript `number` values in their fixed-width ranges. The 64-bit kinds (`u64`, `i64`) always use `bigint` — even when the magnitude would fit in a `number` — to prevent silent truncation or sign erasure.
@@ -642,23 +743,27 @@ const { id, lookupKey } = result; // Id<"inv">, number
642
743
 
643
744
  ### Codec methods
644
745
 
645
- | Method | `TimestampCodec<Brand>` | `ReverseTimestampCodec<Brand>` | `OpaqueTimestampCodec<Brand>` | `WrappedKeyCodec<Brand, Kind>` | Description |
646
- | ---------------------- | ----------------------- | ------------------------------ | ----------------------------- | ------------------------------ | ----------------------------------------------------------------------------- |
647
- | `generate()` | sync | sync | async | — | Produce a fresh ID |
648
- | `generateAt(date)` | sync | sync | async | — | Produce a fresh ID with timestamp bytes from `date` (for backfills) |
649
- | `wrap(lookupKey)` | — | — | — | async | Wrap a lookup key into a public ID using the current wrapping key |
650
- | `unwrap(id)` | — | — | — | async | Verify and recover the lookup key; throws on verification failure |
651
- | `safeUnwrap(input)` | — | — | — | async | Non-throwing: structurally parse then verify; returns parse or verify error |
652
- | `is(value)` | sync | sync | sync | sync | Strict type guard: `true` only for already-canonical strings |
653
- | `parse(value)` | sync | sync | sync | sync | Lenient: normalise to canonical, or throw |
654
- | `safeParse(value)` | sync | sync | sync | sync | Lenient: normalise to canonical, or return `{ ok: false, error }` |
655
- | `extractTimestamp(id)` | sync | sync | async | | Decode the creation `Date` from an `Id<Brand>` (trusts the type) |
656
- | `minIdForTime(date)` | sync | sync | | | Tight lower bound for any ID generated at `date` (for range queries) |
657
- | `maxIdForTime(date)` | sync | sync | | — | Tight upper bound for any ID generated at `date` (for range queries) |
658
- | `toJsonSchema()` | sync | sync | sync | sync | JSON Schema (`type`/`pattern`/`description`/`example`) for the canonical form |
746
+ | Method | `TimestampCodec<Brand>` | `ReverseTimestampCodec<Brand>` | `OpaqueTimestampCodec<Brand>` | `SignedTimestampCodec<Brand>` | `WrappedKeyCodec<Brand, Kind>` | Description |
747
+ | ---------------------- | ----------------------- | ------------------------------ | ----------------------------- | ----------------------------- | ------------------------------ | ----------------------------------------------------------------------------- |
748
+ | `generate()` | sync | sync | async | async | — | Produce a fresh ID |
749
+ | `generateAt(date)` | sync | sync | async | async | — | Produce a fresh ID with timestamp bytes from `date` (for backfills) |
750
+ | `verify(id)` | — | — | — | async | — | Verify the HMAC tag; throws `IdsError` `verification_failed` on mismatch |
751
+ | `safeVerify(input)` | — | — | — | async | — | Non-throwing: structurally parse then verify; returns parse or verify error |
752
+ | `wrap(lookupKey)` | — | — | — | — | async | Wrap a lookup key into a public ID using the current wrapping key |
753
+ | `unwrap(id)` | | | | | async | Verify and recover the lookup key; throws on verification failure |
754
+ | `safeUnwrap(input)` | | | | | async | Non-throwing: structurally parse then verify; returns parse or verify error |
755
+ | `is(value)` | sync | sync | sync | sync | sync | Strict type guard: `true` only for already-canonical strings |
756
+ | `parse(value)` | sync | sync | sync | sync | sync | Lenient: normalise to canonical, or throw |
757
+ | `safeParse(value)` | sync | sync | sync | sync | sync | Lenient: normalise to canonical, or return `{ ok: false, error }` |
758
+ | `extractTimestamp(id)` | sync | sync | async | sync | — | Decode the creation `Date` from an `Id<Brand>` (trusts the type) |
759
+ | `minIdForTime(date)` | sync | sync| | sync| — | Tight lower bound for any ID generated at `date` (for range queries) |
760
+ | `maxIdForTime(date)` | sync | sync † | — | sync ‡ | — | Tight upper bound for any ID generated at `date` (for range queries) |
761
+ | `toJsonSchema()` | sync | sync | sync | sync | sync | JSON Schema (`type`/`pattern`/`description`/`example`) for the canonical form |
659
762
 
660
763
  † Under the Reverse Timestamp codec, a newer timestamp maps to a lexicographically smaller ID — pass `minIdForTime(t_new)` as the lower bound and `maxIdForTime(t_old)` as the upper bound for a [t_old, t_new] scan.
661
764
 
765
+ ‡ Signed Timestamp sentinels carry no valid HMAC tag and are not verifiable — they exist only for indexed range scans, not as real IDs.
766
+
662
767
  ## ORM adapters
663
768
 
664
769
  ### Drizzle (`@smonn/ids/drizzle`)
@@ -800,7 +905,7 @@ Brand-agnostic subcommands, no install required. Run `npx @smonn/ids --help` for
800
905
 
801
906
  ### `inspect` (`i`)
802
907
 
803
- Decode an ID and print brand, timestamp, canonical form, and whether the input was already canonical.
908
+ Decode an ID and print brand, timestamp (or lookup key), canonical form, and whether the input was already canonical.
804
909
 
805
910
  ```bash
806
911
  $ npx @smonn/ids inspect usr_01h7b3k9rqxn1cw3p9r8t2sgkz
@@ -810,13 +915,34 @@ canonical: usr_01h7b3k9rqxn1cw3p9r8t2sgkz
810
915
  input: canonical
811
916
  ```
812
917
 
813
- Accepts non-canonical input (uppercase, Crockford aliases). Assumes the **Timestamp codec** if the brand uses the **Opaque Timestamp codec**, pass `--opaque` and set `IDS_KEY` (below); otherwise the timestamp line is meaningless garbage.
918
+ Accepts non-canonical input (uppercase, Crockford aliases). Pass the flag that matches the codec variant used at generation without a flag, the **Timestamp codec** is assumed.
919
+
920
+ | Flag | Codec variant | Env var | Notes |
921
+ | ---------------------- | ----------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------- |
922
+ | _(none)_ | Timestamp codec | — | Timestamp readable directly |
923
+ | `--opaque` | Opaque Timestamp codec | `IDS_KEY` | Wrong key yields plausible-but-wrong timestamp, not an error (see [CONTEXT.md](./CONTEXT.md)) |
924
+ | `--reverse` | Reverse Timestamp codec | — | No key required; timestamp decoded from inverted bytes |
925
+ | `--wrapped --kind <k>` | Wrapped key codec | `IDS_WRAPPING_KEY` | `--kind` required: `u32`, `i32`, `u64`, `i64`; prints `lookup-key` |
926
+ | `--signed` | Signed Timestamp codec | `IDS_SIGNING_KEY` (optional) | Without key: prints timestamp only. With key: adds `verification: ok` or `verification: failed` |
927
+
928
+ Key format defaults to `hex` for all keyed modes; override with `--key-format hex|base64url` or the matching `_FORMAT` env var (see [Environment variables](#environment-variables) below).
814
929
 
815
930
  ```bash
931
+ # Opaque Timestamp (IDS_KEY required):
816
932
  IDS_KEY=<hex-or-base64url-key> npx @smonn/ids inspect inv_… --opaque
817
- ```
818
933
 
819
- 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)).
934
+ # Wrapped key (IDS_WRAPPING_KEY and --kind required):
935
+ IDS_WRAPPING_KEY=<hex-or-base64url-key> npx @smonn/ids inspect item_… --wrapped --kind u64
936
+
937
+ # Reverse Timestamp (no key):
938
+ npx @smonn/ids inspect feed_… --reverse
939
+
940
+ # Signed Timestamp — timestamp only (no key):
941
+ npx @smonn/ids inspect evt_… --signed
942
+
943
+ # Signed Timestamp — with verification:
944
+ IDS_SIGNING_KEY=<hex-or-base64url-key> npx @smonn/ids inspect evt_… --signed
945
+ ```
820
946
 
821
947
  ### `generate` (`g`)
822
948
 
@@ -829,15 +955,29 @@ usr_…
829
955
  usr_…
830
956
  ```
831
957
 
832
- Flags: `--count` / `-c N` (default 1, max 10000). Uses the Timestamp codec unless `--opaque` is set.
958
+ Flags: `--count` / `-c N` (default 1, max 10000). Uses the Timestamp codec unless a mode flag is set.
959
+
960
+ | Flag | Codec variant | Env var |
961
+ | ----------- | ----------------------- | ----------------- |
962
+ | _(none)_ | Timestamp codec | — |
963
+ | `--opaque` | Opaque Timestamp codec | `IDS_KEY` |
964
+ | `--reverse` | Reverse Timestamp codec | — |
965
+ | `--signed` | Signed Timestamp codec | `IDS_SIGNING_KEY` |
833
966
 
834
967
  ```bash
968
+ # Opaque Timestamp:
835
969
  IDS_KEY=<hex-or-base64url-key> npx @smonn/ids generate inv --opaque --count 2
970
+
971
+ # Reverse Timestamp (newest-first sort order):
972
+ npx @smonn/ids generate feed --reverse --count 5
973
+
974
+ # Signed Timestamp:
975
+ IDS_SIGNING_KEY=<hex-or-base64url-key> npx @smonn/ids generate evt --signed
836
976
  ```
837
977
 
838
978
  ### `keygen` (`k`)
839
979
 
840
- Emit a random Opaque key to stdout (a secret — do not log or commit). Default: 256-bit hex.
980
+ Emit a random key to stdout — for use with `importOpaqueKey`, `importWrappingKey`, or `importSigningKey` (a secret — do not log or commit). Default: 256-bit hex for the Opaque key domain.
841
981
 
842
982
  ```bash
843
983
  $ npx @smonn/ids keygen
@@ -847,15 +987,53 @@ $ npx @smonn/ids keygen --bits 128 --key-format base64url
847
987
  AbCdEf…
848
988
  ```
849
989
 
850
- 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`.
990
+ | Flag | Key domain | Intended for | Import function |
991
+ | ----------- | ---------- | ------------------ | ------------------- |
992
+ | _(none)_ | Opaque | `IDS_KEY` | `importOpaqueKey` |
993
+ | `--wrapped` | Wrapping | `IDS_WRAPPING_KEY` | `importWrappingKey` |
994
+ | `--signed` | Signing | `IDS_SIGNING_KEY` | `importSigningKey` |
995
+
996
+ Flags: `--bits 128|192|256` (default 256), `--key-format hex|base64url` (default `hex`). Key-format env vars do not affect `keygen` — only `--key-format` on the command line.
997
+
998
+ ```bash
999
+ # Wrapping key:
1000
+ npx @smonn/ids keygen --wrapped
1001
+
1002
+ # Signing key (base64url):
1003
+ npx @smonn/ids keygen --signed --key-format base64url
1004
+ ```
1005
+
1006
+ ### Environment variables
1007
+
1008
+ All keyed modes read secrets from environment variables — not from argv (argv leaks via `ps` and shell history). Missing or malformed key env vars print a clear stderr message and exit non-zero. Invalid input prints the parse error to stderr and exits non-zero.
1009
+
1010
+ | Env var | Used by | Default format |
1011
+ | ------------------------- | ----------------------------- | -------------- |
1012
+ | `IDS_KEY` | `--opaque` | `hex` |
1013
+ | `IDS_KEY_FORMAT` | `--opaque` (format override) | — |
1014
+ | `IDS_WRAPPING_KEY` | `--wrapped` | `hex` |
1015
+ | `IDS_WRAPPING_KEY_FORMAT` | `--wrapped` (format override) | — |
1016
+ | `IDS_SIGNING_KEY` | `--signed` | `hex` |
1017
+ | `IDS_SIGNING_KEY_FORMAT` | `--signed` (format override) | — |
1018
+
1019
+ Key format defaults to `hex` for all modes; override per-invocation with `--key-format hex|base64url` or set the matching `_FORMAT` env var for a session default. `--key-format` on the command line wins over the env var. Key-format env vars do not affect `keygen` output — only `--key-format` applies there.
1020
+
1021
+ ### Signed mode (`--signed`)
1022
+
1023
+ `generate --signed` and `inspect --signed` read the HMAC signing key from `IDS_SIGNING_KEY` — not from argv.
851
1024
 
852
- ### Opaque mode (`--opaque`)
1025
+ `inspect --signed` always emits a full timestamp report on stdout and carries a `verification:` line with a three-value verdict:
853
1026
 
854
- `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.
1027
+ | Case | stdout | stderr | exit |
1028
+ | ------------- | ------------------------------------ | ---------------------------------------------- | ---- |
1029
+ | Correct key | report + `verification: ok` | — | 0 |
1030
+ | Tag mismatch | report + `verification: failed` | `verification_failed: <message>` | 1 |
1031
+ | Key missing | report + `verification: unavailable` | `missing IDS_SIGNING_KEY environment variable` | 1 |
1032
+ | Key malformed | report + `verification: unavailable` | specific key diagnostic | 1 |
855
1033
 
856
- 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`.
1034
+ The timestamp is always readable (Signed Timestamp IDs carry a plaintext timestamp), so `inspect` without `--signed` also decodes it but without verification.
857
1035
 
858
- Invalid input prints the parse error to stderr and exits non-zero.
1036
+ Key format defaults to `hex`; override with `--key-format` or `IDS_SIGNING_KEY_FORMAT`. `--key-format` wins over the environment variable.
859
1037
 
860
1038
  ## Design
861
1039
 
@@ -0,0 +1,27 @@
1
+ import { t as IdsError } from "./error-Cp5qYZcv.mjs";
2
+ //#region src/adapter-types.ts
3
+ /** Parses `value` as `Id<Brand>` via `codec.safeParse`; throws `IdsError("invalid_id")` on failure. Shared read helper for ORM adapters. */
4
+ function readIdColumn(codec, value) {
5
+ const result = codec.safeParse(value);
6
+ if (!result.ok) throw new IdsError("invalid_id", `invalid ID from database: ${result.error}`, { cause: result.error });
7
+ return result.id;
8
+ }
9
+ /**
10
+ * Maps a `ParseError` to `{ reason, status }` for web adapter failure handling.
11
+ *
12
+ * - `invalid_prefix` → `brand_mismatch` / default 404
13
+ * - anything else → `malformed` / default 400
14
+ * - `options.status[reason]` overrides the default for that reason
15
+ */
16
+ function resolveIdParamFailure(error, options) {
17
+ const reason = error === "invalid_prefix" ? "brand_mismatch" : "malformed";
18
+ const defaultStatus = reason === "brand_mismatch" ? 404 : 400;
19
+ return {
20
+ reason,
21
+ status: options?.status?.[reason] ?? defaultStatus
22
+ };
23
+ }
24
+ //#endregion
25
+ export { resolveIdParamFailure as n, readIdColumn as t };
26
+
27
+ //# sourceMappingURL=adapter-types-BY-wrYYB.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-types-BY-wrYYB.mjs","names":[],"sources":["../src/adapter-types.ts"],"sourcesContent":["import { IdsError } from \"./error.js\";\nimport type { Id, ParseError, ParseResult } from \"./types.js\";\n\n/** Discriminated failure value passed to `onError` and emitted to the framework's error handler. */\nexport type IdParamFailure =\n | { readonly reason: \"brand_mismatch\"; readonly status: number }\n | { readonly reason: \"malformed\"; readonly status: number };\n\n/** Minimum structural type required by web and ORM adapters. Any codec variant satisfies this — all expose `safeParse`. Adapters only ever call `safeParse` — never key-dependent methods like `extractTimestamp`, `wrap`, or `unwrap`. */\nexport type IdCodec<Brand extends string> = {\n safeParse(value: unknown): ParseResult<Brand>;\n};\n\n/** Re-exported from ORM adapter subpaths (`@smonn/ids/drizzle`, `@smonn/ids/prisma`, `@smonn/ids/kysely`) under the public name; structurally identical to {@link IdCodec}. */\nexport type IdColumnCodec<Brand extends string> = IdCodec<Brand>;\n\n/** Parses `value` as `Id<Brand>` via `codec.safeParse`; throws `IdsError(\"invalid_id\")` on failure. Shared read helper for ORM adapters. */\nexport function readIdColumn<Brand extends string>(\n codec: IdCodec<Brand>,\n value: unknown,\n): Id<Brand> {\n const result = codec.safeParse(value);\n if (!result.ok) {\n throw new IdsError(\"invalid_id\", `invalid ID from database: ${result.error}`, {\n cause: result.error,\n });\n }\n return result.id;\n}\n\n/**\n * Maps a `ParseError` to `{ reason, status }` for web adapter failure handling.\n *\n * - `invalid_prefix` → `brand_mismatch` / default 404\n * - anything else → `malformed` / default 400\n * - `options.status[reason]` overrides the default for that reason\n */\nexport function resolveIdParamFailure(\n error: ParseError,\n options?: { status?: { brand_mismatch?: number; malformed?: number } },\n): IdParamFailure {\n const reason = error === \"invalid_prefix\" ? (\"brand_mismatch\" as const) : (\"malformed\" as const);\n const defaultStatus = reason === \"brand_mismatch\" ? 404 : 400;\n const status = options?.status?.[reason] ?? defaultStatus;\n return { reason, status };\n}\n"],"mappings":";;;AAiBA,SAAgB,aACd,OACA,OACW;CACX,MAAM,SAAS,MAAM,UAAU,KAAK;CACpC,IAAI,CAAC,OAAO,IACV,MAAM,IAAI,SAAS,cAAc,6BAA6B,OAAO,SAAS,EAC5E,OAAO,OAAO,MAChB,CAAC;CAEH,OAAO,OAAO;AAChB;;;;;;;;AASA,SAAgB,sBACd,OACA,SACgB;CAChB,MAAM,SAAS,UAAU,mBAAoB,mBAA8B;CAC3E,MAAM,gBAAgB,WAAW,mBAAmB,MAAM;CAE1D,OAAO;EAAE;EAAQ,QADF,SAAS,SAAS,WAAW;CACpB;AAC1B"}
@@ -0,0 +1,20 @@
1
+ import { i as ParseResult } from "./types-g7CiQDyE.mjs";
2
+
3
+ //#region src/adapter-types.d.ts
4
+ /** Discriminated failure value passed to `onError` and emitted to the framework's error handler. */
5
+ type IdParamFailure = {
6
+ readonly reason: "brand_mismatch";
7
+ readonly status: number;
8
+ } | {
9
+ readonly reason: "malformed";
10
+ readonly status: number;
11
+ };
12
+ /** Minimum structural type required by web and ORM adapters. Any codec variant satisfies this — all expose `safeParse`. Adapters only ever call `safeParse` — never key-dependent methods like `extractTimestamp`, `wrap`, or `unwrap`. */
13
+ type IdCodec<Brand extends string> = {
14
+ safeParse(value: unknown): ParseResult<Brand>;
15
+ };
16
+ /** Re-exported from ORM adapter subpaths (`@smonn/ids/drizzle`, `@smonn/ids/prisma`, `@smonn/ids/kysely`) under the public name; structurally identical to {@link IdCodec}. */
17
+ type IdColumnCodec<Brand extends string> = IdCodec<Brand>;
18
+ //#endregion
19
+ export { IdColumnCodec as n, IdParamFailure as r, IdCodec as t };
20
+ //# sourceMappingURL=adapter-types-unUcmMXC.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-types-unUcmMXC.d.mts","names":[],"sources":["../src/adapter-types.ts"],"mappings":";;;;KAIY,cAAA;EAAA,SACG,MAAA;EAAA,SAAmC,MAAA;AAAA;EAAA,SACnC,MAAA;EAAA,SAA8B,MAAA;AAAA;;KAGjC,OAAA;EACV,SAAA,CAAU,KAAA,YAAiB,WAAA,CAAY,KAAA;AAAA;AADzC;AAAA,KAKY,aAAA,yBAAsC,OAAA,CAAQ,KAAA"}