@smonn/ids 0.3.0 → 0.3.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 (2) hide show
  1. package/README.md +45 -16
  2. package/package.json +1 -1
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 default 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. A second 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,34 @@ 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 default 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 default 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 default 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
+
167
189
  ## What this is **not** for
168
190
 
169
191
  - **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
192
  - **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
193
  - **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.
194
+ - **Hiding creation time with the default 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
195
 
174
196
  ## API surface
175
197
 
@@ -183,21 +205,28 @@ import {
183
205
  type ParseResult, // safeParse return type
184
206
  type JsonSchema, // toJsonSchema return type
185
207
  } from "@smonn/ids";
208
+
209
+ import {
210
+ createOpaqueId, // (brand: string, opts: { key, now?, rng?, allowDuplicateBrand? }) => OpaqueCodec<Brand>
211
+ importOpaqueKey, // (bytes: Uint8Array) => Promise<CryptoKey>
212
+ type OpaqueCodec, // returned by createOpaqueId
213
+ type OpaqueOptions, // { key, now, rng, allowDuplicateBrand } injection points
214
+ } from "@smonn/ids/opaque";
186
215
  ```
187
216
 
188
- ### `Codec<Brand>`
189
-
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 |
217
+ ### Codec methods
218
+
219
+ | Method | `Codec<Brand>` | `OpaqueCodec<Brand>` | Description |
220
+ | ---------------------- | -------------- | -------------------- | ----------------------------------------------------------------------------- |
221
+ | `generate()` | sync | async | Produce a fresh ID |
222
+ | `generateAt(date)` | sync | async | Produce a fresh ID with timestamp bytes from `date` (for backfills) |
223
+ | `is(value)` | sync | sync | Strict type guard: `true` only for already-canonical strings |
224
+ | `parse(value)` | sync | sync | Lenient: normalise to canonical, or throw |
225
+ | `safeParse(value)` | sync | sync | Lenient: normalise to canonical, or return `{ ok: false, error }` |
226
+ | `extractTimestamp(id)` | sync | async | Decode the creation `Date` from an `Id<Brand>` (trusts the type) |
227
+ | `minIdForTime(date)` | sync | — | Tight lower bound for any ID generated at `date` (for range queries) |
228
+ | `maxIdForTime(date)` | sync | — | Tight upper bound for any ID generated at `date` (for range queries) |
229
+ | `toJsonSchema()` | sync | sync | JSON Schema (`type`/`pattern`/`description`/`example`) for the canonical form |
201
230
 
202
231
  ## CLI
203
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smonn/ids",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "author": "Simon Ingeson (https://github.com/smonn)",
6
6
  "repository": {