smonn/ids

GitHub: smonn/ids

为 TypeScript 应用程序提供面向公众的品牌 ID。

Stars: 1 | Forks: 0

# @smonn/ids TypeScript 应用程序面向公众的品牌 ID。 ``` pnpm add @smonn/ids ``` 每个 ID 都像 `usr_01h7b3k9rqxn4cw3p9r8t2sgkz` 这样:三个字母的品牌,一个下划线,然后是 26 个 Crockford base32 载荷字符。时间戳编解码器将 48 位毫秒 Unix 时间戳编码后跟 80 个随机位——与 [ULID](https://github.com/ulid/spec) 相同的字节布局;有关故意的差异,请参阅 [ADR-0002](./docs/adr/0002-payload-layout.md)。不透明编解码器 (`@smonn/ids/opaque`) 保持相同的线形,但使用密钥加密载荷,因此时间戳无法从 ID 中读取。 ## 这是什么 ### “给我的实体 ID 提供在 URL、仪表板和支持票据中公开的安全 ID” ``` import { createId } from "@smonn/ids"; const users = createId("usr"); const id = users.generate(); // "usr_01h7b3k9rqxn4cw3p9r8t2sgkz" ``` 三个字母的品牌告诉您 ID 指的是什么,而无需进行带外查找。没有通过顺序 PK 泄露行计数,没有 slug 冲突,没有在堆栈跟踪中“这是用户还是组织?”的歧义。 ### “捕捉我传递 `UserId` 而需要 `OrgId` 的情况” ``` import { type Id, createId } from "@smonn/ids"; const users = createId("usr"); const orgs = createId("org"); function loadUser(id: Id<"usr">) { /* ... */ } loadUser(orgs.generate()); // ❌ Type 'Id<"org">' is not assignable to 'Id<"usr">'. ``` `Id` 通常是标记的。`Id<"usr">` 和 `Id<"org">` 不可互换——尽管在运行时它们都是字符串,但类型系统将它们视为不同的。 ### “支持代理通过电子邮件发给我一个 ID —— 即使他们打错了也能接受” ``` users.safeParse("usr_01h7b3k9rqxn1cw3p9r8t2sgkz"); // canonical users.safeParse("USR_01H7B3K9RQXN1CW3P9R8T2SGKZ"); // uppercase users.safeParse("usr_Olh7b3k9rqxnIcw3p9r8t2sgkz"); // o, I, l aliased // → { ok: true, id: "usr_01h7b3k9rqxn1cw3p9r8t2sgkz" } for all three ``` `safeParse` 接受混合大小写和 Crockford 规范的可视别名(`o → 0`、`i → 1`、`l → 1`),并始终返回 **规范形式**——小写,别名已解析。规范字符串上的等性检查按预期工作。 ### “验证来自 URL 或请求体的 ID” ``` const r = users.safeParse(input); if (!r.ok) { switch (r.error) { case "not_string": return 400; // wasn't a string at all case "invalid_prefix": return 404; // wrong kind of ID (or not an ID) case "invalid_base32": return 400; // prefix matched but payload is malformed } } const userId = r.id; // Id<"usr">, canonical ``` `ParseError` 作为字面量联合体导出,以便在编译时进行详尽的切换。 ### “仅使用 ID 对记录进行排序和日期戳” 载荷的前 6 个字节是一个大端毫秒 Unix 时间戳,因此 `ORDER BY id` 按创建时间排序,无需单独的 `created_at` 列。要从现有 ID 中提取时间戳: ``` users.extractTimestamp(id); // Date ``` 对于时间范围查询,`minIdForTime(date)` 和 `maxIdForTime(date)` 在给定毫秒的紧下限和上限处构建合成 ID——相同的时戳字节,随机部分填充为所有 `0x00`(最小)或所有 `0xFF`(最大)。无需单独的 `created_at` 列: ``` const start = new Date("2026-01-01T00:00:00Z"); const end = new Date("2026-02-01T00:00:00Z"); sql`SELECT * FROM users WHERE id BETWEEN ${users.minIdForTime(start)} AND ${users.maxIdForTime(end)}`; ``` 两者以与 `generate()` 相同的方式验证日期——早于纪元或超过 48 位上限抛出。 要在您选择的日期而不是 `now` 上铸造一个真正的 ID(随机尾部和所有),请使用 `generateAt(date)`。时戳字节来自提供的 `Date`;随机部分由编解码器的 `rng` 填充,因此结果正好通过 `extractTimestamp`: ``` const id = users.generateAt(new Date("2024-03-15T12:00:00Z")); // Id<"usr"> users.extractTimestamp(id); // → 2024-03-15T12:00:00.000Z ``` 这是回填的单行代码:从 UUIDv7 / ULID / Snowflake 迁移 `oldRows.map((r) => users.generateAt(extractTime(r)))`,无需为每个时间戳启动一个一次性编解码器。它以与 `generate()` 相同的方式验证日期——早于纪元、超过 48 位上限或 `Invalid Date` 抛出。 时间戳布局(毫秒精度、大端、Unix 纪元)是公共契约的一部分——请参阅 [ADR-0002](./docs/adr/0002-payload-layout.md)。 注意:由同一过程在相同毫秒内生成的两个 ID 具有独立的随机尾部,并且相对于彼此 **不** 以确定性的方式排序。如果您需要毫秒内的稳定排序,则此库不是正确的工具。 ### “注入一个固定的时钟和 RNG,以便我的测试是确定的” ``` const users = createId("usr", { now: () => new Date("2026-01-01T00:00:00Z").getTime(), rng: (target) => {}, // leave target as zero-filled }); users.generate(); // deterministic snapshot-friendly output ``` 两个 `Options` 字段都是可选的。默认值是 `Date.now` 和基于 `crypto.randomUUID` 的熵收集器(比 `crypto.getRandomValues` 对此库所需的 10 字节填充更快)。`now` 返回自 Unix 纪元以来的毫秒数。`rng` 将随机字节写入提供的目标(编解码器持久缓冲区的 10 字节视图),因此自定义 RNG 从不需要分配。 ### “在产品上线之前捕捉到重复注册的品牌” 预期的模式是每个品牌每个进程一个编解码器,在模块初始化时构建。对于同一品牌调用 `createId(brand)` 第二次通常意味着捆绑或导入错误(意外重新导出,测试在没有重置的情况下重新导入)。在开发中(`process.env.NODE_ENV !== "production"`),第二次调用会发出一次性的 `console.warn`;品牌跟踪注册表在生产中跳过。相同的注册表覆盖跨编解码器冲突:`createId("usr")` 后跟 `createOpaqueId("usr")` 也会发出警告,因为编解码器选择是每个品牌的承诺([ADR-0007](./docs/adr/0007-wire-indistinguishable-codec-variants.md))。故意重新创建编解码器的测试可以选择退出: ``` const users = createId("usr", { allowDuplicateBrand: true }); ``` 此检查是一种启发式方法,而不是保证。两个物理副本的 `@smonn/ids` 被加载到同一进程中(最坏情况的捆绑错误)各自保持自己的注册表,因此都不会发出警告——它捕获单个模块副本的重新导入,而不是模块本身的重复副本。 ### “与任何标准模式验证器一起使用” 每个编解码器实现了 [Standard Schema v1](https://standardschema.dev/),因此它可以直接插入任何验证器感知库(Zod、Valibot、ArkType、tRPC 输入、Hono 等),而无需重写相同的 `z.string().refine(usr.is)` 代码: ``` import { type } from "arktype"; const Body = type({ userId: users }); const r = Body({ userId: "USR_01H7B3K9RQXN1CW3P9R8T2SGKZ" }); // → { userId: "usr_01h7b3k9rqxn1cw3p9r8t2sgkz" } typed as Id<"usr"> ``` `validate` 是同步的,包装 `safeParse`,并在成功时返回规范 `Id`。每个 `ParseError` 变体映射到不同的 `issues[].message`: | ParseError | message | | ---------------- | ------------------------ | | `not_string` | `expected string` | | `invalid_prefix` | `expected prefix 'usr_'` | | `invalid_base32` | `invalid base32 payload` | ### “在 OpenAPI / JSON Schema 规范中描述 ID 字段” ``` users.toJsonSchema(); // { // type: "string", // pattern: "^usr_[0-9a-hjkmnp-tv-z]{26}$", // description: "Branded ID for 'usr'", // example: "usr_01h7b3k9rqxn1cw3p9r8t2sgkz", // } ``` `toJsonSchema()` 返回一个普通对象,您可以将其直接放入 OpenAPI `components.schemas` 条目、JSON Schema 文档或任何从 `example` 提取样本有效载荷的工具。字符类 `[0-9a-hjkmnp-tv-z]` 是小写 Crockford base32 字母表(不包括 `i`、`l`、`o`、`u`)。 `pattern` 描述的是 **规范形式**——它匹配 `generate()` 输出和 `is()` 接受的内容,但拒绝 `safeParse()` 可以容忍的大写和 Crockford 别名(`o`、`i`、`l`)。在边界处对宽松输入进行归一化是编解码器的任务;描述静态数据的工件描述了规范线形(请参阅 [ADR-0003](./docs/adr/0003-canonical-strict-is.md))。 `example` 通过在每次调用时对 `generate()` 进行调用而产生,因此它是新鲜的(非确定性的)并且始终与返回的 `pattern` 匹配。一个后果:使用注入的 `now`(在 48 位范围内)的编解码器——与破坏 `generate()` 的相同配置——会导致 `toJsonSchema()` 抛出。 ### “不要在客户可以看到的 ID 中泄露创建时间” 时间戳编解码器按设计公开创建时间戳——这就是 `ORDER BY id` 之所以能工作。如果这是您无法接受的泄露(发票 ID 揭示计费周期,注册 ID 揭示获取速度),请使用 `@smonn/ids/opaque` 中的不透明编解码器。相同的 `_<26 chars>` 线形,但载荷使用您提供的密钥进行 AES 加密。 ``` import { createOpaqueId, importOpaqueKey } from "@smonn/ids/opaque"; const key = await importOpaqueKey(new Uint8Array(16)); // 128- or 256-bit raw key const invoices = createOpaqueId("inv", { key }); const id = await invoices.generate(); // "inv_…", timestamp not extractable without the key await invoices.extractTimestamp(id); // Date — same codec, same key required ``` 与时间戳编解码器的三个区别: - **异步密钥相关方法。** WebCrypto 仅异步,因此 `generate`、`generateAt` 和 `extractTimestamp` 返回 `Promise`。`is`、`parse`、`safeParse`、`toJsonSchema` 和标准模式适配器保持同步——它们仅在工作线形上工作([ADR-0006](./docs/adr/0006-async-keyed-codec-contract.md))。 - **没有 `minIdForTime` / `maxIdForTime`。** 加密后的载荷不按时间排序。如果您需要对不透明编码的实体进行时间范围扫描,请将时间戳存储在单独的列中。 - **与时间戳编解码器不可区分的线形。** 编解码器选择是每个品牌的承诺;品牌注册表在开发中警告您是否对同一品牌进行了两次注册([ADR-0007](./docs/adr/0007-wire-indistinguishable-codec-variants.md))。 加密是 AES-CBC,使用零 IV。这在这里是故意安全的,因为明文已经为每个 ID 带来了 80 位熵;请参阅 [ADR-0004](./docs/adr/0004-aes-cbc-strip-trick.md) 了解完整的理由。 要存储或传输密钥材料,请使用 `encodeOpaqueKey` / `decodeOpaqueKey` 在 `hex` 或 `base64url` 中往返原始字节——与 ID 载荷中使用的 Crockford base32 区分开来。CLI 的 `keygen` 子命令以此格式发出密钥(请参阅 [CLI](#cli))。 ## 这不是什么 - **内部代理主键。** 如果没有人看到您的 ID,则品牌前缀和宽松解析是冗余的。使用 `bigint` 序列。 - **线兼容的 ULID。** 字节布局是 ULID 形状的,但编码是 lowercase 并包裹在品牌信封中。标准的 ULID 解析器将拒绝这些。 - **分布式跟踪 / 请求关联 ID。** 使用 OpenTelemetry 格式的 ID。 - **使用时间戳编解码器隐藏创建时间。** 任何拥有一个已知创建时间的 ID 的人都可以计算纪元偏移量。自定义纪元不会有所帮助且不受支持。要按 ID 隐藏创建时间,请使用不透明编解码器(如上所述)。 ## API 表面 ``` import { createId, // (brand: string, opts?: Partial) => Codec type Id, // branded string type type Codec, // returned by createId type Options, // { now, rng, allowDuplicateBrand } injection points type ParseError, // "not_string" | "invalid_prefix" | "invalid_base32" type ParseResult, // safeParse return type type JsonSchema, // toJsonSchema return type } from "@smonn/ids"; import { createOpaqueId, // (brand: string, opts: { key, now?, rng?, allowDuplicateBrand? }) => OpaqueCodec importOpaqueKey, // (bytes: Uint8Array) => Promise encodeOpaqueKey, // (bytes: Uint8Array, format: OpaqueKeyFormat) => string decodeOpaqueKey, // (encoded: string, format: OpaqueKeyFormat) => Uint8Array type OpaqueCodec, // returned by createOpaqueId type OpaqueOptions, // { key, now, rng, allowDuplicateBrand } injection points type OpaqueKeyFormat, // "hex" | "base64url" } from "@smonn/ids/opaque"; ``` ### 编解码器方法 | 方法 | `Codec` | `OpaqueCodec` | 描述 | | ---------------------- | -------------- | -------------------- | ----------------------------------------------------------------------------- | | `generate()` | 同步 | 异步 | 生成一个新鲜 ID | | `generateAt(date)` | 同步 | 异步 | 使用 `date` 的时戳字节生成一个新鲜 ID(用于回填) | | `is(value)` | 同步 | 同步 | 严格类型守卫:仅对已规范字符串返回 `true` | | `parse(value)` | 同步 | 同步 | 宽松:正常化到规范,或抛出 | | `safeParse(value)` | 同步 | 同步 | 宽松:正常化到规范,或返回 `{ ok: false, error }` | | `extractTimestamp(id)` | 同步 | 异步 | 从 `Id` 中解码创建 `Date`(信任类型) | | `minIdForTime(date)` | 同步 | — | 对于在 `date` 生成的任何 ID 的紧下限(用于范围查询) | | `maxIdForTime(date)` | 同步 | — | 对于在 `date` 生成的任何 ID 的紧上限(用于范围查询) | | `toJsonSchema()` | 同步 | 同步 | 规范形式的 JSON Schema (`type`/`pattern`/`description`/`example`) | ## 命令行界面 品牌无关的子命令,无需安装。运行 `npx @smonn/ids --help` 以获取完整的标志列表。 ### `inspect` (`i`) 解码 ID 并打印品牌、时间戳、规范形式以及输入是否已经是规范形式。 ``` $ npx @smonn/ids inspect usr_01h7b3k9rqxn1cw3p9r8t2sgkz brand: usr timestamp: 1983-05-27T10:24:22.469Z (43 years ago) canonical: usr_01h7b3k9rqxn1cw3p9r8t2sgkz input: canonical ``` 接受非规范输入(大写,Crockford 别名)。假设 **时间戳编解码器**——如果品牌使用 **不透明编解码器**,请传递 `--opaque` 并设置 `IDS_KEY`(以下);否则时间戳行是无意义的垃圾。 ``` IDS_KEY= npx @smonn/ids inspect inv_… --opaque ``` 打印解密的时间戳 **假设 `IDS_KEY` 与生成时使用的密钥匹配**——一个格式良好但错误的密钥会产生一个看似合理但错误的时戳,而不是错误(请参阅 [CONTEXT.md](./CONTEXT.md))。 ### `generate` (`g`) 为品牌铸造一个或多个规范 ID。输出每行一个 ID(可管道化)。 ``` $ npx @smonn/ids generate usr --count 3 usr_… usr_… usr_… ``` 标志:`--count` / `-c N`(默认 1)。除非设置 `--opaque`,否则使用时间戳编解码器。 ``` IDS_KEY= npx @smonn/ids generate inv --opaque --count 2 ``` ### `keygen` (`k`) 将随机不透明密钥输出到 stdout(一个秘密——不要记录或提交)。默认:256 位十六进制。 ``` $ npx @smonn/ids keygen a1b2c3… $ npx @smonn/ids keygen --bits 128 --key-format base64url AbCdEf… ``` 标志:`--bits 128|192|256`(默认 256),`--key-format hex|base64url`(默认 `hex`)。`IDS_KEY_FORMAT` 不影响 `keygen`——只有命令行上的 `--key-format`。输出往返于 `decodeOpaqueKey` / `importOpaqueKey` 不透明模式 (`--opaque`) `generate --opaque` 和 `inspect --opaque` 从 `IDS_KEY` 环境变量中读取 AES 密钥——而不是从 argv(argv 通过 `ps` 和 shell 历史记录泄漏)。缺失或格式不正确的 `IDS_KEY` 会打印清晰的 stderr 消息并退出非零。 密钥格式默认为 `hex`;通过 `--key-format` 或设置 `IDS_KEY_FORMAT=hex|base64url` 为会话默认值来覆盖每次调用的默认值。命令行上的 `--key-format` 胜过 `IDS_KEY_FORMAT`。 无效的输入会将解析错误打印到 stderr 并退出非零。 ## 设计 - [`CONTEXT.md`](./CONTEXT.md) — 项目词汇表 - [`docs/adr/`](./docs/adr/) — 记录的设计决策
标签:MITM代理, 自动化攻击