@smonn/ids 0.9.1 β 0.9.3
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 +60 -1003
- package/dist/cli.mjs +355 -575
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,1041 +1,98 @@
|
|
|
1
1
|
# @smonn/ids
|
|
2
2
|
|
|
3
|
-
Public-facing branded IDs for TypeScript apps.
|
|
3
|
+
Public-facing branded IDs for TypeScript apps. Type-safe, sortable, and codec-pluggable.
|
|
4
|
+
|
|
5
|
+
π **Full documentation & interactive playground: [ids.smonn.se](https://ids.smonn.se)**
|
|
4
6
|
|
|
5
7
|
```bash
|
|
6
8
|
pnpm add @smonn/ids
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
Each ID looks like `usr_01h7b3k9rqxn4cw3p9r8t2sgkz`: a three-letter brand, an
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
Each ID looks like `usr_01h7b3k9rqxn4cw3p9r8t2sgkz`: a three-letter brand, an
|
|
12
|
+
underscore, then 26 Crockford base32 characters of payload. The default
|
|
13
|
+
Timestamp codec encodes a 48-bit millisecond Unix timestamp followed by 80
|
|
14
|
+
random bits β the same byte layout as a [ULID](https://github.com/ulid/spec).
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
import { createTimestampId } from "@smonn/ids";
|
|
17
|
-
|
|
18
|
-
const users = createTimestampId("usr");
|
|
19
|
-
const id = users.generate(); // "usr_01h7b3k9rqxn4cw3p9r8t2sgkz"
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
The three-letter brand tells you what kind of thing the ID refers to without an out-of-band lookup. No leaking row counts via sequential PKs, no slug collisions, no "is this a user or an org?" ambiguity in a stack trace.
|
|
23
|
-
|
|
24
|
-
### "Catch me passing a `UserId` where I needed an `OrgId`"
|
|
16
|
+
## Quickstart
|
|
25
17
|
|
|
26
18
|
```ts
|
|
27
19
|
import { type Id, createTimestampId } from "@smonn/ids";
|
|
28
20
|
|
|
29
21
|
const users = createTimestampId("usr");
|
|
30
|
-
const orgs = createTimestampId("org");
|
|
31
22
|
|
|
23
|
+
// Generate β sortable by creation time via ORDER BY id
|
|
24
|
+
const id = users.generate(); // "usr_01h7b3k9rqxn4cw3p9r8t2sgkz"
|
|
25
|
+
|
|
26
|
+
// Branded: Id<"usr"> and Id<"org"> are not interchangeable
|
|
32
27
|
function loadUser(id: Id<"usr">) {
|
|
33
28
|
/* ... */
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
### "A support agent emailed me an ID β accept it even if they typed it wrong"
|
|
42
|
-
|
|
43
|
-
```ts
|
|
44
|
-
users.safeParse("usr_01h7b3k9rqxn1cw3p9r8t2sgkz"); // canonical
|
|
45
|
-
users.safeParse("USR_01H7B3K9RQXN1CW3P9R8T2SGKZ"); // uppercase
|
|
46
|
-
users.safeParse("usr_Olh7b3k9rqxnIcw3p9r8t2sgkz"); // o, I, l aliased
|
|
47
|
-
// β { ok: true, id: "usr_01h7b3k9rqxn1cw3p9r8t2sgkz" } for all three
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
`safeParse` accepts mixed case and the Crockford-spec visual aliases (`o β 0`, `i β 1`, `l β 1`), and always returns the **canonical form** β lowercase, aliases resolved. Equality checks on canonical strings work as expected.
|
|
51
|
-
|
|
52
|
-
### "Validate an ID arriving from a URL or request body"
|
|
53
|
-
|
|
54
|
-
```ts
|
|
55
|
-
const r = users.safeParse(input);
|
|
56
|
-
|
|
57
|
-
if (!r.ok) {
|
|
58
|
-
switch (r.error) {
|
|
59
|
-
case "not_string":
|
|
60
|
-
return 400; // wasn't a string at all
|
|
61
|
-
case "invalid_prefix":
|
|
62
|
-
return 404; // wrong kind of ID (or not an ID)
|
|
63
|
-
case "invalid_base32":
|
|
64
|
-
return 400; // prefix matched but payload is malformed
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const userId = r.id; // Id<"usr">, canonical
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
`ParseError` is exported as a literal union so the switch is exhaustive at compile time.
|
|
72
|
-
|
|
73
|
-
### "Handle structured errors from this library"
|
|
74
|
-
|
|
75
|
-
`parse()`, `unwrap()`, the ORM adapter read paths, and the codec constructors all throw `IdsError` on failure β a single class with a stable `code` field for programmatic branching. Use `isIdsError()` to safely identify them without depending on `instanceof` across module copies:
|
|
76
|
-
|
|
77
|
-
```ts
|
|
78
|
-
import { isIdsError } from "@smonn/ids";
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
users.parse(rawInput);
|
|
82
|
-
} catch (err) {
|
|
83
|
-
if (isIdsError(err)) {
|
|
84
|
-
switch (err.code) {
|
|
85
|
-
case "invalid_id": // parse failed; err.cause is the ParseError string
|
|
86
|
-
return 400;
|
|
87
|
-
case "invalid_brand": // bad codec construction β fix the brand string
|
|
88
|
-
throw err;
|
|
89
|
-
// ...
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
throw err;
|
|
31
|
+
// Validate untrusted input β lenient in, canonical out
|
|
32
|
+
const r = users.safeParse("USR_01H7B3K9RQXN1CW3P9R8T2SGKZ");
|
|
33
|
+
if (r.ok) {
|
|
34
|
+
r.id; // "usr_01h7b3k9rqxn1cw3p9r8t2sgkz" as Id<"usr">
|
|
93
35
|
}
|
|
94
36
|
```
|
|
95
37
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
| `invalid_key_format` | format is not `hex` or `base64url` | `decodeOpaqueKey`, `decodeWrappingKey` | Pass `"hex"` or `"base64url"` |
|
|
102
|
-
| `invalid_key_encoding` | key string is malformed for its declared format | `decodeOpaqueKey`, `decodeWrappingKey` | Re-encode the key with the matching format |
|
|
103
|
-
| `invalid_key_length` | raw key is not 16, 24, or 32 bytes | `importOpaqueKey`, `importWrappingKey`, decoder functions | Use a valid AES key size |
|
|
104
|
-
| `invalid_kind` | wrapped kind is not `u32`/`i32`/`u64`/`i64` | `createWrappedKeyId({ kind })` | Use one of the four supported kinds |
|
|
105
|
-
| `empty_keyring` | the wrapping keyring is empty | `createWrappedKeyId({ keys })` | Supply at least one `WrappingKey` |
|
|
106
|
-
| `duplicate_keyring_entry` | two keyring entries share the same raw secret | `createWrappedKeyId({ keys })` | Deduplicate the key list |
|
|
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)`, `verify(id)` | Check keyring; tamper or wrong key |
|
|
109
|
-
| `invalid_id` | string is not a valid ID for this brand | `parse()`, ORM adapter read paths | Use `safeParse()` for untrusted input |
|
|
110
|
-
|
|
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.
|
|
112
|
-
|
|
113
|
-
`isIdsError()` uses a non-enumerable brand symbol rather than bare `instanceof`, so it works correctly even when multiple copies of `@smonn/ids` are loaded in the same process (the ESM + CJS dual-package hazard).
|
|
114
|
-
|
|
115
|
-
### "Sort and date-stamp records using just the ID"
|
|
116
|
-
|
|
117
|
-
The first 6 bytes of the payload are a big-endian millisecond Unix timestamp, so `ORDER BY id` sorts by creation time without a separate `created_at` column. To extract the timestamp from an existing ID:
|
|
118
|
-
|
|
119
|
-
```ts
|
|
120
|
-
users.extractTimestamp(id); // Date
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
For time-range queries, `minIdForTime(date)` and `maxIdForTime(date)` build synthetic IDs at the tight lower and upper bounds of a given millisecond β same timestamp bytes, random portion filled with all `0x00` (min) or all `0xFF` (max). No separate `created_at` column needed:
|
|
124
|
-
|
|
125
|
-
```ts
|
|
126
|
-
const start = new Date("2026-01-01T00:00:00Z");
|
|
127
|
-
const end = new Date("2026-02-01T00:00:00Z");
|
|
128
|
-
|
|
129
|
-
sql`SELECT * FROM users WHERE id BETWEEN ${users.minIdForTime(start)} AND ${users.maxIdForTime(end)}`;
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
Both validate the date the same way `generate()` does β pre-epoch or past the 48-bit ceiling throws.
|
|
133
|
-
|
|
134
|
-
To mint a real ID (random tail and all) at a timestamp you choose rather than at `now`, use `generateAt(date)`. The timestamp bytes come from the supplied `Date`; the random portion is filled by the codec's `rng`, so the result round-trips through `extractTimestamp` exactly:
|
|
135
|
-
|
|
136
|
-
```ts
|
|
137
|
-
const id = users.generateAt(new Date("2024-03-15T12:00:00Z")); // Id<"usr">
|
|
138
|
-
users.extractTimestamp(id); // β 2024-03-15T12:00:00.000Z
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
This is the one-liner for backfilling: migrating from UUIDv7 / ULID / Snowflake is `oldRows.map((r) => users.generateAt(extractTime(r)))`, with no need to spin up a throwaway codec per timestamp. It validates the date exactly like `generate()` β pre-epoch, past the 48-bit ceiling, or an `Invalid Date` throws.
|
|
142
|
-
|
|
143
|
-
The timestamp layout (millisecond precision, big-endian, Unix epoch) is part of the public contract β see [ADR-0002](./docs/adr/0002-payload-layout.md).
|
|
144
|
-
|
|
145
|
-
Caveat: two IDs generated in the same millisecond by the same process have independent random tails and do **not** sort deterministically relative to each other. If you need stable intra-millisecond ordering, this library isn't the right tool.
|
|
146
|
-
|
|
147
|
-
### "Inject a fixed clock and RNG so my tests are deterministic"
|
|
148
|
-
|
|
149
|
-
```ts
|
|
150
|
-
const users = createTimestampId("usr", {
|
|
151
|
-
now: () => new Date("2026-01-01T00:00:00Z").getTime(),
|
|
152
|
-
rng: (target) => {}, // leave target as zero-filled
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
users.generate(); // deterministic snapshot-friendly output
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
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.
|
|
159
|
-
|
|
160
|
-
### "Catch a double-registered brand before it bites in production"
|
|
161
|
-
|
|
162
|
-
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:
|
|
163
|
-
|
|
164
|
-
```ts
|
|
165
|
-
const users = createTimestampId("usr", { allowDuplicateBrand: true });
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
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.
|
|
169
|
-
|
|
170
|
-
### "Use with any Standard Schema validator"
|
|
171
|
-
|
|
172
|
-
Each codec implements [Standard Schema v1](https://standardschema.dev/), so it slots directly into any validator-aware library (Zod, Valibot, ArkType, tRPC inputs, Hono, etc.) without rewriting the same `z.string().refine(usr.is)` boilerplate:
|
|
173
|
-
|
|
174
|
-
```ts
|
|
175
|
-
import { type } from "arktype";
|
|
176
|
-
|
|
177
|
-
const Body = type({ userId: users });
|
|
178
|
-
|
|
179
|
-
const r = Body({ userId: "USR_01H7B3K9RQXN1CW3P9R8T2SGKZ" });
|
|
180
|
-
// β { userId: "usr_01h7b3k9rqxn1cw3p9r8t2sgkz" } typed as Id<"usr">
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
`validate` is synchronous, wraps `safeParse`, and returns the canonical `Id<Brand>` on success. Each `ParseError` variant maps to a distinct `issues[].message`:
|
|
184
|
-
|
|
185
|
-
| ParseError | message |
|
|
186
|
-
| ---------------- | ------------------------ |
|
|
187
|
-
| `not_string` | `expected string` |
|
|
188
|
-
| `invalid_prefix` | `expected prefix 'usr_'` |
|
|
189
|
-
| `invalid_base32` | `invalid base32 payload` |
|
|
190
|
-
|
|
191
|
-
### "Describe an ID field in an OpenAPI / JSON Schema spec"
|
|
192
|
-
|
|
193
|
-
```ts
|
|
194
|
-
users.toJsonSchema();
|
|
195
|
-
// {
|
|
196
|
-
// type: "string",
|
|
197
|
-
// pattern: "^usr_[0-9a-hjkmnp-tv-z]{26}$",
|
|
198
|
-
// description: "Branded ID for 'usr'",
|
|
199
|
-
// example: "usr_01h7b3k9rqxn1cw3p9r8t2sgkz",
|
|
200
|
-
// }
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
`toJsonSchema()` returns a plain object you can drop straight into an OpenAPI `components.schemas` entry, a JSON Schema document, or any tool that derives sample payloads from `example`. The character class `[0-9a-hjkmnp-tv-z]` is the lowercase Crockford base32 alphabet (excludes `i`, `l`, `o`, `u`).
|
|
204
|
-
|
|
205
|
-
The `pattern` describes the **canonical form only** β it matches `generate()` output and what `is()` accepts, but rejects uppercase and the Crockford aliases (`o`, `i`, `l`) that `safeParse()` tolerates. Normalising lenient input is the codec's job at the boundary; an artefact that describes data at rest describes the canonical wire shape (see [ADR-0003](./docs/adr/0003-canonical-strict-is.md)).
|
|
206
|
-
|
|
207
|
-
`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.
|
|
208
|
-
|
|
209
|
-
### "Validate a route param in Hono"
|
|
210
|
-
|
|
211
|
-
`@smonn/ids/hono` provides `idParam` β a middleware factory that validates a named route param against a codec and exposes the canonical `Id<Brand>` to the handler. Hono is an **optional peer dependency**; install it separately alongside `@smonn/ids`.
|
|
212
|
-
|
|
213
|
-
```bash
|
|
214
|
-
pnpm add hono
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
```ts
|
|
218
|
-
import { idParam } from "@smonn/ids/hono";
|
|
219
|
-
import { createTimestampId } from "@smonn/ids";
|
|
220
|
-
|
|
221
|
-
const usr = createTimestampId("usr");
|
|
222
|
-
|
|
223
|
-
// Default: throws HTTPException β app.onError handles rendering (HTML, JSON, problem+json, β¦)
|
|
224
|
-
app.get("/users/:id", idParam("id", usr), (c) => {
|
|
225
|
-
const id = c.get("id"); // Id<"usr">, canonical
|
|
226
|
-
// β¦
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// Override: consumer fully owns the error response
|
|
230
|
-
app.get(
|
|
231
|
-
"/orgs/:id",
|
|
232
|
-
idParam("id", org, {
|
|
233
|
-
onError: (failure, c) => c.json({ error: failure.reason }, failure.status),
|
|
234
|
-
}),
|
|
235
|
-
handler,
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
// Or a lightweight status remap without a full handler
|
|
239
|
-
app.get("/things/:id", idParam("id", thing, { status: { brand_mismatch: 400 } }), handler);
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
**Default error-channel behavior:** on failure the adapter throws `HTTPException(status)` β it does **not** write a response body itself. This lets the app's existing `app.onError` handler control content negotiation (HTML, JSON, problem+json, redirect, etc.) exactly as it would for any other error.
|
|
243
|
-
|
|
244
|
-
**`options.onError`:** when provided, the hook owns the response entirely β the adapter neither throws nor writes anything.
|
|
245
|
-
|
|
246
|
-
**`options.status`:** remaps the default HTTP status for a failure reason without requiring a full handler.
|
|
247
|
-
|
|
248
|
-
**400 vs 404 defaults:**
|
|
249
|
-
|
|
250
|
-
- **Brand mismatch (`invalid_prefix`) β `reason: "brand_mismatch"`, status 404.** The resource cannot exist under this route β a `usr_` ID makes no sense on `/orders/:id`.
|
|
251
|
-
- **Malformed or missing ID (`invalid_base32` or `not_string`) β `reason: "malformed"`, status 400.** The ID is absent or unreadable β a bad request, not a missing resource.
|
|
252
|
-
|
|
253
|
-
`idParam` calls `safeParse` at the boundary (lenient: accepts mixed case and Crockford aliases), so the handler always receives a canonical, normalized `Id<Brand>` β never the raw URL string. Works with any codec variant's structural `safeParse`.
|
|
254
|
-
|
|
255
|
-
### "Validate a route param in Express"
|
|
256
|
-
|
|
257
|
-
`@smonn/ids/express` provides the same `idParam` factory for Express. Express is an **optional peer dependency**; install it separately alongside `@smonn/ids`.
|
|
258
|
-
|
|
259
|
-
```bash
|
|
260
|
-
pnpm add express
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
```ts
|
|
264
|
-
import { idParam, IdParamError } from "@smonn/ids/express";
|
|
265
|
-
import { createTimestampId } from "@smonn/ids";
|
|
266
|
-
|
|
267
|
-
const usr = createTimestampId("usr");
|
|
268
|
-
|
|
269
|
-
// Default: calls next(err) with an IdParamError β app error-handling middleware renders it
|
|
270
|
-
app.get("/users/:id", idParam("id", usr), (req, res) => {
|
|
271
|
-
const id = res.locals.id; // Id<"usr">, canonical
|
|
272
|
-
// β¦
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
// Error-handling middleware receives the typed error
|
|
276
|
-
app.use((err, req, res, next) => {
|
|
277
|
-
if (err instanceof IdParamError) {
|
|
278
|
-
res.status(err.status).json({ error: err.reason });
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
next(err);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
// Override: consumer fully owns the error response
|
|
285
|
-
app.get(
|
|
286
|
-
"/orgs/:id",
|
|
287
|
-
idParam("id", org, {
|
|
288
|
-
onError: (failure, req, res) => res.status(failure.status).json({ error: failure.reason }),
|
|
289
|
-
}),
|
|
290
|
-
handler,
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
// Or a lightweight status remap without a full handler
|
|
294
|
-
app.get("/things/:id", idParam("id", thing, { status: { brand_mismatch: 400 } }), handler);
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
**Default error-channel behavior:** on failure the adapter calls `next(err)` with an `IdParamError` carrying `status` and `reason` β it does **not** write a response body itself. This lets the app's existing error-handling middleware control rendering exactly as it would for any other error.
|
|
298
|
-
|
|
299
|
-
**`options.onError`:** when provided, the hook owns the response entirely β the adapter does not call `next(err)`.
|
|
300
|
-
|
|
301
|
-
**`options.status`:** remaps the default HTTP status for a failure reason without requiring a full handler.
|
|
302
|
-
|
|
303
|
-
The 400 vs 404 defaults are identical to the Hono adapter: `reason: "brand_mismatch"` β 404, `reason: "malformed"` β 400. The canonical `Id<Brand>` is stored in `res.locals` under `paramName` and available to downstream handlers.
|
|
304
|
-
|
|
305
|
-
### "Validate a route param in Fastify"
|
|
306
|
-
|
|
307
|
-
`@smonn/ids/fastify` provides the same `idParam` factory for Fastify. Fastify is an **optional peer dependency**; install it separately alongside `@smonn/ids`.
|
|
308
|
-
|
|
309
|
-
```bash
|
|
310
|
-
pnpm add fastify
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
```ts
|
|
314
|
-
import { idParam, IdParamError } from "@smonn/ids/fastify";
|
|
315
|
-
import { createTimestampId } from "@smonn/ids";
|
|
316
|
-
|
|
317
|
-
const usr = createTimestampId("usr");
|
|
318
|
-
|
|
319
|
-
// Default: throws IdParamError β setErrorHandler renders it
|
|
320
|
-
fastify.get<{ Params: { id: string } }>(
|
|
321
|
-
"/users/:id",
|
|
322
|
-
{
|
|
323
|
-
preHandler: idParam("id", usr),
|
|
324
|
-
},
|
|
325
|
-
(request, reply) => {
|
|
326
|
-
const id = request.params.id; // string; use `as Id<"usr">` if the narrowed type is needed
|
|
327
|
-
// β¦
|
|
328
|
-
},
|
|
329
|
-
);
|
|
330
|
-
|
|
331
|
-
// Error handler receives the typed error
|
|
332
|
-
fastify.setErrorHandler((err, request, reply) => {
|
|
333
|
-
if (err instanceof IdParamError) {
|
|
334
|
-
reply.status(err.statusCode).send({ error: err.reason });
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
reply.send(err);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
// Override: consumer fully owns the error response
|
|
341
|
-
fastify.get(
|
|
342
|
-
"/orgs/:id",
|
|
343
|
-
{
|
|
344
|
-
preHandler: idParam("id", org, {
|
|
345
|
-
onError: (failure, request, reply) =>
|
|
346
|
-
reply.status(failure.status).send({ error: failure.reason }),
|
|
347
|
-
}),
|
|
348
|
-
},
|
|
349
|
-
handler,
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
// Or a lightweight status remap without a full handler
|
|
353
|
-
fastify.get(
|
|
354
|
-
"/things/:id",
|
|
355
|
-
{
|
|
356
|
-
preHandler: idParam("id", thing, { status: { brand_mismatch: 400 } }),
|
|
357
|
-
},
|
|
358
|
-
handler,
|
|
359
|
-
);
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
**Default error-channel behavior:** on failure the adapter throws `IdParamError` carrying `statusCode` and `reason` β it does **not** write a response body itself. Fastify's `setErrorHandler` receives the error and controls rendering exactly as it would for any other error.
|
|
363
|
-
|
|
364
|
-
**`options.onError`:** when provided, the hook owns the response entirely β the adapter does not throw.
|
|
365
|
-
|
|
366
|
-
**`options.status`:** remaps the default HTTP status for a failure reason without requiring a full handler.
|
|
367
|
-
|
|
368
|
-
The 400 vs 404 defaults are identical to the Hono and Express adapters: `reason: "brand_mismatch"` β 404, `reason: "malformed"` β 400. The canonical `Id<Brand>` is stored in `request.params` under `paramName`. Works with any codec variant's structural `safeParse`.
|
|
369
|
-
|
|
370
|
-
### "Don't leak creation time in IDs that customers can see"
|
|
371
|
-
|
|
372
|
-
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.
|
|
373
|
-
|
|
374
|
-
```ts
|
|
375
|
-
import { createOpaqueTimestampId, importOpaqueKey } from "@smonn/ids/opaque";
|
|
376
|
-
|
|
377
|
-
const key = await importOpaqueKey(new Uint8Array(16)); // returns an OpaqueKey handle
|
|
378
|
-
const invoices = createOpaqueTimestampId("inv", { key });
|
|
379
|
-
|
|
380
|
-
const id = await invoices.generate(); // "inv_β¦", timestamp not extractable without the key
|
|
381
|
-
await invoices.extractTimestamp(id); // Date β same codec, same key required
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
Three differences from the Timestamp codec:
|
|
385
|
-
|
|
386
|
-
- **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)).
|
|
387
|
-
- **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.
|
|
388
|
-
- **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)).
|
|
389
|
-
|
|
390
|
-
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.
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
### "Newest-first IDs for descending range scans"
|
|
417
|
-
|
|
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.
|
|
419
|
-
|
|
420
|
-
```ts
|
|
421
|
-
import { createReverseTimestampId } from "@smonn/ids/reverse";
|
|
422
|
-
|
|
423
|
-
const events = createReverseTimestampId("evt");
|
|
424
|
-
|
|
425
|
-
const id = events.generate(); // "evt_β¦", sorts newest-first
|
|
426
|
-
events.extractTimestamp(id); // Date β inversion is reversed to recover the original ms
|
|
427
|
-
```
|
|
38
|
+
`safeParse` accepts mixed case and the Crockford visual aliases (`o β 0`,
|
|
39
|
+
`i β 1`, `l β 1`) and always returns the canonical lowercase form. See the
|
|
40
|
+
[Timestamp codec guide](https://ids.smonn.se/codecs/timestamp/) for sorting,
|
|
41
|
+
backfills (`generateAt`), range queries, structured errors, Standard Schema, and
|
|
42
|
+
JSON Schema.
|
|
428
43
|
|
|
429
|
-
|
|
44
|
+
## Choosing a codec
|
|
430
45
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
46
|
+
All five codecs share the same `<brand>_<26 chars>` wire shape but make different
|
|
47
|
+
trade-offs. They are wire-indistinguishable, so codec choice is a per-brand
|
|
48
|
+
commitment.
|
|
434
49
|
|
|
435
|
-
|
|
436
|
-
|
|
50
|
+
| Codec | Import | Sort direction | Key required | Timestamp extractable |
|
|
51
|
+
| ----------------- | -------------------- | ------------------------- | ------------------ | -------------------------- |
|
|
52
|
+
| Timestamp | `@smonn/ids` | Ascending (oldest-first) | No | Always (plaintext) |
|
|
53
|
+
| Reverse Timestamp | `@smonn/ids/reverse` | Descending (newest-first) | No | Always (plaintext) |
|
|
54
|
+
| Signed Timestamp | `@smonn/ids/signed` | Ascending (oldest-first) | Yes (signing key) | Always (plaintext) |
|
|
55
|
+
| Opaque Timestamp | `@smonn/ids/opaque` | None (encrypted) | Yes (AES key) | With key only |
|
|
56
|
+
| Wrapped key | `@smonn/ids/wrapped` | None | Yes (wrapping key) | N/A β not timestamp-family |
|
|
437
57
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
58
|
+
- **Newest-first scans** on forward-only KV stores β [Reverse Timestamp](https://ids.smonn.se/codecs/reverse/)
|
|
59
|
+
- **Tamper-evident share links** verified without a DB lookup β [Signed Timestamp](https://ids.smonn.se/codecs/signed/) (integrity)
|
|
60
|
+
- **IDs that must not leak creation time** β [Opaque Timestamp](https://ids.smonn.se/codecs/opaque/) (confidentiality)
|
|
61
|
+
- **A public handle for an internal integer PK** β [Wrapped key](https://ids.smonn.se/codecs/wrapped/)
|
|
441
62
|
|
|
442
|
-
|
|
63
|
+
Try them all live in the [playground](https://ids.smonn.se/playground/).
|
|
443
64
|
|
|
444
|
-
|
|
65
|
+
## Integrations
|
|
445
66
|
|
|
446
|
-
|
|
67
|
+
Framework and ORM adapters ship as optional subpath exports (each requires its
|
|
68
|
+
own peer dependency):
|
|
447
69
|
|
|
448
|
-
|
|
70
|
+
- **HTTP route params:** [Hono](https://ids.smonn.se/adapters/hono/), [Express](https://ids.smonn.se/adapters/express/), [Fastify](https://ids.smonn.se/adapters/fastify/) β `idParam` middleware
|
|
71
|
+
- **ORM columns:** [Drizzle](https://ids.smonn.se/adapters/drizzle/), [Kysely](https://ids.smonn.se/adapters/kysely/), [Prisma](https://ids.smonn.se/adapters/prisma/)
|
|
72
|
+
- **CLI:** brand-agnostic `inspect` / `generate` / `keygen` β `npx @smonn/ids --help` ([docs](https://ids.smonn.se/cli/))
|
|
449
73
|
|
|
450
|
-
|
|
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 | None β encrypted payloads do not sort by time |
|
|
456
|
-
| Wrapped key | `@smonn/ids/wrapped` | None | Yes (wrapping key) | N/A β not timestamp-family | None |
|
|
74
|
+
Every codec also implements [Standard Schema v1](https://standardschema.dev/), so
|
|
75
|
+
it slots into Zod, Valibot, ArkType, tRPC, and any validator-aware library.
|
|
457
76
|
|
|
458
77
|
## What this is **not** for
|
|
459
78
|
|
|
460
|
-
- **Internal surrogate primary keys.** If nobody outside your service
|
|
461
|
-
|
|
79
|
+
- **Internal surrogate primary keys.** If nobody outside your service sees the
|
|
80
|
+
ID, the brand prefix and lenient parsing are dead weight. Use a `bigint`
|
|
81
|
+
sequence.
|
|
82
|
+
- **Wire-compatible ULIDs.** The byte layout is ULID-shaped, but the encoding is
|
|
83
|
+
lowercase and brand-wrapped. Stock ULID parsers will reject these.
|
|
462
84
|
- **Distributed-trace / request-correlation IDs.** Use OpenTelemetry-format IDs.
|
|
463
|
-
- **Hiding creation time with the Timestamp codec.** Anyone with one ID at a
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
```ts
|
|
468
|
-
import {
|
|
469
|
-
IdsError, // class β thrown by caller-reachable failures; carries a stable `code`
|
|
470
|
-
isIdsError, // (value: unknown) => value is IdsError β brand check, survives dual-package
|
|
471
|
-
type IdsErrorCode, // "invalid_brand" | "invalid_key_format" | ... (10 members)
|
|
472
|
-
createTimestampId, // (brand: string, opts?: TimestampOptions) => TimestampCodec<Brand>
|
|
473
|
-
type Id, // branded string type
|
|
474
|
-
type TimestampCodec, // returned by createTimestampId
|
|
475
|
-
type TimestampOptions, // { now?, rng?, allowDuplicateBrand? } constructor options
|
|
476
|
-
type ParseError, // "not_string" | "invalid_prefix" | "invalid_base32"
|
|
477
|
-
type ParseResult, // safeParse return type
|
|
478
|
-
type JsonSchema, // toJsonSchema return type
|
|
479
|
-
} from "@smonn/ids";
|
|
480
|
-
|
|
481
|
-
import {
|
|
482
|
-
IdsError, // re-exported for convenience β same class as "@smonn/ids"
|
|
483
|
-
isIdsError, // re-exported for convenience β same guard as "@smonn/ids"
|
|
484
|
-
type IdsErrorCode, // re-exported for convenience β same union as "@smonn/ids"
|
|
485
|
-
createOpaqueTimestampId, // (brand: string, opts: OpaqueTimestampOptions) => OpaqueTimestampCodec<Brand>
|
|
486
|
-
importOpaqueKey, // (bytes: Uint8Array) => Promise<OpaqueKey>
|
|
487
|
-
encodeOpaqueKey, // (bytes: Uint8Array, format: OpaqueKeyFormat) => string
|
|
488
|
-
decodeOpaqueKey, // (encoded: string, format: OpaqueKeyFormat) => Uint8Array
|
|
489
|
-
type OpaqueTimestampCodec, // returned by createOpaqueTimestampId
|
|
490
|
-
type OpaqueTimestampOptions, // { key: OpaqueKey, now?, rng?, allowDuplicateBrand? } constructor options
|
|
491
|
-
type OpaqueKey, // opaque imported handle for AES key material
|
|
492
|
-
type OpaqueKeyFormat, // "hex" | "base64url"
|
|
493
|
-
} from "@smonn/ids/opaque";
|
|
494
|
-
|
|
495
|
-
import {
|
|
496
|
-
IdsError, // re-exported for convenience β same class as "@smonn/ids"
|
|
497
|
-
isIdsError, // re-exported for convenience β same guard as "@smonn/ids"
|
|
498
|
-
type IdsErrorCode, // re-exported for convenience β same union as "@smonn/ids"
|
|
499
|
-
createReverseTimestampId, // (brand: string, opts?: ReverseTimestampOptions) => ReverseTimestampCodec<Brand>
|
|
500
|
-
type ReverseTimestampCodec, // returned by createReverseTimestampId
|
|
501
|
-
type ReverseTimestampOptions, // { now?, rng?, allowDuplicateBrand? } constructor options
|
|
502
|
-
} from "@smonn/ids/reverse";
|
|
503
|
-
|
|
504
|
-
import {
|
|
505
|
-
importSigningKey, // (bytes: Uint8Array) => Promise<SigningKey>
|
|
506
|
-
encodeSigningKey, // (bytes: Uint8Array, format: SigningKeyFormat) => string
|
|
507
|
-
decodeSigningKey, // (encoded: string, format: SigningKeyFormat) => Uint8Array
|
|
508
|
-
createSignedTimestampId, // (brand: string, opts: SignedTimestampOptions) => SignedTimestampCodec<Brand>
|
|
509
|
-
IdsError, // re-exported from @smonn/ids/signed for convenience
|
|
510
|
-
isIdsError, // re-exported from @smonn/ids/signed for convenience
|
|
511
|
-
type SigningKey, // opaque SigningKey handle (HKDF-derived)
|
|
512
|
-
type SigningKeyFormat, // "hex" | "base64url"
|
|
513
|
-
type IdsErrorCode, // re-exported from @smonn/ids/signed for convenience
|
|
514
|
-
type SignedTimestampCodec, // returned by createSignedTimestampId
|
|
515
|
-
type SignedTimestampOptions, // { keys: SigningKey[], now?, rng?, allowDuplicateBrand? } constructor options
|
|
516
|
-
type SafeVerifyResult, // { ok: true, id: Id<Brand> } | { ok: false, error: ParseError | "verification_failed" }
|
|
517
|
-
} from "@smonn/ids/signed";
|
|
518
|
-
|
|
519
|
-
import {
|
|
520
|
-
IdsError, // re-exported for convenience β same class as "@smonn/ids"
|
|
521
|
-
isIdsError, // re-exported for convenience β same guard as "@smonn/ids"
|
|
522
|
-
type IdsErrorCode, // re-exported for convenience β same union as "@smonn/ids"
|
|
523
|
-
createWrappedKeyId, // (brand: string, opts: { kind: "u32" | "i32" | "u64" | "i64", keys }) => WrappedKeyCodec<Brand, Kind>
|
|
524
|
-
importWrappingKey, // (bytes: Uint8Array) => Promise<WrappingKey>
|
|
525
|
-
encodeWrappingKey, // (bytes: Uint8Array, format: WrappingKeyFormat) => string
|
|
526
|
-
decodeWrappingKey, // (encoded: string, format: WrappingKeyFormat) => Uint8Array
|
|
527
|
-
type WrappedKeyCodec, // returned by createWrappedKeyId
|
|
528
|
-
type WrappingKey, // opaque imported handle for wrapping key material
|
|
529
|
-
type WrappingKeyFormat, // "hex" | "base64url"
|
|
530
|
-
} from "@smonn/ids/wrapped";
|
|
531
|
-
|
|
532
|
-
import {
|
|
533
|
-
IdsError, // re-exported for convenience β same class as "@smonn/ids"
|
|
534
|
-
isIdsError, // re-exported for convenience β same guard as "@smonn/ids"
|
|
535
|
-
type IdsErrorCode, // re-exported for convenience β same union as "@smonn/ids"
|
|
536
|
-
idColumn, // (codec: IdColumnCodec<Brand>) => PgCustomColumnBuilder (Drizzle column)
|
|
537
|
-
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
538
|
-
} from "@smonn/ids/drizzle";
|
|
539
|
-
|
|
540
|
-
import {
|
|
541
|
-
IdsError, // re-exported for convenience β same class as "@smonn/ids"
|
|
542
|
-
isIdsError, // re-exported for convenience β same guard as "@smonn/ids"
|
|
543
|
-
type IdsErrorCode, // re-exported for convenience β same union as "@smonn/ids"
|
|
544
|
-
idField, // (codec: IdColumnCodec<Brand>) => IdTransform<Brand> β read/write transforms for Prisma $extends
|
|
545
|
-
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
546
|
-
type IdTransform, // { read(value: unknown): Id<Brand>; write(value: Id<Brand>): string }
|
|
547
|
-
} from "@smonn/ids/prisma";
|
|
548
|
-
|
|
549
|
-
import {
|
|
550
|
-
IdsError, // re-exported for convenience β same class as "@smonn/ids"
|
|
551
|
-
isIdsError, // re-exported for convenience β same guard as "@smonn/ids"
|
|
552
|
-
type IdsErrorCode, // re-exported for convenience β same union as "@smonn/ids"
|
|
553
|
-
idColumn, // (codec: IdColumnCodec<Brand>) => { toDriver, fromDriver }
|
|
554
|
-
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
555
|
-
type IdColumnType, // ColumnType<Id<Brand>, Id<Brand>, Id<Brand>>
|
|
556
|
-
} from "@smonn/ids/kysely";
|
|
557
|
-
|
|
558
|
-
import {
|
|
559
|
-
idParam, // (paramName: string, codec, options?) => Hono MiddlewareHandler β throws HTTPException by default; onError/status options override
|
|
560
|
-
type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
|
|
561
|
-
type IdParamOptions, // { onError?, status? }
|
|
562
|
-
} from "@smonn/ids/hono";
|
|
563
|
-
|
|
564
|
-
import {
|
|
565
|
-
idParam, // (paramName: string, codec, options?) => Express middleware β calls next(err) with IdParamError by default; onError/status options override
|
|
566
|
-
IdParamError, // Error subclass with .reason and .status β forwarded via next(err) by default
|
|
567
|
-
type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
|
|
568
|
-
type IdParamOptions, // { onError?, status? }
|
|
569
|
-
} from "@smonn/ids/express";
|
|
570
|
-
|
|
571
|
-
import {
|
|
572
|
-
idParam, // (paramName: string, codec, options?) => Fastify preHandler β throws IdParamError by default; onError/status options override
|
|
573
|
-
IdParamError, // Error subclass with .reason and .statusCode β thrown into setErrorHandler by default
|
|
574
|
-
type IdParamFailure, // { reason: "brand_mismatch" | "malformed"; status: number }
|
|
575
|
-
type IdParamOptions, // { onError?, status? }
|
|
576
|
-
} from "@smonn/ids/fastify";
|
|
577
|
-
```
|
|
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
|
-
|
|
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.
|
|
658
|
-
|
|
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.
|
|
660
|
-
|
|
661
|
-
```ts
|
|
662
|
-
import { createWrappedKeyId, importWrappingKey } from "@smonn/ids/wrapped";
|
|
663
|
-
|
|
664
|
-
const key = await importWrappingKey(new Uint8Array(32));
|
|
665
|
-
|
|
666
|
-
// u32: number, range [0, 4294967295]
|
|
667
|
-
const u32Ids = createWrappedKeyId("inv", { kind: "u32", keys: [key] });
|
|
668
|
-
const u32Id = await u32Ids.wrap(42); // number β Id<"inv">
|
|
669
|
-
const u32Key = await u32Ids.unwrap(u32Id); // β 42 (number)
|
|
670
|
-
|
|
671
|
-
// i32: number, range [-2147483648, 2147483647]
|
|
672
|
-
const i32Ids = createWrappedKeyId("rec", { kind: "i32", keys: [key] });
|
|
673
|
-
const i32Id = await i32Ids.wrap(-7); // number β Id<"rec">
|
|
674
|
-
const i32Key = await i32Ids.unwrap(i32Id); // β -7 (number)
|
|
675
|
-
|
|
676
|
-
// u64: bigint, range [0n, 18446744073709551615n]
|
|
677
|
-
const u64Ids = createWrappedKeyId("ord", { kind: "u64", keys: [key] });
|
|
678
|
-
const u64Id = await u64Ids.wrap(42n); // bigint β Id<"ord">
|
|
679
|
-
const u64Key = await u64Ids.unwrap(u64Id); // β 42n (bigint)
|
|
680
|
-
|
|
681
|
-
// i64: bigint, range [-9223372036854775808n, 9223372036854775807n]
|
|
682
|
-
const i64Ids = createWrappedKeyId("evt", { kind: "i64", keys: [key] });
|
|
683
|
-
const i64Id = await i64Ids.wrap(-1n); // bigint β Id<"evt">
|
|
684
|
-
const i64Key = await i64Ids.unwrap(i64Id); // β -1n (bigint)
|
|
685
|
-
```
|
|
686
|
-
|
|
687
|
-
**Keyring rotation.** Pass a non-empty ordered list of wrapping keys at construction. The first entry is the _current_ key β the only one `wrap` uses. `unwrap` trials every entry in order until the verification tag matches, so IDs wrapped under any listed key are still unwrappable. Removing an entry from the list revokes all IDs wrapped under it.
|
|
688
|
-
|
|
689
|
-
```ts
|
|
690
|
-
const oldKey = await importWrappingKey(rawOldSecret);
|
|
691
|
-
const newKey = await importWrappingKey(rawNewSecret);
|
|
692
|
-
|
|
693
|
-
// Before rotation: only oldKey in the ring
|
|
694
|
-
const legacy = createWrappedKeyId("inv", { kind: "u32", keys: [oldKey] });
|
|
695
|
-
const id = await legacy.wrap(7);
|
|
696
|
-
|
|
697
|
-
// After rotation: newKey is current; oldKey is still accepted on unwrap
|
|
698
|
-
const rotated = createWrappedKeyId("inv", { kind: "u32", keys: [newKey, oldKey] });
|
|
699
|
-
await rotated.unwrap(id); // succeeds β tried oldKey and matched
|
|
700
|
-
await rotated.wrap(7); // uses newKey β different public ID
|
|
701
|
-
```
|
|
702
|
-
|
|
703
|
-
**Equality leakage.** The Wrapped key codec is deterministic: the same lookup key wrapped under the same wrapping key always yields the same public ID. An observer without operator material can tell that two identical public IDs wrap the same lookup key, but cannot recover the lookup key or wrapping key from the ID alone. This is an accepted trade-off for fitting an 8-byte integer lane and an 8-byte verification tag into the 16-byte payload. UUID-sized values (128 bits) are out of scope for this compact branch β there is no room for them alongside the verification tag.
|
|
704
|
-
|
|
705
|
-
**Fail-closed verification.** `is`, `parse`, and `safeParse` are structural β they check only the prefix and base32 payload shape, with no key material required. Cryptographic verification only happens in `unwrap` and `safeUnwrap`.
|
|
706
|
-
|
|
707
|
-
- `unwrap(id)` takes a trusted `Id<Brand>` and **throws** if payload verification fails. Use it when the ID is already known to be structurally valid (e.g. freshly loaded from your database).
|
|
708
|
-
- `safeUnwrap(input)` accepts untrusted input, structurally parses first, then verifies β without throwing. It returns one of:
|
|
709
|
-
|
|
710
|
-
```ts
|
|
711
|
-
// Success
|
|
712
|
-
{
|
|
713
|
-
ok: true;
|
|
714
|
-
id: Id<Brand>;
|
|
715
|
-
lookupKey: number | bigint;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// Structural parse failure (wrong brand, invalid base32, etc.)
|
|
719
|
-
{
|
|
720
|
-
ok: false;
|
|
721
|
-
error: "not_string" | "invalid_prefix" | "invalid_base32";
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// Payload verified but tag mismatch (tampered, wrong keyring, or revoked key)
|
|
725
|
-
{
|
|
726
|
-
ok: false;
|
|
727
|
-
error: "verification_failed";
|
|
728
|
-
}
|
|
729
|
-
```
|
|
730
|
-
|
|
731
|
-
Use `safeUnwrap` at API boundaries where the input is untrusted:
|
|
732
|
-
|
|
733
|
-
```ts
|
|
734
|
-
const result = await invoices.safeUnwrap(req.body.invoiceId);
|
|
735
|
-
|
|
736
|
-
if (!result.ok) {
|
|
737
|
-
if (result.error === "verification_failed") return 403; // tampered or wrong key
|
|
738
|
-
return 400; // malformed ID
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const { id, lookupKey } = result; // Id<"inv">, number
|
|
742
|
-
```
|
|
743
|
-
|
|
744
|
-
### Codec methods
|
|
745
|
-
|
|
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 |
|
|
762
|
-
|
|
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.
|
|
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
|
-
|
|
767
|
-
## ORM adapters
|
|
768
|
-
|
|
769
|
-
### Drizzle (`@smonn/ids/drizzle`)
|
|
770
|
-
|
|
771
|
-
`@smonn/ids/drizzle` is a subpath export that provides a Drizzle custom column type bound to a codec. It requires `drizzle-orm` as a peer dependency β installing `@smonn/ids` alone does not require Drizzle.
|
|
772
|
-
|
|
773
|
-
```bash
|
|
774
|
-
pnpm add drizzle-orm
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
```ts
|
|
778
|
-
import { pgTable } from "drizzle-orm/pg-core";
|
|
779
|
-
import { idColumn } from "@smonn/ids/drizzle";
|
|
780
|
-
import { createTimestampId } from "@smonn/ids";
|
|
781
|
-
|
|
782
|
-
const usr = createTimestampId("usr");
|
|
783
|
-
|
|
784
|
-
export const users = pgTable("users", {
|
|
785
|
-
id: idColumn(usr).primaryKey(),
|
|
786
|
-
});
|
|
787
|
-
// users.id is typed as Id<"usr"> end-to-end
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
`idColumn(codec)` works with any codec variant β `TimestampCodec`, `OpaqueTimestampCodec`, and `WrappedKeyCodec` all satisfy the required interface.
|
|
791
|
-
|
|
792
|
-
**Write path:** `Id<Brand>` is already canonical, so it is passed to the driver unchanged.
|
|
793
|
-
|
|
794
|
-
**Read path:** values from the database are normalised via `codec.safeParse()` rather than the strict `is()` check. Data at rest should already be canonical per [ADR-0003](./docs/adr/0003-canonical-strict-is.md), but `safeParse` is a safe boundary in case stale non-canonical values exist. An unrecognised value throws at read time so corrupt data surfaces immediately rather than silently.
|
|
795
|
-
|
|
796
|
-
```ts
|
|
797
|
-
import {
|
|
798
|
-
idColumn, // (codec: IdColumnCodec<Brand>) => PgCustomColumnBuilder
|
|
799
|
-
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
800
|
-
} from "@smonn/ids/drizzle";
|
|
801
|
-
```
|
|
802
|
-
|
|
803
|
-
### Kysely (`@smonn/ids/kysely`)
|
|
804
|
-
|
|
805
|
-
`@smonn/ids/kysely` is a subpath export that provides a Kysely column adapter bound to a codec. It requires `kysely` as a peer dependency β installing `@smonn/ids` alone does not require Kysely.
|
|
806
|
-
|
|
807
|
-
```bash
|
|
808
|
-
pnpm add kysely
|
|
809
|
-
```
|
|
810
|
-
|
|
811
|
-
```ts
|
|
812
|
-
import { idColumn, type IdColumnType } from "@smonn/ids/kysely";
|
|
813
|
-
import { createTimestampId } from "@smonn/ids";
|
|
814
|
-
|
|
815
|
-
const usr = createTimestampId("usr");
|
|
816
|
-
const usrCol = idColumn(usr);
|
|
817
|
-
|
|
818
|
-
// Use IdColumnType in your Database interface:
|
|
819
|
-
interface Database {
|
|
820
|
-
users: { id: IdColumnType<"usr"> };
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
// Kysely has no runtime transformer β fromDriver/toDriver do NOT fire automatically.
|
|
824
|
-
// You must call fromDriver() manually on every read result.
|
|
825
|
-
// The `as unknown as string` cast is required because TypeScript already sees
|
|
826
|
-
// row.id as Id<"usr"> (from the Database interface), even though the raw DB
|
|
827
|
-
// value is a plain string at runtime.
|
|
828
|
-
const row = await db.selectFrom("users").selectAll().executeTakeFirstOrThrow();
|
|
829
|
-
const id = usrCol.fromDriver(row.id as unknown as string);
|
|
830
|
-
```
|
|
831
|
-
|
|
832
|
-
`idColumn(codec)` works with any codec variant β `TimestampCodec`, `OpaqueTimestampCodec`, and `WrappedKeyCodec` all satisfy the required interface.
|
|
833
|
-
|
|
834
|
-
**Write path:** `Id<Brand>` is already canonical, so `toDriver` passes it to the driver unchanged.
|
|
835
|
-
|
|
836
|
-
**Read path:** `fromDriver` normalises the raw DB string via `codec.safeParse()`. An unrecognised value throws at read time so corrupt data surfaces immediately rather than silently.
|
|
837
|
-
|
|
838
|
-
```ts
|
|
839
|
-
import {
|
|
840
|
-
IdsError, // re-exported for convenience β same class as "@smonn/ids"
|
|
841
|
-
isIdsError, // re-exported for convenience β same guard as "@smonn/ids"
|
|
842
|
-
type IdsErrorCode, // re-exported for convenience β same union as "@smonn/ids"
|
|
843
|
-
idColumn, // (codec: IdColumnCodec<Brand>) => { toDriver, fromDriver }
|
|
844
|
-
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
845
|
-
type IdColumnType, // ColumnType<Id<Brand>, Id<Brand>, Id<Brand>>
|
|
846
|
-
} from "@smonn/ids/kysely";
|
|
847
|
-
```
|
|
848
|
-
|
|
849
|
-
### Prisma (`@smonn/ids/prisma`)
|
|
850
|
-
|
|
851
|
-
`@smonn/ids/prisma` is a subpath export that provides a read/write transform pair for integrating `Id<Brand>` with Prisma's `$extends` extension model. It requires `@prisma/client` as a peer dependency β installing `@smonn/ids` alone does not require Prisma.
|
|
852
|
-
|
|
853
|
-
```bash
|
|
854
|
-
pnpm add @prisma/client
|
|
855
|
-
```
|
|
856
|
-
|
|
857
|
-
```ts
|
|
858
|
-
import { idField } from "@smonn/ids/prisma";
|
|
859
|
-
import { createTimestampId } from "@smonn/ids";
|
|
860
|
-
import type { Id } from "@smonn/ids";
|
|
861
|
-
|
|
862
|
-
const usr = createTimestampId("usr");
|
|
863
|
-
const userIdField = idField(usr);
|
|
864
|
-
|
|
865
|
-
const xprisma = prisma.$extends({
|
|
866
|
-
result: {
|
|
867
|
-
user: {
|
|
868
|
-
id: {
|
|
869
|
-
needs: { id: true },
|
|
870
|
-
compute(user) {
|
|
871
|
-
// Cast required β see Prisma caveat below
|
|
872
|
-
return userIdField.read(user.id) as Id<"usr">;
|
|
873
|
-
},
|
|
874
|
-
},
|
|
875
|
-
},
|
|
876
|
-
},
|
|
877
|
-
});
|
|
878
|
-
|
|
879
|
-
// Write path: Id<Brand> is already canonical β pass it directly
|
|
880
|
-
await xprisma.user.create({ data: { id: userIdField.write(usr.generate()), name: "Alice" } });
|
|
881
|
-
|
|
882
|
-
// Read path: validated and typed as Id<"usr"> (with cast)
|
|
883
|
-
const user = await xprisma.user.findFirstOrThrow();
|
|
884
|
-
```
|
|
885
|
-
|
|
886
|
-
`idField(codec)` works with any codec variant β `TimestampCodec`, `OpaqueTimestampCodec`, `ReverseTimestampCodec`, and `WrappedKeyCodec` all satisfy the required interface.
|
|
887
|
-
|
|
888
|
-
**Write path:** `Id<Brand>` is already canonical, so `write` is an identity function β it returns the string unchanged.
|
|
889
|
-
|
|
890
|
-
**Read path:** values from the database are normalised via `codec.safeParse()` rather than the strict `is()` check. Data at rest should already be canonical per [ADR-0003](./docs/adr/0003-canonical-strict-is.md), but `safeParse` is a safe boundary in case stale non-canonical values exist. An unrecognised value throws at read time so corrupt data surfaces immediately rather than silently.
|
|
891
|
-
|
|
892
|
-
**Prisma casting caveat:** Prisma's `$extends` result component can add typed computed accessors to model instances, but cannot retroactively re-type an existing schema field at the Prisma Client level. The `read` function asserts `Id<Brand>` at the TypeScript level, but Prisma's generated types for the model field will not reflect this branding. Callers will need an explicit `as Id<"brand">` cast at consumption sites. This is a Prisma type-system constraint, not a library limitation β see the JSDoc on `idField` for the canonical example.
|
|
893
|
-
|
|
894
|
-
```ts
|
|
895
|
-
import {
|
|
896
|
-
idField, // (codec: IdColumnCodec<Brand>) => IdTransform<Brand>
|
|
897
|
-
type IdColumnCodec, // { safeParse(value: unknown): ParseResult<Brand> }
|
|
898
|
-
type IdTransform, // { read(value: unknown): Id<Brand>; write(value: Id<Brand>): string }
|
|
899
|
-
} from "@smonn/ids/prisma";
|
|
900
|
-
```
|
|
901
|
-
|
|
902
|
-
## CLI
|
|
903
|
-
|
|
904
|
-
Brand-agnostic subcommands, no install required. Run `npx @smonn/ids --help` for the full flag list.
|
|
905
|
-
|
|
906
|
-
### `inspect` (`i`)
|
|
907
|
-
|
|
908
|
-
Decode an ID and print brand, timestamp (or lookup key), canonical form, and whether the input was already canonical.
|
|
909
|
-
|
|
910
|
-
```bash
|
|
911
|
-
$ npx @smonn/ids inspect usr_01h7b3k9rqxn1cw3p9r8t2sgkz
|
|
912
|
-
brand: usr
|
|
913
|
-
timestamp: 1983-05-27T10:24:22.469Z (43 years ago)
|
|
914
|
-
canonical: usr_01h7b3k9rqxn1cw3p9r8t2sgkz
|
|
915
|
-
input: canonical
|
|
916
|
-
```
|
|
917
|
-
|
|
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).
|
|
929
|
-
|
|
930
|
-
```bash
|
|
931
|
-
# Opaque Timestamp (IDS_KEY required):
|
|
932
|
-
IDS_KEY=<hex-or-base64url-key> npx @smonn/ids inspect inv_β¦ --opaque
|
|
933
|
-
|
|
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
|
-
```
|
|
946
|
-
|
|
947
|
-
### `generate` (`g`)
|
|
948
|
-
|
|
949
|
-
Mint one or more canonical IDs for a brand. Output is one ID per line (pipeable).
|
|
950
|
-
|
|
951
|
-
```bash
|
|
952
|
-
$ npx @smonn/ids generate usr --count 3
|
|
953
|
-
usr_β¦
|
|
954
|
-
usr_β¦
|
|
955
|
-
usr_β¦
|
|
956
|
-
```
|
|
957
|
-
|
|
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` |
|
|
966
|
-
|
|
967
|
-
```bash
|
|
968
|
-
# Opaque Timestamp:
|
|
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
|
|
976
|
-
```
|
|
977
|
-
|
|
978
|
-
### `keygen` (`k`)
|
|
979
|
-
|
|
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.
|
|
981
|
-
|
|
982
|
-
```bash
|
|
983
|
-
$ npx @smonn/ids keygen
|
|
984
|
-
a1b2c3β¦
|
|
985
|
-
|
|
986
|
-
$ npx @smonn/ids keygen --bits 128 --key-format base64url
|
|
987
|
-
AbCdEfβ¦
|
|
988
|
-
```
|
|
989
|
-
|
|
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.
|
|
1024
|
-
|
|
1025
|
-
`inspect --signed` always emits a full timestamp report on stdout and carries a `verification:` line with a three-value verdict:
|
|
1026
|
-
|
|
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 |
|
|
85
|
+
- **Hiding creation time with the Timestamp codec.** Anyone with one ID at a
|
|
86
|
+
known creation time can compute the epoch offset. Use the Opaque Timestamp
|
|
87
|
+
codec to hide creation time per-ID.
|
|
1033
88
|
|
|
1034
|
-
|
|
89
|
+
## Links
|
|
1035
90
|
|
|
1036
|
-
|
|
91
|
+
- **[Documentation](https://ids.smonn.se)** β full guides, API reference, and playground
|
|
92
|
+
- **[Design decisions](./docs/adr/)** β recorded ADRs
|
|
93
|
+
- **[CONTEXT.md](./CONTEXT.md)** β glossary of the project's vocabulary
|
|
94
|
+
- **[Contributing](./CONTRIBUTING.md)** Β· **[Security](./SECURITY.md)**
|
|
1037
95
|
|
|
1038
|
-
##
|
|
96
|
+
## License
|
|
1039
97
|
|
|
1040
|
-
|
|
1041
|
-
- [`docs/adr/`](./docs/adr/) β recorded design decisions
|
|
98
|
+
[MIT](./LICENSE)
|