@sdxc/jwt 2026.10.6 → 2026.10.12

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 CHANGED
@@ -10,6 +10,11 @@ npm add @sdxc/jwt
10
10
 
11
11
  Signing and verification run on [`jose`](https://www.npmjs.com/package/jose), and the duration strings the time claims accept come from [`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration). Both install with the package.
12
12
 
13
+ Two entry points:
14
+
15
+ - `@sdxc/jwt` — `JWT`, the `JWK` namespace, and the `KeyStorage` contract.
16
+ - `@sdxc/jwt/r2` — `createR2KeyStorage`, the `KeyStorage` over a Cloudflare R2 bucket.
17
+
13
18
  ## Usage
14
19
 
15
20
  ### Describing A Kind Of Token
@@ -235,6 +240,22 @@ interface KeyStorageListResult {
235
240
 
236
241
  To implement it: `get` answers `null` for a key nothing is stored under; `list` returns the entries whose key starts with `prefix`, at most `limit` of them, resuming from `cursor`, and carries a `cursor` of its own only while further pages remain; `set` replaces whatever is stored under the key. Each method may be synchronous or return a promise, so a plain object satisfies the contract in a test while an object store satisfies it in production. `signingKeys` writes each pair as a JSON `SerializedKeyPair` under the key `signing:key:<id>` and lists with the prefix `signing:key` to find them again, so keep every key ever written and every token it signed stays verifiable.
237
242
 
243
+ ### `createR2KeyStorage(bucket: R2KeyBucket): KeyStorage`
244
+
245
+ From `@sdxc/jwt/r2`. Stores key files in a Cloudflare R2 bucket, the place a key outlives every isolate and every Worker issuing tokens for one issuer reads the same set.
246
+
247
+ ```typescript
248
+ import { JWK } from "@sdxc/jwt";
249
+ import { createR2KeyStorage } from "@sdxc/jwt/r2";
250
+ import { env } from "cloudflare:workers";
251
+
252
+ let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
253
+ ```
254
+
255
+ Each object is written with the file's type as its `Content-Type` and its name and type as custom metadata (`name`, `type`), so `get` hands back the `File` that was stored, its `lastModified` set to the upload time. An object written without that metadata reads back named after its key. `list` carries R2's cursor only while a page is truncated.
256
+
257
+ `R2KeyBucket` is the part of a bucket binding the storage calls — `get`, `list` and `put` — declared structurally, so an `R2Bucket` satisfies it and importing the module needs no platform types. `R2KeyObject` is what its `get` answers.
258
+
238
259
  ## Rotation, And The Order To Roll It Out In
239
260
 
240
261
  A rotation adds a key: the issuer writes a new pair into storage, `signingKeys` returns it first, `sign` picks it as the newest, and the retired key stays in the published JWKS so the tokens it signed keep verifying until they expire. That rests on every verifier resolving a key per token by `kid`, which makes the rollout order a deployment constraint:
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Key storage over a Cloudflare R2 bucket, where signing keys outlive every
3
+ * isolate and every Worker issuing tokens for one issuer reads the same files.
4
+ * Each stored object carries the file's name and type, so a key reads back as the `File` written.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { KeyStorage, KeyStorageListOptions } from "./key-storage.js";
10
+ /** The HTTP metadata an object is written and read with. */
11
+ interface R2KeyHTTPMetadata {
12
+ contentType?: string;
13
+ }
14
+ /** A stored object as a read answers it. */
15
+ export interface R2KeyObject {
16
+ uploaded: Date;
17
+ httpMetadata?: R2KeyHTTPMetadata;
18
+ customMetadata?: Record<string, string>;
19
+ arrayBuffer(): Promise<ArrayBuffer>;
20
+ }
21
+ /**
22
+ * The parts of an R2 bucket binding the storage uses, declared structurally so
23
+ * importing this module needs no platform types; an `R2Bucket` satisfies it.
24
+ */
25
+ export interface R2KeyBucket {
26
+ get(key: string): Promise<R2KeyObject | null>;
27
+ list(options?: KeyStorageListOptions): Promise<{
28
+ objects: {
29
+ key: string;
30
+ }[];
31
+ truncated: boolean;
32
+ cursor?: string;
33
+ }>;
34
+ put(key: string, value: ArrayBuffer, options?: {
35
+ httpMetadata?: R2KeyHTTPMetadata;
36
+ customMetadata?: Record<string, string>;
37
+ }): Promise<unknown>;
38
+ }
39
+ /**
40
+ * Adapts an R2 bucket binding to the storage `JWK.signingKeys` reads and rotates
41
+ * keys through. The bucket is passed in, so the caller decides where keys live.
42
+ *
43
+ * @param bucket - The R2 bucket binding key files are stored in.
44
+ * @returns Storage that reads, writes, and pages through key files in that bucket.
45
+ * @example let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
46
+ */
47
+ export declare function createR2KeyStorage(bucket: R2KeyBucket): KeyStorage;
48
+ export {};
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Key storage over a Cloudflare R2 bucket, where signing keys outlive every
3
+ * isolate and every Worker issuing tokens for one issuer reads the same files.
4
+ * Each stored object carries the file's name and type, so a key reads back as the `File` written.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Adapts an R2 bucket binding to the storage `JWK.signingKeys` reads and rotates
11
+ * keys through. The bucket is passed in, so the caller decides where keys live.
12
+ *
13
+ * @param bucket - The R2 bucket binding key files are stored in.
14
+ * @returns Storage that reads, writes, and pages through key files in that bucket.
15
+ * @example let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
16
+ */
17
+ export function createR2KeyStorage(bucket) {
18
+ return {
19
+ /**
20
+ * A missing key comes back as null, so an object removed between a listing
21
+ * and its read reads the same as a key that was never there. The bytes are
22
+ * read fully into memory, which a key file of a few hundred bytes affords.
23
+ */
24
+ async get(key) {
25
+ let object = await bucket.get(key);
26
+ if (!object)
27
+ return null;
28
+ let metadata = object.customMetadata;
29
+ return new File([await object.arrayBuffer()], metadata?.name ?? key, {
30
+ type: object.httpMetadata?.contentType ?? metadata?.type,
31
+ lastModified: object.uploaded.getTime(),
32
+ });
33
+ },
34
+ /**
35
+ * Carries R2's cursor only while the page is truncated, so a caller walking
36
+ * pages until the cursor is gone stops after the last one.
37
+ */
38
+ async list(options) {
39
+ let result = await bucket.list({
40
+ cursor: options?.cursor,
41
+ limit: options?.limit,
42
+ prefix: options?.prefix,
43
+ });
44
+ return {
45
+ files: result.objects.map((object) => ({ key: object.key })),
46
+ cursor: result.truncated ? result.cursor : undefined,
47
+ };
48
+ },
49
+ /** Replaces whatever is stored under the key, recording the file's name and type. */
50
+ async set(key, file) {
51
+ let metadata = { name: file.name, type: file.type };
52
+ await bucket.put(key, await file.arrayBuffer(), {
53
+ httpMetadata: { contentType: file.type },
54
+ customMetadata: metadata,
55
+ });
56
+ },
57
+ };
58
+ }
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "@sdxc/jwt",
3
- "version": "2026.10.6",
3
+ "version": "2026.10.12",
4
4
  "description": "JWT payload classes and the keys that sign them",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./dist/index.js",
9
- "./algorithm": "./dist/algorithm.js"
9
+ "./algorithm": "./dist/algorithm.js",
10
+ "./r2": "./dist/r2-key-storage.js"
10
11
  },
11
12
  "dependencies": {
12
13
  "@sdxc/duration": "2026.10.6",
13
- "jose": "^6.2.10"
14
+ "jose": "^6.2.12"
14
15
  },
15
- "gitHead": "b1067eb20fc47ec51ff8c4de47d07dc3f9c00466",
16
+ "gitHead": "857ef87ddb0b2484e28b46431d0740fcc80e5f33",
16
17
  "publishConfig": {
17
18
  "access": "public"
18
19
  },