@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.
- package/README.md +45 -16
- 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
|
|
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
|
|
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
|
-
###
|
|
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
|
|