@smonn/ids 0.3.4 → 0.4.0
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 +34 -34
- package/dist/cli.mjs +266 -225
- package/dist/cli.mjs.map +1 -1
- package/dist/codec-shell-C0arqqX3.mjs.map +1 -1
- package/dist/index.d.mts +9 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{opaque-B-ttBfHO.mjs → opaque-CX-Lc5B9.mjs} +18 -6
- package/dist/opaque-CX-Lc5B9.mjs.map +1 -0
- package/dist/opaque.d.mts +11 -13
- package/dist/opaque.d.mts.map +1 -1
- package/dist/opaque.mjs +2 -2
- package/dist/{id-CcoPE2EX.mjs → timestamp-BjdAetut.mjs} +7 -7
- package/dist/timestamp-BjdAetut.mjs.map +1 -0
- package/dist/{types-Bv-63YK4.d.mts → types-g7CiQDyE.d.mts} +2 -2
- package/dist/{types-Bv-63YK4.d.mts.map → types-g7CiQDyE.d.mts.map} +1 -1
- package/package.json +1 -1
- package/dist/id-CcoPE2EX.mjs.map +0 -1
- package/dist/opaque-B-ttBfHO.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -6,16 +6,16 @@ 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 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.
|
|
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 Timestamp 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
|
|
|
13
13
|
### "Give my entities IDs that are safe to expose in URLs, dashboards, and support tickets"
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
|
-
import {
|
|
16
|
+
import { createTimestampId } from "@smonn/ids";
|
|
17
17
|
|
|
18
|
-
const users =
|
|
18
|
+
const users = createTimestampId("usr");
|
|
19
19
|
const id = users.generate(); // "usr_01h7b3k9rqxn4cw3p9r8t2sgkz"
|
|
20
20
|
```
|
|
21
21
|
|
|
@@ -24,10 +24,10 @@ The three-letter brand tells you what kind of thing the ID refers to without an
|
|
|
24
24
|
### "Catch me passing a `UserId` where I needed an `OrgId`"
|
|
25
25
|
|
|
26
26
|
```ts
|
|
27
|
-
import { type Id,
|
|
27
|
+
import { type Id, createTimestampId } from "@smonn/ids";
|
|
28
28
|
|
|
29
|
-
const users =
|
|
30
|
-
const orgs =
|
|
29
|
+
const users = createTimestampId("usr");
|
|
30
|
+
const orgs = createTimestampId("org");
|
|
31
31
|
|
|
32
32
|
function loadUser(id: Id<"usr">) {
|
|
33
33
|
/* ... */
|
|
@@ -105,7 +105,7 @@ Caveat: two IDs generated in the same millisecond by the same process have indep
|
|
|
105
105
|
### "Inject a fixed clock and RNG so my tests are deterministic"
|
|
106
106
|
|
|
107
107
|
```ts
|
|
108
|
-
const users =
|
|
108
|
+
const users = createTimestampId("usr", {
|
|
109
109
|
now: () => new Date("2026-01-01T00:00:00Z").getTime(),
|
|
110
110
|
rng: (target) => {}, // leave target as zero-filled
|
|
111
111
|
});
|
|
@@ -113,14 +113,14 @@ const users = createId("usr", {
|
|
|
113
113
|
users.generate(); // deterministic snapshot-friendly output
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
Both `
|
|
116
|
+
Both injection fields (`now?` and `rng?`) are optional. Defaults are `Date.now` and an entropy harvester built on `crypto.randomUUID` (faster than `crypto.getRandomValues` for the 10-byte fills this library needs). `now` returns milliseconds since the Unix epoch. `rng` writes random bytes into the provided target (a 10-byte view into the codec's persistent buffer), so a custom RNG never has to allocate.
|
|
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 `
|
|
120
|
+
The intended pattern is one codec per brand per process, constructed at module init. Calling `createTimestampId(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: `createTimestampId("usr")` followed by `createOpaqueTimestampId("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
|
-
const users =
|
|
123
|
+
const users = createTimestampId("usr", { allowDuplicateBrand: true });
|
|
124
124
|
```
|
|
125
125
|
|
|
126
126
|
The check is a heuristic, not a guarantee. Two physical copies of `@smonn/ids` loaded into the same process (the worst-case bundling bug) each keep their own registry, so neither warns — it catches re-imports of a single module copy, not duplicate copies of the module itself.
|
|
@@ -166,13 +166,13 @@ The `pattern` describes the **canonical form only** — it matches `generate()`
|
|
|
166
166
|
|
|
167
167
|
### "Don't leak creation time in IDs that customers can see"
|
|
168
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.
|
|
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 Timestamp codec at `@smonn/ids/opaque`. Same `<brand>_<26 chars>` wire shape, but the payload is AES-encrypted under a key you supply.
|
|
170
170
|
|
|
171
171
|
```ts
|
|
172
|
-
import {
|
|
172
|
+
import { createOpaqueTimestampId, importOpaqueKey } from "@smonn/ids/opaque";
|
|
173
173
|
|
|
174
174
|
const key = await importOpaqueKey(new Uint8Array(16)); // 128- or 256-bit raw key
|
|
175
|
-
const invoices =
|
|
175
|
+
const invoices = createOpaqueTimestampId("inv", { key });
|
|
176
176
|
|
|
177
177
|
const id = await invoices.generate(); // "inv_…", timestamp not extractable without the key
|
|
178
178
|
await invoices.extractTimestamp(id); // Date — same codec, same key required
|
|
@@ -181,7 +181,7 @@ await invoices.extractTimestamp(id); // Date — same codec, same key required
|
|
|
181
181
|
Three differences from the Timestamp codec:
|
|
182
182
|
|
|
183
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
|
|
184
|
+
- **No `minIdForTime` / `maxIdForTime`.** Encrypted payloads don't sort by time. If you need time-range scans on entities using the Opaque Timestamp codec, store the timestamp in a separate column.
|
|
185
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
186
|
|
|
187
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.
|
|
@@ -193,45 +193,45 @@ To store or transport key material outside the library, `encodeOpaqueKey` / `dec
|
|
|
193
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.
|
|
194
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.
|
|
195
195
|
- **Distributed-trace / request-correlation IDs.** Use OpenTelemetry-format IDs.
|
|
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).
|
|
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 Timestamp codec (above).
|
|
197
197
|
|
|
198
198
|
## API surface
|
|
199
199
|
|
|
200
200
|
```ts
|
|
201
201
|
import {
|
|
202
|
-
|
|
202
|
+
createTimestampId, // (brand: string, opts?: TimestampOptions) => TimestampCodec<Brand>
|
|
203
203
|
type Id, // branded string type
|
|
204
|
-
type
|
|
205
|
-
type
|
|
204
|
+
type TimestampCodec, // returned by createTimestampId
|
|
205
|
+
type TimestampOptions, // { now?, rng?, allowDuplicateBrand? } constructor options
|
|
206
206
|
type ParseError, // "not_string" | "invalid_prefix" | "invalid_base32"
|
|
207
207
|
type ParseResult, // safeParse return type
|
|
208
208
|
type JsonSchema, // toJsonSchema return type
|
|
209
209
|
} from "@smonn/ids";
|
|
210
210
|
|
|
211
211
|
import {
|
|
212
|
-
|
|
212
|
+
createOpaqueTimestampId, // (brand: string, opts: OpaqueTimestampOptions) => OpaqueTimestampCodec<Brand>
|
|
213
213
|
importOpaqueKey, // (bytes: Uint8Array) => Promise<CryptoKey>
|
|
214
214
|
encodeOpaqueKey, // (bytes: Uint8Array, format: OpaqueKeyFormat) => string
|
|
215
215
|
decodeOpaqueKey, // (encoded: string, format: OpaqueKeyFormat) => Uint8Array
|
|
216
|
-
type
|
|
217
|
-
type
|
|
216
|
+
type OpaqueTimestampCodec, // returned by createOpaqueTimestampId
|
|
217
|
+
type OpaqueTimestampOptions, // { key, now?, rng?, allowDuplicateBrand? } constructor options
|
|
218
218
|
type OpaqueKeyFormat, // "hex" | "base64url"
|
|
219
219
|
} from "@smonn/ids/opaque";
|
|
220
220
|
```
|
|
221
221
|
|
|
222
222
|
### Codec methods
|
|
223
223
|
|
|
224
|
-
| Method | `
|
|
225
|
-
| ---------------------- |
|
|
226
|
-
| `generate()` | sync
|
|
227
|
-
| `generateAt(date)` | sync
|
|
228
|
-
| `is(value)` | sync
|
|
229
|
-
| `parse(value)` | sync
|
|
230
|
-
| `safeParse(value)` | sync
|
|
231
|
-
| `extractTimestamp(id)` | sync
|
|
232
|
-
| `minIdForTime(date)` | sync
|
|
233
|
-
| `maxIdForTime(date)` | sync
|
|
234
|
-
| `toJsonSchema()` | sync
|
|
224
|
+
| Method | `TimestampCodec<Brand>` | `OpaqueTimestampCodec<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 |
|
|
235
235
|
|
|
236
236
|
## CLI
|
|
237
237
|
|
|
@@ -249,7 +249,7 @@ canonical: usr_01h7b3k9rqxn1cw3p9r8t2sgkz
|
|
|
249
249
|
input: canonical
|
|
250
250
|
```
|
|
251
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.
|
|
252
|
+
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.
|
|
253
253
|
|
|
254
254
|
```bash
|
|
255
255
|
IDS_KEY=<hex-or-base64url-key> npx @smonn/ids inspect inv_… --opaque
|
|
@@ -268,7 +268,7 @@ usr_…
|
|
|
268
268
|
usr_…
|
|
269
269
|
```
|
|
270
270
|
|
|
271
|
-
Flags: `--count` / `-c N` (default 1). Uses the Timestamp codec unless `--opaque` is set.
|
|
271
|
+
Flags: `--count` / `-c N` (default 1, max 10000). Uses the Timestamp codec unless `--opaque` is set.
|
|
272
272
|
|
|
273
273
|
```bash
|
|
274
274
|
IDS_KEY=<hex-or-base64url-key> npx @smonn/ids generate inv --opaque --count 2
|