@vielzeug/archive 0.0.0-stage → 26.10.7
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 +81 -2
- package/dist/_crypto.cjs +2 -0
- package/dist/_crypto.cjs.map +1 -0
- package/dist/_crypto.d.ts +7 -0
- package/dist/_crypto.d.ts.map +1 -0
- package/dist/_crypto.js +2 -0
- package/dist/_crypto.js.map +1 -0
- package/dist/_json.cjs +2 -0
- package/dist/_json.cjs.map +1 -0
- package/dist/_json.d.ts +4 -0
- package/dist/_json.d.ts.map +1 -0
- package/dist/_json.js +2 -0
- package/dist/_json.js.map +1 -0
- package/dist/auth/config.cjs +2 -0
- package/dist/auth/config.cjs.map +1 -0
- package/dist/auth/config.d.ts +88 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/config.js +2 -0
- package/dist/auth/config.js.map +1 -0
- package/dist/auth/errors.cjs +2 -0
- package/dist/auth/errors.cjs.map +1 -0
- package/dist/auth/errors.d.ts +10 -0
- package/dist/auth/errors.d.ts.map +1 -0
- package/dist/auth/errors.js +2 -0
- package/dist/auth/errors.js.map +1 -0
- package/dist/auth/store.cjs +19 -0
- package/dist/auth/store.cjs.map +1 -0
- package/dist/auth/store.d.ts +45 -0
- package/dist/auth/store.d.ts.map +1 -0
- package/dist/auth/store.js +19 -0
- package/dist/auth/store.js.map +1 -0
- package/dist/auth/worker.cjs +16 -0
- package/dist/auth/worker.cjs.map +1 -0
- package/dist/auth/worker.d.ts +47 -0
- package/dist/auth/worker.d.ts.map +1 -0
- package/dist/auth/worker.js +16 -0
- package/dist/auth/worker.js.map +1 -0
- package/dist/auth.cjs +1 -0
- package/dist/auth.d.ts +5 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +1 -0
- package/dist/collection.cjs +2 -0
- package/dist/collection.cjs.map +1 -0
- package/dist/collection.d.ts +139 -0
- package/dist/collection.d.ts.map +1 -0
- package/dist/collection.js +2 -0
- package/dist/collection.js.map +1 -0
- package/dist/contract.cjs +2 -0
- package/dist/contract.cjs.map +1 -0
- package/dist/contract.d.ts +228 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -0
- package/dist/errors.cjs +2 -0
- package/dist/errors.cjs.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/worker/_pagination.cjs +2 -0
- package/dist/worker/_pagination.cjs.map +1 -0
- package/dist/worker/_pagination.d.ts +31 -0
- package/dist/worker/_pagination.d.ts.map +1 -0
- package/dist/worker/_pagination.js +2 -0
- package/dist/worker/_pagination.js.map +1 -0
- package/dist/worker/_service.cjs +2 -0
- package/dist/worker/_service.cjs.map +1 -0
- package/dist/worker/_service.d.ts +5 -0
- package/dist/worker/_service.d.ts.map +1 -0
- package/dist/worker/_service.js +2 -0
- package/dist/worker/_service.js.map +1 -0
- package/dist/worker/auth.cjs +2 -0
- package/dist/worker/auth.cjs.map +1 -0
- package/dist/worker/auth.d.ts +34 -0
- package/dist/worker/auth.d.ts.map +1 -0
- package/dist/worker/auth.js +2 -0
- package/dist/worker/auth.js.map +1 -0
- package/dist/worker/env.cjs +2 -0
- package/dist/worker/env.cjs.map +1 -0
- package/dist/worker/env.d.ts +73 -0
- package/dist/worker/env.d.ts.map +1 -0
- package/dist/worker/env.js +2 -0
- package/dist/worker/env.js.map +1 -0
- package/dist/worker/http.cjs +2 -0
- package/dist/worker/http.cjs.map +1 -0
- package/dist/worker/http.d.ts +66 -0
- package/dist/worker/http.d.ts.map +1 -0
- package/dist/worker/http.js +2 -0
- package/dist/worker/http.js.map +1 -0
- package/dist/worker/publications.cjs +2 -0
- package/dist/worker/publications.cjs.map +1 -0
- package/dist/worker/publications.d.ts +60 -0
- package/dist/worker/publications.d.ts.map +1 -0
- package/dist/worker/publications.js +2 -0
- package/dist/worker/publications.js.map +1 -0
- package/dist/worker/sql.cjs +60 -0
- package/dist/worker/sql.cjs.map +1 -0
- package/dist/worker/sql.d.ts +87 -0
- package/dist/worker/sql.d.ts.map +1 -0
- package/dist/worker/sql.js +60 -0
- package/dist/worker/sql.js.map +1 -0
- package/dist/worker/worker.cjs +2 -0
- package/dist/worker/worker.cjs.map +1 -0
- package/dist/worker/worker.d.ts +72 -0
- package/dist/worker/worker.d.ts.map +1 -0
- package/dist/worker/worker.js +2 -0
- package/dist/worker/worker.js.map +1 -0
- package/dist/worker.cjs +1 -0
- package/dist/worker.d.ts +4 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +1 -0
- package/migrations/0001_community_archive.sql +173 -0
- package/migrations/auth/0001_auth.sql +65 -0
- package/package.json +72 -4
package/README.md
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vielzeug/archive
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Community content publishing for Cloudflare Workers + D1: a hardened Worker core over per-collection payload schemas
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @vielzeug/archive @vielzeug/spell
|
|
9
|
+
npm install @vielzeug/archive @vielzeug/spell
|
|
10
|
+
yarn add @vielzeug/archive @vielzeug/spell
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
Define your content as one payload schema plus what to index, and export the Worker
|
|
16
|
+
built from it:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { defineArchiveCollection } from '@vielzeug/archive';
|
|
20
|
+
import { createArchiveWorker } from '@vielzeug/archive/worker';
|
|
21
|
+
import { s } from '@vielzeug/spell';
|
|
22
|
+
|
|
23
|
+
const payloadSchema = s.object({
|
|
24
|
+
cuisine: s.enum(['italian', 'japanese']),
|
|
25
|
+
ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),
|
|
26
|
+
name: s.string().trim().min(1).max(32),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const recipesCollection = defineArchiveCollection({
|
|
30
|
+
// Ingredient order is not identity: sort it into one canonical form.
|
|
31
|
+
canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),
|
|
32
|
+
contentVersion: 1,
|
|
33
|
+
filters: ['italian', 'japanese'],
|
|
34
|
+
index: (payload) => ({
|
|
35
|
+
filter: payload.cuisine,
|
|
36
|
+
tags: [payload.ingredients.length <= 5 ? 'quick' : 'involved'],
|
|
37
|
+
title: payload.name,
|
|
38
|
+
}),
|
|
39
|
+
name: 'recipes',
|
|
40
|
+
payloadSchema,
|
|
41
|
+
tags: ['quick', 'involved'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export default createArchiveWorker([recipesCollection]);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The collection's `contract` bundle then drives your client:
|
|
48
|
+
`recipesCollection.contract.entry`, `.page`, `.publishInput`, and `.updateInput`
|
|
49
|
+
are the same schemas the Worker uses. Payloads are stable JSON objects with
|
|
50
|
+
sorted-key serialization; vocabularies are optional readonly arrays. The root
|
|
51
|
+
entry is browser-safe. Deploy the sibling `@vielzeug/archive/auth` issuer with its
|
|
52
|
+
required `AUTH_ARCHIVE` binding for coordinated account deletion, and bind the
|
|
53
|
+
catalog's required `AUTH_ISSUER_SERVICE` to that issuer for an uncached
|
|
54
|
+
session-version check after local JWT verification.
|
|
55
|
+
|
|
56
|
+
Issuer sessions carry the required `sessionVersion` claim backed by
|
|
57
|
+
`auth_users.session_version`. `POST /api/v1/sign-out` only clears the current
|
|
58
|
+
cookie; `POST /api/v1/auth/sign-out-all` advances the version and revokes all
|
|
59
|
+
sessions. `GET /api/v1/auth/health` exposes the typed, read-only
|
|
60
|
+
`archiveAuthHealthSchema` readiness response. Issuer admission is durable D1
|
|
61
|
+
state: 20 requests per minute, plus three sends per email per hour; no issuer
|
|
62
|
+
rate-limiter binding is required. Magic-link GETs show a confirmation page; only
|
|
63
|
+
a deliberate guarded POST redeems the token.
|
|
64
|
+
|
|
65
|
+
Full account deletion revokes old issuer/subject sessions; profile-only deletion
|
|
66
|
+
does not. Re-enrollment uses a fresh issuer subject. Both Worker factories expose
|
|
67
|
+
typed `tap()` events and never log directly. Invalid content derives to `null`;
|
|
68
|
+
collection faults throw `ArchiveDerivationError`. Shipped DDL initializes fresh
|
|
69
|
+
databases only. Index projections are persisted data; changing a definition does
|
|
70
|
+
not update existing rows.
|
|
71
|
+
|
|
72
|
+
## Documentation
|
|
73
|
+
|
|
74
|
+
- [Overview](https://vielzeug.dev/archive/)
|
|
75
|
+
- [Usage Guide](https://vielzeug.dev/archive/usage)
|
|
76
|
+
- [API Reference](https://vielzeug.dev/archive/api)
|
|
77
|
+
- [Examples](https://vielzeug.dev/archive/examples)
|
|
78
|
+
- [Release Status](https://vielzeug.dev/archive/migration)
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT © [Helmuth Saatkamp](https://github.com/helmuthdu): part of the [Vielzeug](https://github.com/helmuthdu/vielzeug) monorepo.
|
package/dist/_crypto.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_crypto.cjs","names":[],"sources":["../src/_crypto.ts"],"sourcesContent":["/**\n * The one SHA-256→hex helper shared by both server surfaces: the collection content\n * hash (browser-safe root) and the issuer's sign-in-link token hash. Web Crypto\n * only; no D1, JWKS or binding machinery.\n */\nexport const sha256Hex = async (text: string): Promise<string> =>\n [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))]\n .map((byte) => byte.toString(16).padStart(2, '0'))\n .join('');\n"],"mappings":"AAKA,IAAa,UAAY,KAAO,IAC9B,CAAC,GAAG,IAAI,WAAW,MAAM,OAAO,OAAO,OAAO,UAAW,IAAI,YAAY,CAAC,CAAC,OAAO,CAAI,CAAC,CAAC,CAAC,CAAC,CACvF,IAAK,GAAS,EAAK,SAAS,EAAE,CAAC,CAAC,SAAS,EAAG,GAAG,CAAC,CAAC,CACjD,KAAK,EAAE"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one SHA-256→hex helper shared by both server surfaces: the collection content
|
|
3
|
+
* hash (browser-safe root) and the issuer's sign-in-link token hash. Web Crypto
|
|
4
|
+
* only; no D1, JWKS or binding machinery.
|
|
5
|
+
*/
|
|
6
|
+
export declare const sha256Hex: (text: string) => Promise<string>;
|
|
7
|
+
//# sourceMappingURL=_crypto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_crypto.d.ts","sourceRoot":"","sources":["../src/_crypto.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,SAAS,GAAU,MAAM,MAAM,KAAG,OAAO,CAAC,MAAM,CAGhD,CAAC"}
|
package/dist/_crypto.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_crypto.js","names":[],"sources":["../src/_crypto.ts"],"sourcesContent":["/**\n * The one SHA-256→hex helper shared by both server surfaces: the collection content\n * hash (browser-safe root) and the issuer's sign-in-link token hash. Web Crypto\n * only; no D1, JWKS or binding machinery.\n */\nexport const sha256Hex = async (text: string): Promise<string> =>\n [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))]\n .map((byte) => byte.toString(16).padStart(2, '0'))\n .join('');\n"],"mappings":"AAKA,IAAa,UAAY,KAAO,IAC9B,CAAC,GAAG,IAAI,WAAW,MAAM,OAAO,OAAO,OAAO,UAAW,IAAI,YAAY,CAAC,CAAC,OAAO,CAAI,CAAC,CAAC,CAAC,CAAC,CACvF,IAAK,GAAS,EAAK,SAAS,EAAE,CAAC,CAAC,SAAS,EAAG,GAAG,CAAC,CAAC,CACjD,KAAK,EAAE"}
|
package/dist/_json.cjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
function canonicalJson(e,t=!1,n){let r=new Set,text=e=>/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(e)?null:JSON.stringify(e),serialize=(e,n)=>{if(e===null)return`null`;if(typeof e==`string`)return text(e);if(typeof e==`boolean`)return String(e);if(typeof e==`number`)return Number.isFinite(e)?JSON.stringify(e):null;if(typeof e!=`object`||r.has(e))return null;let i=Array.isArray(e),a=Object.getPrototypeOf(e);if(!i&&a!==Object.prototype&&a!==null||i&&a!==Array.prototype)return null;r.add(e);try{let r=[],a=Reflect.ownKeys(e);if(i){if(a.length!==e.length+1)return null;for(let t=0;t<e.length;t++){let i=Object.getOwnPropertyDescriptor(e,String(t));if(!i||!i.enumerable||!(`value`in i))return null;let a=serialize(i.value,Array.isArray(n)?n[t]:void 0);if(a===null)return null;r.push(a)}return`[${r.join(`,`)}]`}if(a.some(e=>typeof e!=`string`))return null;for(let i of Object.keys(e).sort()){let a=Object.getOwnPropertyDescriptor(e,i);if(!a||!(`value`in a))return null;let o=typeof n==`object`&&n?n:null;if(a.value===void 0&&t&&(o===null||!Object.hasOwn(o,i)))continue;let s=text(i),c=serialize(a.value,o===null?void 0:Reflect.get(o,i));if(s===null||c===null)return null;r.push(`${s}:${c}`)}return Object.keys(e).length===a.length?`{${r.join(`,`)}}`:null}finally{r.delete(e)}};try{return serialize(e,n)}catch{return null}}function canonicalObjectJson(e,t=!1,n){return typeof e!=`object`||!e||Array.isArray(e)?null:canonicalJson(e,t,n)}exports.canonicalJson=canonicalJson,exports.canonicalObjectJson=canonicalObjectJson;
|
|
2
|
+
//# sourceMappingURL=_json.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_json.cjs","names":[],"sources":["../src/_json.ts"],"sourcesContent":["/** Canonical JSON rejects values JSON.stringify would silently discard or coerce. */\nexport function canonicalJson(value: unknown, omitUndefinedFields = false, omissionSource?: unknown): string | null {\n const active = new Set<object>();\n const text = (value: string): string | null => {\n if (/[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?<![\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]/u.test(value)) return null;\n return JSON.stringify(value);\n };\n const serialize = (value: unknown, source?: unknown): string | null => {\n if (value === null) return 'null';\n if (typeof value === 'string') return text(value);\n if (typeof value === 'boolean') return String(value);\n if (typeof value === 'number') return Number.isFinite(value) ? JSON.stringify(value) : null;\n if (typeof value !== 'object' || active.has(value)) return null;\n const array = Array.isArray(value);\n const prototype = Object.getPrototypeOf(value);\n if (!array && prototype !== Object.prototype && prototype !== null) return null;\n if (array && prototype !== Array.prototype) return null;\n active.add(value);\n try {\n const fields: string[] = [];\n const keys = Reflect.ownKeys(value);\n if (array) {\n if (keys.length !== value.length + 1) return null;\n for (let i = 0; i < value.length; i++) {\n const descriptor = Object.getOwnPropertyDescriptor(value, String(i));\n if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return null;\n const item = serialize(descriptor.value, Array.isArray(source) ? source[i] : undefined);\n if (item === null) return null;\n fields.push(item);\n }\n return `[${fields.join(',')}]`;\n }\n if (keys.some((key) => typeof key !== 'string')) return null;\n for (const key of Object.keys(value).sort()) {\n const descriptor = Object.getOwnPropertyDescriptor(value, key);\n if (!descriptor || !('value' in descriptor)) return null;\n const sourceObject = source !== null && typeof source === 'object' ? source : null;\n // Spell materializes absent optional object fields as undefined.\n if (\n descriptor.value === undefined &&\n omitUndefinedFields &&\n (sourceObject === null || !Object.hasOwn(sourceObject, key))\n )\n continue;\n const name = text(key);\n const item = serialize(descriptor.value, sourceObject === null ? undefined : Reflect.get(sourceObject, key));\n if (name === null || item === null) return null;\n fields.push(`${name}:${item}`);\n }\n if (Object.keys(value).length !== keys.length) return null;\n return `{${fields.join(',')}}`;\n } finally {\n active.delete(value);\n }\n };\n try {\n return serialize(value, omissionSource);\n } catch {\n return null;\n }\n}\n\nexport function canonicalObjectJson(\n value: unknown,\n omitUndefinedFields = false,\n omissionSource?: unknown,\n): string | null {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;\n return canonicalJson(value, omitUndefinedFields, omissionSource);\n}\n"],"mappings":"AACA,SAAgB,cAAc,EAAgB,EAAsB,GAAO,EAAyC,CAClH,IAAM,EAAS,IAAI,IACb,KAAQ,GACR,0EAA0E,KAAK,CAAK,EAAU,KAC3F,KAAK,UAAU,CAAK,EAEvB,WAAa,EAAgB,IAAoC,CACrE,GAAI,IAAU,KAAM,MAAO,OAC3B,GAAI,OAAO,GAAU,SAAU,OAAO,KAAK,CAAK,EAChD,GAAI,OAAO,GAAU,UAAW,OAAO,OAAO,CAAK,EACnD,GAAI,OAAO,GAAU,SAAU,OAAO,OAAO,SAAS,CAAK,EAAI,KAAK,UAAU,CAAK,EAAI,KACvF,GAAI,OAAO,GAAU,UAAY,EAAO,IAAI,CAAK,EAAG,OAAO,KAC3D,IAAM,EAAQ,MAAM,QAAQ,CAAK,EAC3B,EAAY,OAAO,eAAe,CAAK,EAE7C,GADI,CAAC,GAAS,IAAc,OAAO,WAAa,IAAc,MAC1D,GAAS,IAAc,MAAM,UAAW,OAAO,KACnD,EAAO,IAAI,CAAK,EAChB,GAAI,CACF,IAAM,EAAmB,CAAC,EACpB,EAAO,QAAQ,QAAQ,CAAK,EAClC,GAAI,EAAO,CACT,GAAI,EAAK,SAAW,EAAM,OAAS,EAAG,OAAO,KAC7C,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,OAAQ,IAAK,CACrC,IAAM,EAAa,OAAO,yBAAyB,EAAO,OAAO,CAAC,CAAC,EACnE,GAAI,CAAC,GAAc,CAAC,EAAW,YAAc,EAAE,UAAW,GAAa,OAAO,KAC9E,IAAM,EAAO,UAAU,EAAW,MAAO,MAAM,QAAQ,CAAM,EAAI,EAAO,GAAK,IAAA,EAAS,EACtF,GAAI,IAAS,KAAM,OAAO,KAC1B,EAAO,KAAK,CAAI,CAClB,CACA,MAAO,IAAI,EAAO,KAAK,GAAG,EAAE,EAC9B,CACA,GAAI,EAAK,KAAM,GAAQ,OAAO,GAAQ,QAAQ,EAAG,OAAO,KACxD,IAAK,IAAM,KAAO,OAAO,KAAK,CAAK,CAAC,CAAC,KAAK,EAAG,CAC3C,IAAM,EAAa,OAAO,yBAAyB,EAAO,CAAG,EAC7D,GAAI,CAAC,GAAc,EAAE,UAAW,GAAa,OAAO,KACpD,IAAM,EAAkC,OAAO,GAAW,UAArC,EAAgD,EAAS,KAE9E,GACE,EAAW,QAAU,IAAA,IACrB,IACC,IAAiB,MAAQ,CAAC,OAAO,OAAO,EAAc,CAAG,GAE1D,SACF,IAAM,EAAO,KAAK,CAAG,EACf,EAAO,UAAU,EAAW,MAAO,IAAiB,KAAO,IAAA,GAAY,QAAQ,IAAI,EAAc,CAAG,CAAC,EAC3G,GAAI,IAAS,MAAQ,IAAS,KAAM,OAAO,KAC3C,EAAO,KAAK,GAAG,EAAK,GAAG,GAAM,CAC/B,CAEA,OADI,OAAO,KAAK,CAAK,CAAC,CAAC,SAAW,EAAK,OAChC,IAAI,EAAO,KAAK,GAAG,EAAE,GAD0B,IAExD,QAAU,CACR,EAAO,OAAO,CAAK,CACrB,CACF,EACA,GAAI,CACF,OAAO,UAAU,EAAO,CAAc,CACxC,MAAQ,CACN,OAAO,IACT,CACF,CAEA,SAAgB,oBACd,EACA,EAAsB,GACtB,EACe,CAEf,OADsB,OAAO,GAAU,WAAnC,GAA+C,MAAM,QAAQ,CAAK,EAAU,KACzE,cAAc,EAAO,EAAqB,CAAc,CACjE"}
|
package/dist/_json.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Canonical JSON rejects values JSON.stringify would silently discard or coerce. */
|
|
2
|
+
export declare function canonicalJson(value: unknown, omitUndefinedFields?: boolean, omissionSource?: unknown): string | null;
|
|
3
|
+
export declare function canonicalObjectJson(value: unknown, omitUndefinedFields?: boolean, omissionSource?: unknown): string | null;
|
|
4
|
+
//# sourceMappingURL=_json.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_json.d.ts","sourceRoot":"","sources":["../src/_json.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,mBAAmB,UAAQ,EAAE,cAAc,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CA2DlH;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,OAAO,EACd,mBAAmB,UAAQ,EAC3B,cAAc,CAAC,EAAE,OAAO,GACvB,MAAM,GAAG,IAAI,CAGf"}
|
package/dist/_json.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
function canonicalJson(e,t=!1,n){let r=new Set,text=e=>/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(e)?null:JSON.stringify(e),serialize=(e,n)=>{if(e===null)return`null`;if(typeof e==`string`)return text(e);if(typeof e==`boolean`)return String(e);if(typeof e==`number`)return Number.isFinite(e)?JSON.stringify(e):null;if(typeof e!=`object`||r.has(e))return null;let i=Array.isArray(e),a=Object.getPrototypeOf(e);if(!i&&a!==Object.prototype&&a!==null||i&&a!==Array.prototype)return null;r.add(e);try{let r=[],a=Reflect.ownKeys(e);if(i){if(a.length!==e.length+1)return null;for(let t=0;t<e.length;t++){let i=Object.getOwnPropertyDescriptor(e,String(t));if(!i||!i.enumerable||!(`value`in i))return null;let a=serialize(i.value,Array.isArray(n)?n[t]:void 0);if(a===null)return null;r.push(a)}return`[${r.join(`,`)}]`}if(a.some(e=>typeof e!=`string`))return null;for(let i of Object.keys(e).sort()){let a=Object.getOwnPropertyDescriptor(e,i);if(!a||!(`value`in a))return null;let o=typeof n==`object`&&n?n:null;if(a.value===void 0&&t&&(o===null||!Object.hasOwn(o,i)))continue;let s=text(i),c=serialize(a.value,o===null?void 0:Reflect.get(o,i));if(s===null||c===null)return null;r.push(`${s}:${c}`)}return Object.keys(e).length===a.length?`{${r.join(`,`)}}`:null}finally{r.delete(e)}};try{return serialize(e,n)}catch{return null}}function canonicalObjectJson(e,t=!1,n){return typeof e!=`object`||!e||Array.isArray(e)?null:canonicalJson(e,t,n)}export{canonicalJson,canonicalObjectJson};
|
|
2
|
+
//# sourceMappingURL=_json.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_json.js","names":[],"sources":["../src/_json.ts"],"sourcesContent":["/** Canonical JSON rejects values JSON.stringify would silently discard or coerce. */\nexport function canonicalJson(value: unknown, omitUndefinedFields = false, omissionSource?: unknown): string | null {\n const active = new Set<object>();\n const text = (value: string): string | null => {\n if (/[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?<![\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]/u.test(value)) return null;\n return JSON.stringify(value);\n };\n const serialize = (value: unknown, source?: unknown): string | null => {\n if (value === null) return 'null';\n if (typeof value === 'string') return text(value);\n if (typeof value === 'boolean') return String(value);\n if (typeof value === 'number') return Number.isFinite(value) ? JSON.stringify(value) : null;\n if (typeof value !== 'object' || active.has(value)) return null;\n const array = Array.isArray(value);\n const prototype = Object.getPrototypeOf(value);\n if (!array && prototype !== Object.prototype && prototype !== null) return null;\n if (array && prototype !== Array.prototype) return null;\n active.add(value);\n try {\n const fields: string[] = [];\n const keys = Reflect.ownKeys(value);\n if (array) {\n if (keys.length !== value.length + 1) return null;\n for (let i = 0; i < value.length; i++) {\n const descriptor = Object.getOwnPropertyDescriptor(value, String(i));\n if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return null;\n const item = serialize(descriptor.value, Array.isArray(source) ? source[i] : undefined);\n if (item === null) return null;\n fields.push(item);\n }\n return `[${fields.join(',')}]`;\n }\n if (keys.some((key) => typeof key !== 'string')) return null;\n for (const key of Object.keys(value).sort()) {\n const descriptor = Object.getOwnPropertyDescriptor(value, key);\n if (!descriptor || !('value' in descriptor)) return null;\n const sourceObject = source !== null && typeof source === 'object' ? source : null;\n // Spell materializes absent optional object fields as undefined.\n if (\n descriptor.value === undefined &&\n omitUndefinedFields &&\n (sourceObject === null || !Object.hasOwn(sourceObject, key))\n )\n continue;\n const name = text(key);\n const item = serialize(descriptor.value, sourceObject === null ? undefined : Reflect.get(sourceObject, key));\n if (name === null || item === null) return null;\n fields.push(`${name}:${item}`);\n }\n if (Object.keys(value).length !== keys.length) return null;\n return `{${fields.join(',')}}`;\n } finally {\n active.delete(value);\n }\n };\n try {\n return serialize(value, omissionSource);\n } catch {\n return null;\n }\n}\n\nexport function canonicalObjectJson(\n value: unknown,\n omitUndefinedFields = false,\n omissionSource?: unknown,\n): string | null {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;\n return canonicalJson(value, omitUndefinedFields, omissionSource);\n}\n"],"mappings":"AACA,SAAgB,cAAc,EAAgB,EAAsB,GAAO,EAAyC,CAClH,IAAM,EAAS,IAAI,IACb,KAAQ,GACR,0EAA0E,KAAK,CAAK,EAAU,KAC3F,KAAK,UAAU,CAAK,EAEvB,WAAa,EAAgB,IAAoC,CACrE,GAAI,IAAU,KAAM,MAAO,OAC3B,GAAI,OAAO,GAAU,SAAU,OAAO,KAAK,CAAK,EAChD,GAAI,OAAO,GAAU,UAAW,OAAO,OAAO,CAAK,EACnD,GAAI,OAAO,GAAU,SAAU,OAAO,OAAO,SAAS,CAAK,EAAI,KAAK,UAAU,CAAK,EAAI,KACvF,GAAI,OAAO,GAAU,UAAY,EAAO,IAAI,CAAK,EAAG,OAAO,KAC3D,IAAM,EAAQ,MAAM,QAAQ,CAAK,EAC3B,EAAY,OAAO,eAAe,CAAK,EAE7C,GADI,CAAC,GAAS,IAAc,OAAO,WAAa,IAAc,MAC1D,GAAS,IAAc,MAAM,UAAW,OAAO,KACnD,EAAO,IAAI,CAAK,EAChB,GAAI,CACF,IAAM,EAAmB,CAAC,EACpB,EAAO,QAAQ,QAAQ,CAAK,EAClC,GAAI,EAAO,CACT,GAAI,EAAK,SAAW,EAAM,OAAS,EAAG,OAAO,KAC7C,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,OAAQ,IAAK,CACrC,IAAM,EAAa,OAAO,yBAAyB,EAAO,OAAO,CAAC,CAAC,EACnE,GAAI,CAAC,GAAc,CAAC,EAAW,YAAc,EAAE,UAAW,GAAa,OAAO,KAC9E,IAAM,EAAO,UAAU,EAAW,MAAO,MAAM,QAAQ,CAAM,EAAI,EAAO,GAAK,IAAA,EAAS,EACtF,GAAI,IAAS,KAAM,OAAO,KAC1B,EAAO,KAAK,CAAI,CAClB,CACA,MAAO,IAAI,EAAO,KAAK,GAAG,EAAE,EAC9B,CACA,GAAI,EAAK,KAAM,GAAQ,OAAO,GAAQ,QAAQ,EAAG,OAAO,KACxD,IAAK,IAAM,KAAO,OAAO,KAAK,CAAK,CAAC,CAAC,KAAK,EAAG,CAC3C,IAAM,EAAa,OAAO,yBAAyB,EAAO,CAAG,EAC7D,GAAI,CAAC,GAAc,EAAE,UAAW,GAAa,OAAO,KACpD,IAAM,EAAkC,OAAO,GAAW,UAArC,EAAgD,EAAS,KAE9E,GACE,EAAW,QAAU,IAAA,IACrB,IACC,IAAiB,MAAQ,CAAC,OAAO,OAAO,EAAc,CAAG,GAE1D,SACF,IAAM,EAAO,KAAK,CAAG,EACf,EAAO,UAAU,EAAW,MAAO,IAAiB,KAAO,IAAA,GAAY,QAAQ,IAAI,EAAc,CAAG,CAAC,EAC3G,GAAI,IAAS,MAAQ,IAAS,KAAM,OAAO,KAC3C,EAAO,KAAK,GAAG,EAAK,GAAG,GAAM,CAC/B,CAEA,OADI,OAAO,KAAK,CAAK,CAAC,CAAC,SAAW,EAAK,OAChC,IAAI,EAAO,KAAK,GAAG,EAAE,GAD0B,IAExD,QAAU,CACR,EAAO,OAAO,CAAK,CACrB,CACF,EACA,GAAI,CACF,OAAO,UAAU,EAAO,CAAc,CACxC,MAAQ,CACN,OAAO,IACT,CACF,CAEA,SAAgB,oBACd,EACA,EAAsB,GACtB,EACe,CAEf,OADsB,OAAO,GAAU,WAAnC,GAA+C,MAAM,QAAQ,CAAK,EAAU,KACzE,cAAc,EAAO,EAAqB,CAAc,CACjE"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("./errors.cjs");let t=require("jose");var configError=()=>new e.AuthOperationError(503,`configuration`),n=320,r=64,i=342,a=/^[A-Za-z0-9_-]+$/,o=new Set([`127.0.0.1`,`localhost`,`[::1]`]),s=[`d`,`p`,`q`,`dp`,`dq`,`qi`,`oth`,`k`],isWellFormedEmail=e=>e.length<=n&&e===e.toLowerCase()&&/^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/.test(e),requireString=e=>{if(typeof e!=`string`||e.length===0)throw configError();return e},requireBinding=(e,t)=>{if(typeof e!=`object`||!e||typeof Reflect.get(e,t)!=`function`)throw configError()},validateUrl=(e,t)=>{let n;try{n=new URL(e)}catch{throw configError()}if(n.username!==``||n.password!==``||n.hash!==``)throw configError();let r=o.has(n.hostname);if(t===`local`?n.protocol!==`http:`||!r:n.protocol!==`https:`||r)throw configError();return n},validateOrigin=(e,t)=>{let n=validateUrl(e,t);if(n.pathname!==`/`||n.search!==``||n.origin!==e)throw configError();return n.origin},isRecord=e=>typeof e==`object`&&!!e,parseJson=e=>{try{return JSON.parse(e)}catch{throw configError()}},b64urlMember=(e,t)=>{let n=e[t];if(typeof n!=`string`||!a.test(n))throw configError();return n},checkRsaMetadata=e=>{if(e.kty!==`RSA`||e.alg!==void 0&&e.alg!==`RS256`||e.use!==void 0&&e.use!==`sig`)throw configError()},publicMembers=e=>{let t=b64urlMember(e,`n`);if(t.length<i)throw configError();return{e:b64urlMember(e,`e`),kty:`RSA`,n:t}},toSigningKey=async e=>({alg:`RS256`,e:e.e,kid:await(0,t.calculateJwkThumbprint)({e:e.e,kty:`RSA`,n:e.n}),kty:`RSA`,n:e.n,use:`sig`});async function activeKey(e){let n=parseJson(e);if(!isRecord(n))throw configError();checkRsaMetadata(n);let r=publicMembers(n),i={...r,d:b64urlMember(n,`d`),dp:b64urlMember(n,`dp`),dq:b64urlMember(n,`dq`),p:b64urlMember(n,`p`),q:b64urlMember(n,`q`),qi:b64urlMember(n,`qi`)},a;try{let e=await(0,t.importJWK)(i,`RS256`,{extractable:!1});if(e instanceof Uint8Array)throw configError();a=e}catch{throw configError()}return{key:a,public:await toSigningKey(r)}}async function previousKeys(e){if(e===void 0||e.trim().length===0)return[];let t=parseJson(e);if(!isRecord(t)||!Array.isArray(t.keys))throw configError();let n=[];for(let e of t.keys){if(!isRecord(e)||(checkRsaMetadata(e),s.some(t=>t in e)))throw configError();n.push(await toSigningKey(publicMembers(e)))}return n}var c=new WeakMap;async function buildConfig(e){let n=requireString(e.APP_ENV);if(n!==`local`&&n!==`production`)throw configError();let i=n,a=new Set(requireString(e.ALLOWED_ORIGINS).split(`,`).map(e=>e.trim()).filter(e=>e.length>0).map(e=>validateOrigin(e,i)));if(a.size===0)throw configError();let o=validateOrigin(requireString(e.PUBLIC_ORIGIN),i),s=requireString(e.AUTH_ISSUER);validateUrl(s,i);let c=requireString(e.AUDIENCE),l=requireString(e.APP_NAME);if(l.length>r||/[\p{Cc}\p{Cs}]/u.test(l))throw configError();let u=requireString(e.SENDER_ADDRESS);if(!isWellFormedEmail(u)||typeof e.OPERATOR_EMAILS!=`string`)throw configError();let d=new Set(e.OPERATOR_EMAILS.split(`,`).map(e=>e.trim().toLowerCase()).filter(e=>e.length>0));for(let e of d)if(!isWellFormedEmail(e))throw configError();requireBinding(e.AUTH_DB,`prepare`),requireBinding(e.AUTH_DB,`batch`),requireBinding(e.AUTH_EMAIL,`send`),requireBinding(e.AUTH_ARCHIVE,`fetch`);let f=await activeKey(requireString(e.AUTH_JWT_PRIVATE_JWK));try{let e=await new t.SignJWT({}).setProtectedHeader({alg:`RS256`,kid:f.public.kid}).sign(f.key);await(0,t.jwtVerify)(e,(0,t.createLocalJWKSet)({keys:[f.public]}),{algorithms:[`RS256`]})}catch{throw configError()}let p=(await previousKeys(e.AUTH_JWT_PREVIOUS_PUBLIC_JWKS)).filter(e=>e.kid!==f.public.kid),m=[f.public,...p.filter((e,t)=>p.findIndex(t=>t.kid===e.kid)===t)];return{allowedOrigins:a,appEnv:i,appName:l,audience:c,issuer:s,jwks:m,operatorEmails:d,publicOrigin:o,senderAddress:u,signing:{key:f.key,kid:f.public.kid},verifier:(0,t.createLocalJWKSet)({keys:[...m]})}}function authConfig(e){let t=c.get(e);if(t!==void 0)return t;let n=buildConfig(e);return c.set(e,n),n.catch(()=>c.delete(e)),n}exports.authConfig=authConfig,exports.isWellFormedEmail=isWellFormedEmail;
|
|
2
|
+
//# sourceMappingURL=config.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.cjs","names":[],"sources":["../../src/auth/config.ts"],"sourcesContent":["import {\n type CryptoKey,\n calculateJwkThumbprint,\n createLocalJWKSet,\n importJWK,\n type JWK_RSA_Private,\n type JWK_RSA_Public,\n jwtVerify,\n SignJWT,\n} from 'jose';\nimport type { D1Like } from '../worker/env';\nimport type { ApiError } from '../worker/http';\nimport { AuthOperationError } from './errors';\n\n/** The send-email binding subset the issuer calls; the runtime's SendEmail satisfies it. */\nexport interface SendEmail {\n send(message: { from: string; html?: string; subject: string; text: string; to: string }): Promise<unknown>;\n}\n\n/**\n * The catalog Worker as the issuer reaches it: a Cloudflare service binding (or any\n * `fetch`-shaped stub). Account erasure calls the catalog's `DELETE /api/v1/me/account`\n * through it before removing the issuer's own row.\n */\nexport interface AuthArchiveBinding {\n fetch(request: Request): Promise<Response>;\n}\n\n/**\n * The bindings the auth issuer reads. Declared structurally, like the catalog\n * Worker's Env: a deployment's generated `ExportedHandler<Env>` accepts the factory's\n * result as-is, with no `@cloudflare/workers-types` dependency. Every value is\n * validated on first use per bindings object; a missing or malformed one answers\n * 503 `unavailable` on every route, never a permissive fallback.\n */\nexport interface AuthWorkerEnv {\n /** Comma-separated exact origins allowed to call unsafe routes and read CORS responses. */\n ALLOWED_ORIGINS: string;\n /** Exactly `local` (loopback HTTP only) or `production` (non-loopback HTTPS, Secure cookies). */\n APP_ENV: string;\n /** The app's display name: the magic-link email's subject and copy name it. */\n APP_NAME: string;\n AUDIENCE: string;\n /** Service binding to the sibling catalog Worker: account erasure removes the catalog profile through it. */\n AUTH_ARCHIVE: AuthArchiveBinding;\n /** The issuer's own D1: accounts, links, per-account send and singleton admission budgets. */\n AUTH_DB: D1Like;\n AUTH_EMAIL: SendEmail;\n /** The `iss` claim every token carries, exactly as the catalog Worker's `JWT_ISSUER` knows it. */\n AUTH_ISSUER: string;\n /**\n * Optional JWKS JSON (`{\"keys\":[...]}`) of retired PUBLIC RS256 keys, still served\n * from `/jwks.json` and accepted by the issuer so sessions minted before a rotation\n * keep verifying until they expire. Private members are rejected.\n */\n AUTH_JWT_PREVIOUS_PUBLIC_JWKS?: string;\n /** The active RS256 private key as a JWK JSON string (a wrangler secret): signs every new session. */\n AUTH_JWT_PRIVATE_JWK: string;\n /** Comma-separated emails whose sessions carry the operator role claim. */\n OPERATOR_EMAILS: string;\n /** The app's public origin: magic links, the signed-in redirect and the catalog erasure URL point here. */\n PUBLIC_ORIGIN: string;\n /** The address emails come from: a domain onboarded to Cloudflare Email Service. */\n SENDER_ADDRESS: string;\n}\n\nexport type AuthAppEnv = 'local' | 'production';\n\nexport interface PublicSigningKey extends JWK_RSA_Public {\n alg: 'RS256';\n kid: string;\n kty: 'RSA';\n use: 'sig';\n}\n\nexport interface AuthConfig {\n allowedOrigins: ReadonlySet<string>;\n appEnv: AuthAppEnv;\n appName: string;\n audience: string;\n issuer: string;\n /** Every key a session may be verified with: the active key first, then retired ones. */\n jwks: readonly PublicSigningKey[];\n operatorEmails: ReadonlySet<string>;\n publicOrigin: string;\n senderAddress: string;\n signing: { key: CryptoKey; kid: string };\n verifier: ReturnType<typeof createLocalJWKSet>;\n}\n\n// Configuration faults never name the offending field: the response must not leak\n// Env names, only a generic temporary unavailability.\nconst configError = (): ApiError => new AuthOperationError(503, 'configuration');\n\nconst EMAIL_MAX = 320;\nconst APP_NAME_MAX = 64;\n// 2048-bit RSA modulus: 256 bytes, 342 base64url characters unpadded.\nconst MIN_MODULUS_CHARS = 342;\nconst BASE64URL = /^[A-Za-z0-9_-]+$/;\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);\nconst PRIVATE_RSA_MEMBERS = ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'] as const;\n\nexport const isWellFormedEmail = (value: string): boolean =>\n value.length <= EMAIL_MAX &&\n value === value.toLowerCase() &&\n /^[^\\s@<>\"',;]+@[^\\s@<>\"',;]+\\.[^\\s@<>\"',;]+$/.test(value);\n\nconst requireString = (value: unknown): string => {\n if (typeof value !== 'string' || value.length === 0) throw configError();\n return value;\n};\n\nconst requireBinding = (value: unknown, method: string): void => {\n if (typeof value !== 'object' || value === null) throw configError();\n if (typeof Reflect.get(value, method) !== 'function') throw configError();\n};\n\n/** Transport rules: local is loopback HTTP only, production is non-loopback HTTPS; no credentials or fragments. */\nconst validateUrl = (raw: string, appEnv: AuthAppEnv): URL => {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n throw configError();\n }\n if (url.username !== '' || url.password !== '' || url.hash !== '') throw configError();\n const loopback = LOOPBACK_HOSTS.has(url.hostname);\n if (appEnv === 'local' ? url.protocol !== 'http:' || !loopback : url.protocol !== 'https:' || loopback) {\n throw configError();\n }\n return url;\n};\n\n/** An exact canonical origin: scheme + host (+port), no path, query, wildcard or `null`. */\nconst validateOrigin = (raw: string, appEnv: AuthAppEnv): string => {\n const url = validateUrl(raw, appEnv);\n if (url.pathname !== '/' || url.search !== '' || url.origin !== raw) throw configError();\n return url.origin;\n};\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;\n\nconst parseJson = (raw: string): unknown => {\n try {\n return JSON.parse(raw);\n } catch {\n throw configError();\n }\n};\n\nconst b64urlMember = (jwk: Record<string, unknown>, name: string): string => {\n const value = jwk[name];\n if (typeof value !== 'string' || !BASE64URL.test(value)) throw configError();\n return value;\n};\n\n/** RS256 signing-key metadata, when present, must agree with how the issuer uses the key. */\nconst checkRsaMetadata = (jwk: Record<string, unknown>): void => {\n if (jwk.kty !== 'RSA') throw configError();\n if (jwk.alg !== undefined && jwk.alg !== 'RS256') throw configError();\n if (jwk.use !== undefined && jwk.use !== 'sig') throw configError();\n};\n\nconst publicMembers = (jwk: Record<string, unknown>): JWK_RSA_Public => {\n const n = b64urlMember(jwk, 'n');\n if (n.length < MIN_MODULUS_CHARS) throw configError();\n return { e: b64urlMember(jwk, 'e'), kty: 'RSA', n };\n};\n\nconst toSigningKey = async (pub: JWK_RSA_Public): Promise<PublicSigningKey> => ({\n alg: 'RS256',\n e: pub.e,\n kid: await calculateJwkThumbprint({ e: pub.e, kty: 'RSA', n: pub.n }),\n kty: 'RSA',\n n: pub.n,\n use: 'sig',\n});\n\nasync function activeKey(raw: string): Promise<{ key: CryptoKey; public: PublicSigningKey }> {\n const parsed = parseJson(raw);\n if (!isRecord(parsed)) throw configError();\n checkRsaMetadata(parsed);\n const pub = publicMembers(parsed);\n const jwk: JWK_RSA_Private = {\n ...pub,\n d: b64urlMember(parsed, 'd'),\n dp: b64urlMember(parsed, 'dp'),\n dq: b64urlMember(parsed, 'dq'),\n p: b64urlMember(parsed, 'p'),\n q: b64urlMember(parsed, 'q'),\n qi: b64urlMember(parsed, 'qi'),\n };\n let key: CryptoKey;\n try {\n const imported = await importJWK(jwk, 'RS256', { extractable: false });\n if (imported instanceof Uint8Array) throw configError();\n key = imported;\n } catch {\n throw configError();\n }\n return { key, public: await toSigningKey(pub) };\n}\n\n/** Retired verification keys: public RSA members only; any private member is a misconfiguration. */\nasync function previousKeys(raw: string | undefined): Promise<PublicSigningKey[]> {\n if (raw === undefined || raw.trim().length === 0) return [];\n const parsed = parseJson(raw);\n if (!isRecord(parsed) || !Array.isArray(parsed.keys)) throw configError();\n const keys: PublicSigningKey[] = [];\n for (const entry of parsed.keys) {\n if (!isRecord(entry)) throw configError();\n checkRsaMetadata(entry);\n if (PRIVATE_RSA_MEMBERS.some((member) => member in entry)) throw configError();\n keys.push(await toSigningKey(publicMembers(entry)));\n }\n return keys;\n}\n\nconst configs = new WeakMap<AuthWorkerEnv, Promise<AuthConfig>>();\n\nasync function buildConfig(env: AuthWorkerEnv): Promise<AuthConfig> {\n const appEnvRaw = requireString(env.APP_ENV);\n if (appEnvRaw !== 'local' && appEnvRaw !== 'production') throw configError();\n const appEnv: AuthAppEnv = appEnvRaw;\n\n const allowedOrigins = new Set(\n requireString(env.ALLOWED_ORIGINS)\n .split(',')\n .map((origin) => origin.trim())\n .filter((origin) => origin.length > 0)\n .map((origin) => validateOrigin(origin, appEnv)),\n );\n if (allowedOrigins.size === 0) throw configError();\n\n const publicOrigin = validateOrigin(requireString(env.PUBLIC_ORIGIN), appEnv);\n // The issuer is matched as the exact configured string (a trailing slash is meaningful).\n const issuer = requireString(env.AUTH_ISSUER);\n validateUrl(issuer, appEnv);\n const audience = requireString(env.AUDIENCE);\n const appName = requireString(env.APP_NAME);\n if (appName.length > APP_NAME_MAX || /[\\p{Cc}\\p{Cs}]/u.test(appName)) throw configError();\n const senderAddress = requireString(env.SENDER_ADDRESS);\n if (!isWellFormedEmail(senderAddress)) throw configError();\n if (typeof env.OPERATOR_EMAILS !== 'string') throw configError();\n const operatorEmails = new Set(\n env.OPERATOR_EMAILS.split(',')\n .map((entry) => entry.trim().toLowerCase())\n .filter((entry) => entry.length > 0),\n );\n for (const email of operatorEmails) if (!isWellFormedEmail(email)) throw configError();\n\n requireBinding(env.AUTH_DB, 'prepare');\n requireBinding(env.AUTH_DB, 'batch');\n requireBinding(env.AUTH_EMAIL, 'send');\n requireBinding(env.AUTH_ARCHIVE, 'fetch');\n\n const active = await activeKey(requireString(env.AUTH_JWT_PRIVATE_JWK));\n try {\n const proof = await new SignJWT({}).setProtectedHeader({ alg: 'RS256', kid: active.public.kid }).sign(active.key);\n await jwtVerify(proof, createLocalJWKSet({ keys: [active.public] }), { algorithms: ['RS256'] });\n } catch {\n throw configError();\n }\n const retired = (await previousKeys(env.AUTH_JWT_PREVIOUS_PUBLIC_JWKS)).filter(\n (key) => key.kid !== active.public.kid,\n );\n const jwks = [\n active.public,\n ...retired.filter((key, i) => retired.findIndex((other) => other.kid === key.kid) === i),\n ];\n\n return {\n allowedOrigins,\n appEnv,\n appName,\n audience,\n issuer,\n jwks,\n operatorEmails,\n publicOrigin,\n senderAddress,\n signing: { key: active.key, kid: active.public.kid },\n verifier: createLocalJWKSet({ keys: [...jwks] }),\n };\n}\n\n/**\n * Validates the env and imports its keys once per bindings object. A failed\n * validation is not cached, so a corrected binding set recovers without a restart.\n */\nexport function authConfig(env: AuthWorkerEnv): Promise<AuthConfig> {\n const cached = configs.get(env);\n if (cached !== undefined) return cached;\n const pending = buildConfig(env);\n configs.set(env, pending);\n pending.catch(() => configs.delete(env));\n return pending;\n}\n"],"mappings":"sDA4FA,IAAM,gBAA8B,IAAI,EAAA,mBAAmB,IAAK,eAAe,EAEzE,EAAY,IACZ,EAAe,GAEf,EAAoB,IACpB,EAAY,mBACZ,EAAiB,IAAI,IAAI,CAAC,YAAa,YAAa,OAAO,CAAC,EAC5D,EAAsB,CAAC,IAAK,IAAK,IAAK,KAAM,KAAM,KAAM,MAAO,GAAG,EAE3D,kBAAqB,GAChC,EAAM,QAAU,GAChB,IAAU,EAAM,YAAY,GAC5B,+CAA+C,KAAK,CAAK,EAErD,cAAiB,GAA2B,CAChD,GAAI,OAAO,GAAU,UAAY,EAAM,SAAW,EAAG,MAAM,YAAY,EACvE,OAAO,CACT,EAEM,gBAAkB,EAAgB,IAAyB,CAE/D,GADI,OAAO,GAAU,WAAY,GAC7B,OAAO,QAAQ,IAAI,EAAO,CAAM,GAAM,WAAY,MAAM,YAAY,CAC1E,EAGM,aAAe,EAAa,IAA4B,CAC5D,IAAI,EACJ,GAAI,CACF,EAAM,IAAI,IAAI,CAAG,CACnB,MAAQ,CACN,MAAM,YAAY,CACpB,CACA,GAAI,EAAI,WAAa,IAAM,EAAI,WAAa,IAAM,EAAI,OAAS,GAAI,MAAM,YAAY,EACrF,IAAM,EAAW,EAAe,IAAI,EAAI,QAAQ,EAChD,GAAI,IAAW,QAAU,EAAI,WAAa,SAAW,CAAC,EAAW,EAAI,WAAa,UAAY,EAC5F,MAAM,YAAY,EAEpB,OAAO,CACT,EAGM,gBAAkB,EAAa,IAA+B,CAClE,IAAM,EAAM,YAAY,EAAK,CAAM,EACnC,GAAI,EAAI,WAAa,KAAO,EAAI,SAAW,IAAM,EAAI,SAAW,EAAK,MAAM,YAAY,EACvF,OAAO,EAAI,MACb,EAEM,SAAY,GAAqD,OAAO,GAAU,YAAY,EAE9F,UAAa,GAAyB,CAC1C,GAAI,CACF,OAAO,KAAK,MAAM,CAAG,CACvB,MAAQ,CACN,MAAM,YAAY,CACpB,CACF,EAEM,cAAgB,EAA8B,IAAyB,CAC3E,IAAM,EAAQ,EAAI,GAClB,GAAI,OAAO,GAAU,UAAY,CAAC,EAAU,KAAK,CAAK,EAAG,MAAM,YAAY,EAC3E,OAAO,CACT,EAGM,iBAAoB,GAAuC,CAG/D,GAFI,EAAI,MAAQ,OACZ,EAAI,MAAQ,IAAA,IAAa,EAAI,MAAQ,SACrC,EAAI,MAAQ,IAAA,IAAa,EAAI,MAAQ,MAAO,MAAM,YAAY,CACpE,EAEM,cAAiB,GAAiD,CACtE,IAAM,EAAI,aAAa,EAAK,GAAG,EAC/B,GAAI,EAAE,OAAS,EAAmB,MAAM,YAAY,EACpD,MAAO,CAAE,EAAG,aAAa,EAAK,GAAG,EAAG,IAAK,MAAO,GAAE,CACpD,EAEM,aAAe,KAAO,KAAoD,CAC9E,IAAK,QACL,EAAG,EAAI,EACP,IAAK,MAAA,EAAM,EAAA,uBAAA,CAAuB,CAAE,EAAG,EAAI,EAAG,IAAK,MAAO,EAAG,EAAI,CAAE,CAAC,EACpE,IAAK,MACL,EAAG,EAAI,EACP,IAAK,KACP,GAEA,eAAe,UAAU,EAAoE,CAC3F,IAAM,EAAS,UAAU,CAAG,EAC5B,GAAI,CAAC,SAAS,CAAM,EAAG,MAAM,YAAY,EACzC,iBAAiB,CAAM,EACvB,IAAM,EAAM,cAAc,CAAM,EAC1B,EAAuB,CAC3B,GAAG,EACH,EAAG,aAAa,EAAQ,GAAG,EAC3B,GAAI,aAAa,EAAQ,IAAI,EAC7B,GAAI,aAAa,EAAQ,IAAI,EAC7B,EAAG,aAAa,EAAQ,GAAG,EAC3B,EAAG,aAAa,EAAQ,GAAG,EAC3B,GAAI,aAAa,EAAQ,IAAI,CAC/B,EACI,EACJ,GAAI,CACF,IAAM,EAAW,MAAA,EAAM,EAAA,UAAA,CAAU,EAAK,QAAS,CAAE,YAAa,EAAM,CAAC,EACrE,GAAI,aAAoB,WAAY,MAAM,YAAY,EACtD,EAAM,CACR,MAAQ,CACN,MAAM,YAAY,CACpB,CACA,MAAO,CAAE,MAAK,OAAQ,MAAM,aAAa,CAAG,CAAE,CAChD,CAGA,eAAe,aAAa,EAAsD,CAChF,GAAI,IAAQ,IAAA,IAAa,EAAI,KAAK,CAAC,CAAC,SAAW,EAAG,MAAO,CAAC,EAC1D,IAAM,EAAS,UAAU,CAAG,EAC5B,GAAI,CAAC,SAAS,CAAM,GAAK,CAAC,MAAM,QAAQ,EAAO,IAAI,EAAG,MAAM,YAAY,EACxE,IAAM,EAA2B,CAAC,EAClC,IAAK,IAAM,KAAS,EAAO,KAAM,CAG/B,GAFI,CAAC,SAAS,CAAK,IACnB,iBAAiB,CAAK,EAClB,EAAoB,KAAM,GAAW,KAAU,CAAK,GAAG,MAAM,YAAY,EAC7E,EAAK,KAAK,MAAM,aAAa,cAAc,CAAK,CAAC,CAAC,CACpD,CACA,OAAO,CACT,CAEA,IAAM,EAAU,IAAI,QAEpB,eAAe,YAAY,EAAyC,CAClE,IAAM,EAAY,cAAc,EAAI,OAAO,EAC3C,GAAI,IAAc,SAAW,IAAc,aAAc,MAAM,YAAY,EAC3E,IAAM,EAAqB,EAErB,EAAiB,IAAI,IACzB,cAAc,EAAI,eAAe,CAAC,CAC/B,MAAM,GAAG,CAAC,CACV,IAAK,GAAW,EAAO,KAAK,CAAC,CAAC,CAC9B,OAAQ,GAAW,EAAO,OAAS,CAAC,CAAC,CACrC,IAAK,GAAW,eAAe,EAAQ,CAAM,CAAC,CACnD,EACA,GAAI,EAAe,OAAS,EAAG,MAAM,YAAY,EAEjD,IAAM,EAAe,eAAe,cAAc,EAAI,aAAa,EAAG,CAAM,EAEtE,EAAS,cAAc,EAAI,WAAW,EAC5C,YAAY,EAAQ,CAAM,EAC1B,IAAM,EAAW,cAAc,EAAI,QAAQ,EACrC,EAAU,cAAc,EAAI,QAAQ,EAC1C,GAAI,EAAQ,OAAS,GAAgB,kBAAkB,KAAK,CAAO,EAAG,MAAM,YAAY,EACxF,IAAM,EAAgB,cAAc,EAAI,cAAc,EAEtD,GADI,CAAC,kBAAkB,CAAa,GAChC,OAAO,EAAI,iBAAoB,SAAU,MAAM,YAAY,EAC/D,IAAM,EAAiB,IAAI,IACzB,EAAI,gBAAgB,MAAM,GAAG,CAAC,CAC3B,IAAK,GAAU,EAAM,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAC1C,OAAQ,GAAU,EAAM,OAAS,CAAC,CACvC,EACA,IAAK,IAAM,KAAS,EAAgB,GAAI,CAAC,kBAAkB,CAAK,EAAG,MAAM,YAAY,EAErF,eAAe,EAAI,QAAS,SAAS,EACrC,eAAe,EAAI,QAAS,OAAO,EACnC,eAAe,EAAI,WAAY,MAAM,EACrC,eAAe,EAAI,aAAc,OAAO,EAExC,IAAM,EAAS,MAAM,UAAU,cAAc,EAAI,oBAAoB,CAAC,EACtE,GAAI,CACF,IAAM,EAAQ,MAAM,IAAI,EAAA,QAAQ,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAE,IAAK,QAAS,IAAK,EAAO,OAAO,GAAI,CAAC,CAAC,CAAC,KAAK,EAAO,GAAG,EAChH,MAAA,EAAM,EAAA,UAAA,CAAU,GAAA,EAAO,EAAA,kBAAA,CAAkB,CAAE,KAAM,CAAC,EAAO,MAAM,CAAE,CAAC,EAAG,CAAE,WAAY,CAAC,OAAO,CAAE,CAAC,CAChG,MAAQ,CACN,MAAM,YAAY,CACpB,CACA,IAAM,GAAW,MAAM,aAAa,EAAI,6BAA6B,EAAA,CAAG,OACrE,GAAQ,EAAI,MAAQ,EAAO,OAAO,GACrC,EACM,EAAO,CACX,EAAO,OACP,GAAG,EAAQ,QAAQ,EAAK,IAAM,EAAQ,UAAW,GAAU,EAAM,MAAQ,EAAI,GAAG,IAAM,CAAC,CACzF,EAEA,MAAO,CACL,iBACA,SACA,UACA,WACA,SACA,OACA,iBACA,eACA,gBACA,QAAS,CAAE,IAAK,EAAO,IAAK,IAAK,EAAO,OAAO,GAAI,EACnD,UAAA,EAAU,EAAA,kBAAA,CAAkB,CAAE,KAAM,CAAC,GAAG,CAAI,CAAE,CAAC,CACjD,CACF,CAMA,SAAgB,WAAW,EAAyC,CAClE,IAAM,EAAS,EAAQ,IAAI,CAAG,EAC9B,GAAI,IAAW,IAAA,GAAW,OAAO,EACjC,IAAM,EAAU,YAAY,CAAG,EAG/B,OAFA,EAAQ,IAAI,EAAK,CAAO,EACxB,EAAQ,UAAY,EAAQ,OAAO,CAAG,CAAC,EAChC,CACT"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type CryptoKey, createLocalJWKSet, type JWK_RSA_Public } from 'jose';
|
|
2
|
+
import type { D1Like } from '../worker/env';
|
|
3
|
+
/** The send-email binding subset the issuer calls; the runtime's SendEmail satisfies it. */
|
|
4
|
+
export interface SendEmail {
|
|
5
|
+
send(message: {
|
|
6
|
+
from: string;
|
|
7
|
+
html?: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
text: string;
|
|
10
|
+
to: string;
|
|
11
|
+
}): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The catalog Worker as the issuer reaches it: a Cloudflare service binding (or any
|
|
15
|
+
* `fetch`-shaped stub). Account erasure calls the catalog's `DELETE /api/v1/me/account`
|
|
16
|
+
* through it before removing the issuer's own row.
|
|
17
|
+
*/
|
|
18
|
+
export interface AuthArchiveBinding {
|
|
19
|
+
fetch(request: Request): Promise<Response>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The bindings the auth issuer reads. Declared structurally, like the catalog
|
|
23
|
+
* Worker's Env: a deployment's generated `ExportedHandler<Env>` accepts the factory's
|
|
24
|
+
* result as-is, with no `@cloudflare/workers-types` dependency. Every value is
|
|
25
|
+
* validated on first use per bindings object; a missing or malformed one answers
|
|
26
|
+
* 503 `unavailable` on every route, never a permissive fallback.
|
|
27
|
+
*/
|
|
28
|
+
export interface AuthWorkerEnv {
|
|
29
|
+
/** Comma-separated exact origins allowed to call unsafe routes and read CORS responses. */
|
|
30
|
+
ALLOWED_ORIGINS: string;
|
|
31
|
+
/** Exactly `local` (loopback HTTP only) or `production` (non-loopback HTTPS, Secure cookies). */
|
|
32
|
+
APP_ENV: string;
|
|
33
|
+
/** The app's display name: the magic-link email's subject and copy name it. */
|
|
34
|
+
APP_NAME: string;
|
|
35
|
+
AUDIENCE: string;
|
|
36
|
+
/** Service binding to the sibling catalog Worker: account erasure removes the catalog profile through it. */
|
|
37
|
+
AUTH_ARCHIVE: AuthArchiveBinding;
|
|
38
|
+
/** The issuer's own D1: accounts, links, per-account send and singleton admission budgets. */
|
|
39
|
+
AUTH_DB: D1Like;
|
|
40
|
+
AUTH_EMAIL: SendEmail;
|
|
41
|
+
/** The `iss` claim every token carries, exactly as the catalog Worker's `JWT_ISSUER` knows it. */
|
|
42
|
+
AUTH_ISSUER: string;
|
|
43
|
+
/**
|
|
44
|
+
* Optional JWKS JSON (`{"keys":[...]}`) of retired PUBLIC RS256 keys, still served
|
|
45
|
+
* from `/jwks.json` and accepted by the issuer so sessions minted before a rotation
|
|
46
|
+
* keep verifying until they expire. Private members are rejected.
|
|
47
|
+
*/
|
|
48
|
+
AUTH_JWT_PREVIOUS_PUBLIC_JWKS?: string;
|
|
49
|
+
/** The active RS256 private key as a JWK JSON string (a wrangler secret): signs every new session. */
|
|
50
|
+
AUTH_JWT_PRIVATE_JWK: string;
|
|
51
|
+
/** Comma-separated emails whose sessions carry the operator role claim. */
|
|
52
|
+
OPERATOR_EMAILS: string;
|
|
53
|
+
/** The app's public origin: magic links, the signed-in redirect and the catalog erasure URL point here. */
|
|
54
|
+
PUBLIC_ORIGIN: string;
|
|
55
|
+
/** The address emails come from: a domain onboarded to Cloudflare Email Service. */
|
|
56
|
+
SENDER_ADDRESS: string;
|
|
57
|
+
}
|
|
58
|
+
export type AuthAppEnv = 'local' | 'production';
|
|
59
|
+
export interface PublicSigningKey extends JWK_RSA_Public {
|
|
60
|
+
alg: 'RS256';
|
|
61
|
+
kid: string;
|
|
62
|
+
kty: 'RSA';
|
|
63
|
+
use: 'sig';
|
|
64
|
+
}
|
|
65
|
+
export interface AuthConfig {
|
|
66
|
+
allowedOrigins: ReadonlySet<string>;
|
|
67
|
+
appEnv: AuthAppEnv;
|
|
68
|
+
appName: string;
|
|
69
|
+
audience: string;
|
|
70
|
+
issuer: string;
|
|
71
|
+
/** Every key a session may be verified with: the active key first, then retired ones. */
|
|
72
|
+
jwks: readonly PublicSigningKey[];
|
|
73
|
+
operatorEmails: ReadonlySet<string>;
|
|
74
|
+
publicOrigin: string;
|
|
75
|
+
senderAddress: string;
|
|
76
|
+
signing: {
|
|
77
|
+
key: CryptoKey;
|
|
78
|
+
kid: string;
|
|
79
|
+
};
|
|
80
|
+
verifier: ReturnType<typeof createLocalJWKSet>;
|
|
81
|
+
}
|
|
82
|
+
export declare const isWellFormedEmail: (value: string) => boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Validates the env and imports its keys once per bindings object. A failed
|
|
85
|
+
* validation is not cached, so a corrected binding set recovers without a restart.
|
|
86
|
+
*/
|
|
87
|
+
export declare function authConfig(env: AuthWorkerEnv): Promise<AuthConfig>;
|
|
88
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/auth/config.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,SAAS,EAEd,iBAAiB,EAGjB,KAAK,cAAc,EAGpB,MAAM,MAAM,CAAC;AACd,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAI5C,4FAA4F;AAC5F,MAAM,WAAW,SAAS;IACxB,IAAI,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC7G;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC5C;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,2FAA2F;IAC3F,eAAe,EAAE,MAAM,CAAC;IACxB,iGAAiG;IACjG,OAAO,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,6GAA6G;IAC7G,YAAY,EAAE,kBAAkB,CAAC;IACjC,8FAA8F;IAC9F,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,SAAS,CAAC;IACtB,kGAAkG;IAClG,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,6BAA6B,CAAC,EAAE,MAAM,CAAC;IACvC,sGAAsG;IACtG,oBAAoB,EAAE,MAAM,CAAC;IAC7B,2EAA2E;IAC3E,eAAe,EAAE,MAAM,CAAC;IACxB,2GAA2G;IAC3G,aAAa,EAAE,MAAM,CAAC;IACtB,oFAAoF;IACpF,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,YAAY,CAAC;AAEhD,MAAM,WAAW,gBAAiB,SAAQ,cAAc;IACtD,GAAG,EAAE,OAAO,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,KAAK,CAAC;IACX,GAAG,EAAE,KAAK,CAAC;CACZ;AAED,MAAM,WAAW,UAAU;IACzB,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACpC,MAAM,EAAE,UAAU,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,yFAAyF;IACzF,IAAI,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAClC,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE;QAAE,GAAG,EAAE,SAAS,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,QAAQ,EAAE,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;CAChD;AAcD,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,KAAG,OAGU,CAAC;AAqL7D;;;GAGG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAAC,UAAU,CAAC,CAOlE"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{AuthOperationError as e}from"./errors.js";import{SignJWT as t,calculateJwkThumbprint as n,createLocalJWKSet as r,importJWK as i,jwtVerify as a}from"jose";var configError=()=>new e(503,`configuration`),o=320,s=64,c=342,l=/^[A-Za-z0-9_-]+$/,u=new Set([`127.0.0.1`,`localhost`,`[::1]`]),d=[`d`,`p`,`q`,`dp`,`dq`,`qi`,`oth`,`k`],isWellFormedEmail=e=>e.length<=o&&e===e.toLowerCase()&&/^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/.test(e),requireString=e=>{if(typeof e!=`string`||e.length===0)throw configError();return e},requireBinding=(e,t)=>{if(typeof e!=`object`||!e||typeof Reflect.get(e,t)!=`function`)throw configError()},validateUrl=(e,t)=>{let n;try{n=new URL(e)}catch{throw configError()}if(n.username!==``||n.password!==``||n.hash!==``)throw configError();let r=u.has(n.hostname);if(t===`local`?n.protocol!==`http:`||!r:n.protocol!==`https:`||r)throw configError();return n},validateOrigin=(e,t)=>{let n=validateUrl(e,t);if(n.pathname!==`/`||n.search!==``||n.origin!==e)throw configError();return n.origin},isRecord=e=>typeof e==`object`&&!!e,parseJson=e=>{try{return JSON.parse(e)}catch{throw configError()}},b64urlMember=(e,t)=>{let n=e[t];if(typeof n!=`string`||!l.test(n))throw configError();return n},checkRsaMetadata=e=>{if(e.kty!==`RSA`||e.alg!==void 0&&e.alg!==`RS256`||e.use!==void 0&&e.use!==`sig`)throw configError()},publicMembers=e=>{let t=b64urlMember(e,`n`);if(t.length<c)throw configError();return{e:b64urlMember(e,`e`),kty:`RSA`,n:t}},toSigningKey=async e=>({alg:`RS256`,e:e.e,kid:await n({e:e.e,kty:`RSA`,n:e.n}),kty:`RSA`,n:e.n,use:`sig`});async function activeKey(e){let t=parseJson(e);if(!isRecord(t))throw configError();checkRsaMetadata(t);let n=publicMembers(t),r={...n,d:b64urlMember(t,`d`),dp:b64urlMember(t,`dp`),dq:b64urlMember(t,`dq`),p:b64urlMember(t,`p`),q:b64urlMember(t,`q`),qi:b64urlMember(t,`qi`)},a;try{let e=await i(r,`RS256`,{extractable:!1});if(e instanceof Uint8Array)throw configError();a=e}catch{throw configError()}return{key:a,public:await toSigningKey(n)}}async function previousKeys(e){if(e===void 0||e.trim().length===0)return[];let t=parseJson(e);if(!isRecord(t)||!Array.isArray(t.keys))throw configError();let n=[];for(let e of t.keys){if(!isRecord(e)||(checkRsaMetadata(e),d.some(t=>t in e)))throw configError();n.push(await toSigningKey(publicMembers(e)))}return n}var f=new WeakMap;async function buildConfig(e){let n=requireString(e.APP_ENV);if(n!==`local`&&n!==`production`)throw configError();let i=n,o=new Set(requireString(e.ALLOWED_ORIGINS).split(`,`).map(e=>e.trim()).filter(e=>e.length>0).map(e=>validateOrigin(e,i)));if(o.size===0)throw configError();let c=validateOrigin(requireString(e.PUBLIC_ORIGIN),i),l=requireString(e.AUTH_ISSUER);validateUrl(l,i);let u=requireString(e.AUDIENCE),d=requireString(e.APP_NAME);if(d.length>s||/[\p{Cc}\p{Cs}]/u.test(d))throw configError();let f=requireString(e.SENDER_ADDRESS);if(!isWellFormedEmail(f)||typeof e.OPERATOR_EMAILS!=`string`)throw configError();let p=new Set(e.OPERATOR_EMAILS.split(`,`).map(e=>e.trim().toLowerCase()).filter(e=>e.length>0));for(let e of p)if(!isWellFormedEmail(e))throw configError();requireBinding(e.AUTH_DB,`prepare`),requireBinding(e.AUTH_DB,`batch`),requireBinding(e.AUTH_EMAIL,`send`),requireBinding(e.AUTH_ARCHIVE,`fetch`);let m=await activeKey(requireString(e.AUTH_JWT_PRIVATE_JWK));try{let e=await new t({}).setProtectedHeader({alg:`RS256`,kid:m.public.kid}).sign(m.key);await a(e,r({keys:[m.public]}),{algorithms:[`RS256`]})}catch{throw configError()}let h=(await previousKeys(e.AUTH_JWT_PREVIOUS_PUBLIC_JWKS)).filter(e=>e.kid!==m.public.kid),g=[m.public,...h.filter((e,t)=>h.findIndex(t=>t.kid===e.kid)===t)];return{allowedOrigins:o,appEnv:i,appName:d,audience:u,issuer:l,jwks:g,operatorEmails:p,publicOrigin:c,senderAddress:f,signing:{key:m.key,kid:m.public.kid},verifier:r({keys:[...g]})}}function authConfig(e){let t=f.get(e);if(t!==void 0)return t;let n=buildConfig(e);return f.set(e,n),n.catch(()=>f.delete(e)),n}export{authConfig,isWellFormedEmail};
|
|
2
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","names":[],"sources":["../../src/auth/config.ts"],"sourcesContent":["import {\n type CryptoKey,\n calculateJwkThumbprint,\n createLocalJWKSet,\n importJWK,\n type JWK_RSA_Private,\n type JWK_RSA_Public,\n jwtVerify,\n SignJWT,\n} from 'jose';\nimport type { D1Like } from '../worker/env';\nimport type { ApiError } from '../worker/http';\nimport { AuthOperationError } from './errors';\n\n/** The send-email binding subset the issuer calls; the runtime's SendEmail satisfies it. */\nexport interface SendEmail {\n send(message: { from: string; html?: string; subject: string; text: string; to: string }): Promise<unknown>;\n}\n\n/**\n * The catalog Worker as the issuer reaches it: a Cloudflare service binding (or any\n * `fetch`-shaped stub). Account erasure calls the catalog's `DELETE /api/v1/me/account`\n * through it before removing the issuer's own row.\n */\nexport interface AuthArchiveBinding {\n fetch(request: Request): Promise<Response>;\n}\n\n/**\n * The bindings the auth issuer reads. Declared structurally, like the catalog\n * Worker's Env: a deployment's generated `ExportedHandler<Env>` accepts the factory's\n * result as-is, with no `@cloudflare/workers-types` dependency. Every value is\n * validated on first use per bindings object; a missing or malformed one answers\n * 503 `unavailable` on every route, never a permissive fallback.\n */\nexport interface AuthWorkerEnv {\n /** Comma-separated exact origins allowed to call unsafe routes and read CORS responses. */\n ALLOWED_ORIGINS: string;\n /** Exactly `local` (loopback HTTP only) or `production` (non-loopback HTTPS, Secure cookies). */\n APP_ENV: string;\n /** The app's display name: the magic-link email's subject and copy name it. */\n APP_NAME: string;\n AUDIENCE: string;\n /** Service binding to the sibling catalog Worker: account erasure removes the catalog profile through it. */\n AUTH_ARCHIVE: AuthArchiveBinding;\n /** The issuer's own D1: accounts, links, per-account send and singleton admission budgets. */\n AUTH_DB: D1Like;\n AUTH_EMAIL: SendEmail;\n /** The `iss` claim every token carries, exactly as the catalog Worker's `JWT_ISSUER` knows it. */\n AUTH_ISSUER: string;\n /**\n * Optional JWKS JSON (`{\"keys\":[...]}`) of retired PUBLIC RS256 keys, still served\n * from `/jwks.json` and accepted by the issuer so sessions minted before a rotation\n * keep verifying until they expire. Private members are rejected.\n */\n AUTH_JWT_PREVIOUS_PUBLIC_JWKS?: string;\n /** The active RS256 private key as a JWK JSON string (a wrangler secret): signs every new session. */\n AUTH_JWT_PRIVATE_JWK: string;\n /** Comma-separated emails whose sessions carry the operator role claim. */\n OPERATOR_EMAILS: string;\n /** The app's public origin: magic links, the signed-in redirect and the catalog erasure URL point here. */\n PUBLIC_ORIGIN: string;\n /** The address emails come from: a domain onboarded to Cloudflare Email Service. */\n SENDER_ADDRESS: string;\n}\n\nexport type AuthAppEnv = 'local' | 'production';\n\nexport interface PublicSigningKey extends JWK_RSA_Public {\n alg: 'RS256';\n kid: string;\n kty: 'RSA';\n use: 'sig';\n}\n\nexport interface AuthConfig {\n allowedOrigins: ReadonlySet<string>;\n appEnv: AuthAppEnv;\n appName: string;\n audience: string;\n issuer: string;\n /** Every key a session may be verified with: the active key first, then retired ones. */\n jwks: readonly PublicSigningKey[];\n operatorEmails: ReadonlySet<string>;\n publicOrigin: string;\n senderAddress: string;\n signing: { key: CryptoKey; kid: string };\n verifier: ReturnType<typeof createLocalJWKSet>;\n}\n\n// Configuration faults never name the offending field: the response must not leak\n// Env names, only a generic temporary unavailability.\nconst configError = (): ApiError => new AuthOperationError(503, 'configuration');\n\nconst EMAIL_MAX = 320;\nconst APP_NAME_MAX = 64;\n// 2048-bit RSA modulus: 256 bytes, 342 base64url characters unpadded.\nconst MIN_MODULUS_CHARS = 342;\nconst BASE64URL = /^[A-Za-z0-9_-]+$/;\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);\nconst PRIVATE_RSA_MEMBERS = ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'] as const;\n\nexport const isWellFormedEmail = (value: string): boolean =>\n value.length <= EMAIL_MAX &&\n value === value.toLowerCase() &&\n /^[^\\s@<>\"',;]+@[^\\s@<>\"',;]+\\.[^\\s@<>\"',;]+$/.test(value);\n\nconst requireString = (value: unknown): string => {\n if (typeof value !== 'string' || value.length === 0) throw configError();\n return value;\n};\n\nconst requireBinding = (value: unknown, method: string): void => {\n if (typeof value !== 'object' || value === null) throw configError();\n if (typeof Reflect.get(value, method) !== 'function') throw configError();\n};\n\n/** Transport rules: local is loopback HTTP only, production is non-loopback HTTPS; no credentials or fragments. */\nconst validateUrl = (raw: string, appEnv: AuthAppEnv): URL => {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n throw configError();\n }\n if (url.username !== '' || url.password !== '' || url.hash !== '') throw configError();\n const loopback = LOOPBACK_HOSTS.has(url.hostname);\n if (appEnv === 'local' ? url.protocol !== 'http:' || !loopback : url.protocol !== 'https:' || loopback) {\n throw configError();\n }\n return url;\n};\n\n/** An exact canonical origin: scheme + host (+port), no path, query, wildcard or `null`. */\nconst validateOrigin = (raw: string, appEnv: AuthAppEnv): string => {\n const url = validateUrl(raw, appEnv);\n if (url.pathname !== '/' || url.search !== '' || url.origin !== raw) throw configError();\n return url.origin;\n};\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;\n\nconst parseJson = (raw: string): unknown => {\n try {\n return JSON.parse(raw);\n } catch {\n throw configError();\n }\n};\n\nconst b64urlMember = (jwk: Record<string, unknown>, name: string): string => {\n const value = jwk[name];\n if (typeof value !== 'string' || !BASE64URL.test(value)) throw configError();\n return value;\n};\n\n/** RS256 signing-key metadata, when present, must agree with how the issuer uses the key. */\nconst checkRsaMetadata = (jwk: Record<string, unknown>): void => {\n if (jwk.kty !== 'RSA') throw configError();\n if (jwk.alg !== undefined && jwk.alg !== 'RS256') throw configError();\n if (jwk.use !== undefined && jwk.use !== 'sig') throw configError();\n};\n\nconst publicMembers = (jwk: Record<string, unknown>): JWK_RSA_Public => {\n const n = b64urlMember(jwk, 'n');\n if (n.length < MIN_MODULUS_CHARS) throw configError();\n return { e: b64urlMember(jwk, 'e'), kty: 'RSA', n };\n};\n\nconst toSigningKey = async (pub: JWK_RSA_Public): Promise<PublicSigningKey> => ({\n alg: 'RS256',\n e: pub.e,\n kid: await calculateJwkThumbprint({ e: pub.e, kty: 'RSA', n: pub.n }),\n kty: 'RSA',\n n: pub.n,\n use: 'sig',\n});\n\nasync function activeKey(raw: string): Promise<{ key: CryptoKey; public: PublicSigningKey }> {\n const parsed = parseJson(raw);\n if (!isRecord(parsed)) throw configError();\n checkRsaMetadata(parsed);\n const pub = publicMembers(parsed);\n const jwk: JWK_RSA_Private = {\n ...pub,\n d: b64urlMember(parsed, 'd'),\n dp: b64urlMember(parsed, 'dp'),\n dq: b64urlMember(parsed, 'dq'),\n p: b64urlMember(parsed, 'p'),\n q: b64urlMember(parsed, 'q'),\n qi: b64urlMember(parsed, 'qi'),\n };\n let key: CryptoKey;\n try {\n const imported = await importJWK(jwk, 'RS256', { extractable: false });\n if (imported instanceof Uint8Array) throw configError();\n key = imported;\n } catch {\n throw configError();\n }\n return { key, public: await toSigningKey(pub) };\n}\n\n/** Retired verification keys: public RSA members only; any private member is a misconfiguration. */\nasync function previousKeys(raw: string | undefined): Promise<PublicSigningKey[]> {\n if (raw === undefined || raw.trim().length === 0) return [];\n const parsed = parseJson(raw);\n if (!isRecord(parsed) || !Array.isArray(parsed.keys)) throw configError();\n const keys: PublicSigningKey[] = [];\n for (const entry of parsed.keys) {\n if (!isRecord(entry)) throw configError();\n checkRsaMetadata(entry);\n if (PRIVATE_RSA_MEMBERS.some((member) => member in entry)) throw configError();\n keys.push(await toSigningKey(publicMembers(entry)));\n }\n return keys;\n}\n\nconst configs = new WeakMap<AuthWorkerEnv, Promise<AuthConfig>>();\n\nasync function buildConfig(env: AuthWorkerEnv): Promise<AuthConfig> {\n const appEnvRaw = requireString(env.APP_ENV);\n if (appEnvRaw !== 'local' && appEnvRaw !== 'production') throw configError();\n const appEnv: AuthAppEnv = appEnvRaw;\n\n const allowedOrigins = new Set(\n requireString(env.ALLOWED_ORIGINS)\n .split(',')\n .map((origin) => origin.trim())\n .filter((origin) => origin.length > 0)\n .map((origin) => validateOrigin(origin, appEnv)),\n );\n if (allowedOrigins.size === 0) throw configError();\n\n const publicOrigin = validateOrigin(requireString(env.PUBLIC_ORIGIN), appEnv);\n // The issuer is matched as the exact configured string (a trailing slash is meaningful).\n const issuer = requireString(env.AUTH_ISSUER);\n validateUrl(issuer, appEnv);\n const audience = requireString(env.AUDIENCE);\n const appName = requireString(env.APP_NAME);\n if (appName.length > APP_NAME_MAX || /[\\p{Cc}\\p{Cs}]/u.test(appName)) throw configError();\n const senderAddress = requireString(env.SENDER_ADDRESS);\n if (!isWellFormedEmail(senderAddress)) throw configError();\n if (typeof env.OPERATOR_EMAILS !== 'string') throw configError();\n const operatorEmails = new Set(\n env.OPERATOR_EMAILS.split(',')\n .map((entry) => entry.trim().toLowerCase())\n .filter((entry) => entry.length > 0),\n );\n for (const email of operatorEmails) if (!isWellFormedEmail(email)) throw configError();\n\n requireBinding(env.AUTH_DB, 'prepare');\n requireBinding(env.AUTH_DB, 'batch');\n requireBinding(env.AUTH_EMAIL, 'send');\n requireBinding(env.AUTH_ARCHIVE, 'fetch');\n\n const active = await activeKey(requireString(env.AUTH_JWT_PRIVATE_JWK));\n try {\n const proof = await new SignJWT({}).setProtectedHeader({ alg: 'RS256', kid: active.public.kid }).sign(active.key);\n await jwtVerify(proof, createLocalJWKSet({ keys: [active.public] }), { algorithms: ['RS256'] });\n } catch {\n throw configError();\n }\n const retired = (await previousKeys(env.AUTH_JWT_PREVIOUS_PUBLIC_JWKS)).filter(\n (key) => key.kid !== active.public.kid,\n );\n const jwks = [\n active.public,\n ...retired.filter((key, i) => retired.findIndex((other) => other.kid === key.kid) === i),\n ];\n\n return {\n allowedOrigins,\n appEnv,\n appName,\n audience,\n issuer,\n jwks,\n operatorEmails,\n publicOrigin,\n senderAddress,\n signing: { key: active.key, kid: active.public.kid },\n verifier: createLocalJWKSet({ keys: [...jwks] }),\n };\n}\n\n/**\n * Validates the env and imports its keys once per bindings object. A failed\n * validation is not cached, so a corrected binding set recovers without a restart.\n */\nexport function authConfig(env: AuthWorkerEnv): Promise<AuthConfig> {\n const cached = configs.get(env);\n if (cached !== undefined) return cached;\n const pending = buildConfig(env);\n configs.set(env, pending);\n pending.catch(() => configs.delete(env));\n return pending;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("../worker/http.cjs");var AuthOperationError=class extends e.ApiError{authDiagnostic;constructor(e,t,n){super(e,e===500?`internal`:`unavailable`,e===500?`Something went wrong.`:`The service is temporarily unavailable.`,n),this.authDiagnostic={kind:t}}};async function databaseOperation(e){try{return await e()}catch(e){throw new AuthOperationError(500,`database`,{cause:e})}}exports.AuthOperationError=AuthOperationError,exports.databaseOperation=databaseOperation;
|
|
2
|
+
//# sourceMappingURL=errors.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.cjs","names":[],"sources":["../../src/auth/errors.ts"],"sourcesContent":["import { ApiError } from '../worker/http';\n\nexport type AuthDiagnostic = {\n readonly kind: 'configuration' | 'database' | 'schema' | 'email' | 'catalog' | 'internal';\n};\n\nexport class AuthOperationError extends ApiError {\n readonly authDiagnostic: AuthDiagnostic;\n\n constructor(status: number, kind: AuthDiagnostic['kind'], opts?: ErrorOptions) {\n super(\n status,\n status === 500 ? 'internal' : 'unavailable',\n status === 500 ? 'Something went wrong.' : 'The service is temporarily unavailable.',\n opts,\n );\n this.authDiagnostic = { kind };\n }\n}\n\nexport async function databaseOperation<T>(operation: () => Promise<T>): Promise<T> {\n try {\n return await operation();\n } catch (cause) {\n throw new AuthOperationError(500, 'database', { cause });\n }\n}\n"],"mappings":"sCAMA,IAAa,mBAAb,cAAwC,EAAA,QAAS,CAC/C,eAEA,YAAY,EAAgB,EAA8B,EAAqB,CAC7E,MACE,EACA,IAAW,IAAM,WAAa,cAC9B,IAAW,IAAM,wBAA0B,0CAC3C,CACF,EACA,KAAK,eAAiB,CAAE,MAAK,CAC/B,CACF,EAEA,eAAsB,kBAAqB,EAAyC,CAClF,GAAI,CACF,OAAO,MAAM,EAAU,CACzB,OAAS,EAAO,CACd,MAAM,IAAI,mBAAmB,IAAK,WAAY,CAAE,OAAM,CAAC,CACzD,CACF"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ApiError } from '../worker/http';
|
|
2
|
+
export type AuthDiagnostic = {
|
|
3
|
+
readonly kind: 'configuration' | 'database' | 'schema' | 'email' | 'catalog' | 'internal';
|
|
4
|
+
};
|
|
5
|
+
export declare class AuthOperationError extends ApiError {
|
|
6
|
+
readonly authDiagnostic: AuthDiagnostic;
|
|
7
|
+
constructor(status: number, kind: AuthDiagnostic['kind'], opts?: ErrorOptions);
|
|
8
|
+
}
|
|
9
|
+
export declare function databaseOperation<T>(operation: () => Promise<T>): Promise<T>;
|
|
10
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/auth/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,CAAC;CAC3F,CAAC;AAEF,qBAAa,kBAAmB,SAAQ,QAAQ;IAC9C,QAAQ,CAAC,cAAc,EAAE,cAAc,CAAC;gBAE5B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,EAAE,YAAY;CAS9E;AAED,wBAAsB,iBAAiB,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAMlF"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{ApiError as e}from"../worker/http.js";var AuthOperationError=class extends e{authDiagnostic;constructor(e,t,n){super(e,e===500?`internal`:`unavailable`,e===500?`Something went wrong.`:`The service is temporarily unavailable.`,n),this.authDiagnostic={kind:t}}};async function databaseOperation(e){try{return await e()}catch(e){throw new AuthOperationError(500,`database`,{cause:e})}}export{AuthOperationError,databaseOperation};
|
|
2
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../../src/auth/errors.ts"],"sourcesContent":["import { ApiError } from '../worker/http';\n\nexport type AuthDiagnostic = {\n readonly kind: 'configuration' | 'database' | 'schema' | 'email' | 'catalog' | 'internal';\n};\n\nexport class AuthOperationError extends ApiError {\n readonly authDiagnostic: AuthDiagnostic;\n\n constructor(status: number, kind: AuthDiagnostic['kind'], opts?: ErrorOptions) {\n super(\n status,\n status === 500 ? 'internal' : 'unavailable',\n status === 500 ? 'Something went wrong.' : 'The service is temporarily unavailable.',\n opts,\n );\n this.authDiagnostic = { kind };\n }\n}\n\nexport async function databaseOperation<T>(operation: () => Promise<T>): Promise<T> {\n try {\n return await operation();\n } catch (cause) {\n throw new AuthOperationError(500, 'database', { cause });\n }\n}\n"],"mappings":"6CAMA,IAAa,mBAAb,cAAwC,CAAS,CAC/C,eAEA,YAAY,EAAgB,EAA8B,EAAqB,CAC7E,MACE,EACA,IAAW,IAAM,WAAa,cAC9B,IAAW,IAAM,wBAA0B,0CAC3C,CACF,EACA,KAAK,eAAiB,CAAE,MAAK,CAC/B,CACF,EAEA,eAAsB,kBAAqB,EAAyC,CAClF,GAAI,CACF,OAAO,MAAM,EAAU,CACzB,OAAS,EAAO,CACd,MAAM,IAAI,mBAAmB,IAAK,WAAY,CAAE,OAAM,CAAC,CACzD,CACF"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const e=require("./errors.cjs");var t=9e5,n=36e5,r=6e4,i={account:`SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?`,budgetWindow:`SELECT window_start FROM auth_link_budget WHERE user_id = ?`,consumeToken:`UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3
|
|
2
|
+
RETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,
|
|
3
|
+
(SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,deleteAccount:`DELETE FROM auth_users WHERE id = ?`,deleteToken:`DELETE FROM auth_tokens WHERE token_hash = ?`,ensureUser:`INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email`,insertToken:`INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)`,revokeSessions:`UPDATE auth_users SET session_version = session_version + 1
|
|
4
|
+
WHERE id = ? AND session_version = ? RETURNING session_version`,signInWindow:`SELECT window_start FROM auth_sign_in_budget WHERE id = 1`,spendBudget:`INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)
|
|
5
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
6
|
+
window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,
|
|
7
|
+
sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END
|
|
8
|
+
WHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4
|
|
9
|
+
RETURNING sent`,spendSignInBudget:`INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)
|
|
10
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
11
|
+
window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,
|
|
12
|
+
admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END
|
|
13
|
+
WHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3
|
|
14
|
+
RETURNING admitted`,sweepBudget:`DELETE FROM auth_link_budget WHERE window_start <= ?`,sweepTokens:`DELETE FROM auth_tokens WHERE expires_at <= ?`,sweepUnverified:`DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)`};async function ensureUser(t,n){let r=await e.databaseOperation(()=>t.prepare(i.ensureUser).bind(crypto.randomUUID(),n,new Date().toISOString()).first());if(r===null)throw Error(`The account row could not be ensured.`);return r}async function spendSignInBudget(t,n){return e.databaseOperation(async()=>{if(await t.prepare(i.spendSignInBudget).bind(n,n-6e4,20).first()!==null)return null;let e=await t.prepare(i.signInWindow).first();return Math.max(1,Math.ceil(((e?.window_start??n)+r-n)/1e3))})}async function spendLinkBudget(t,r,a){return e.databaseOperation(async()=>{let e=a-n;if(await t.prepare(i.spendBudget).bind(r,a,e,3).first()!==null)return null;let o=((await t.prepare(i.budgetWindow).bind(r).first())?.window_start??a)+n;return Math.max(1,Math.ceil((o-a)/1e3))})}async function insertToken(n,r,a,o){await e.databaseOperation(()=>n.prepare(i.insertToken).bind(r,a,o+t).run())}async function deleteToken(t,n){await e.databaseOperation(()=>t.prepare(i.deleteToken).bind(n).run())}async function consumeToken(t,n,r){return e.databaseOperation(()=>t.prepare(i.consumeToken).bind(new Date(r).toISOString(),n,r).first())}function revokeSessions(t,n,r){return e.databaseOperation(()=>t.prepare(i.revokeSessions).bind(n,r).first())}async function authReadiness(t){return e.databaseOperation(async()=>{for(let[e,n]of Object.entries({auth_link_budget:[`user_id`,`window_start`,`sent`],auth_schema_meta:[`id`,`applied`],auth_sign_in_budget:[`id`,`window_start`,`admitted`],auth_tokens:[`token_hash`,`user_id`,`expires_at`,`redeemed_at`],auth_users:[`id`,`email`,`created_at`,`last_sign_in_at`,`session_version`]})){let r=await t.prepare(`PRAGMA table_info(${e})`).all();if(!n.every(e=>r.results?.some(t=>t.name===e)))return null}if((await t.prepare(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')`).all()).results?.length!==2)return null;let e=await t.prepare(`SELECT applied FROM auth_schema_meta WHERE id = 1`).first();return await t.prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,
|
|
15
|
+
t.token_hash, t.user_id, t.expires_at, t.redeemed_at,
|
|
16
|
+
b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted
|
|
17
|
+
FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0
|
|
18
|
+
LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`).first(),typeof e?.applied==`number`&&Number.isSafeInteger(e.applied)&&e.applied>=0?e.applied:null})}function readAccount(t,n){return e.databaseOperation(()=>t.prepare(i.account).bind(n).first())}async function deleteAccount(t,n){await e.databaseOperation(()=>t.prepare(i.deleteAccount).bind(n).run())}async function sweep(t,r){return(await e.databaseOperation(()=>t.batch([t.prepare(i.sweepTokens).bind(r),t.prepare(i.sweepBudget).bind(r-n),t.prepare(i.sweepUnverified)]))).reduce((e,t)=>e+(t.meta?.changes??0),0)}exports.LINK_TTL_MS=t,exports.LINK_WINDOW_MS=n,exports.SIGN_IN_WINDOW_MS=r,exports.authReadiness=authReadiness,exports.consumeToken=consumeToken,exports.deleteAccount=deleteAccount,exports.deleteToken=deleteToken,exports.ensureUser=ensureUser,exports.insertToken=insertToken,exports.readAccount=readAccount,exports.revokeSessions=revokeSessions,exports.spendLinkBudget=spendLinkBudget,exports.spendSignInBudget=spendSignInBudget,exports.sweep=sweep;
|
|
19
|
+
//# sourceMappingURL=store.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.cjs","names":[],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../worker/env';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { D1Like } from '../worker/env';
|
|
2
|
+
/**
|
|
3
|
+
* The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.
|
|
4
|
+
* Token rows are single-use credentials only; the send allowance lives in its own
|
|
5
|
+
* durable table, so consuming or expiring a link never refunds the budget.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LINK_TTL_MS: number;
|
|
8
|
+
export declare const LINK_WINDOW_MS: number;
|
|
9
|
+
export declare const LINKS_PER_WINDOW = 3;
|
|
10
|
+
export declare const SIGN_IN_WINDOW_MS = 60000;
|
|
11
|
+
export declare const SIGN_INS_PER_WINDOW = 20;
|
|
12
|
+
export interface AccountRow {
|
|
13
|
+
created_at: string;
|
|
14
|
+
email: string;
|
|
15
|
+
id: string;
|
|
16
|
+
last_sign_in_at: string | null;
|
|
17
|
+
session_version: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function ensureUser(db: D1Like, email: string): Promise<{
|
|
20
|
+
email: string;
|
|
21
|
+
id: string;
|
|
22
|
+
}>;
|
|
23
|
+
/** The deployment-wide admission budget is spent before account creation or delivery. */
|
|
24
|
+
export declare function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null>;
|
|
25
|
+
/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */
|
|
26
|
+
export declare function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null>;
|
|
27
|
+
export declare function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void>;
|
|
28
|
+
export declare function deleteToken(db: D1Like, tokenHash: string): Promise<void>;
|
|
29
|
+
/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */
|
|
30
|
+
export declare function consumeToken(db: D1Like, tokenHash: string, nowMs: number): Promise<{
|
|
31
|
+
email: string;
|
|
32
|
+
id: string;
|
|
33
|
+
sessionVersion: number;
|
|
34
|
+
} | null>;
|
|
35
|
+
/** The trigger deletes outstanding links within this same atomic version change. */
|
|
36
|
+
export declare function revokeSessions(db: D1Like, subject: string, version: number): Promise<{
|
|
37
|
+
session_version: number;
|
|
38
|
+
} | null>;
|
|
39
|
+
export declare function authReadiness(db: D1Like): Promise<number | null>;
|
|
40
|
+
export declare function readAccount(db: D1Like, userId: string): Promise<AccountRow | null>;
|
|
41
|
+
/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */
|
|
42
|
+
export declare function deleteAccount(db: D1Like, userId: string): Promise<void>;
|
|
43
|
+
/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */
|
|
44
|
+
export declare function sweep(db: D1Like, nowMs: number): Promise<number>;
|
|
45
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/auth/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAG5C;;;;GAIG;AACH,eAAO,MAAM,WAAW,QAAiB,CAAC;AAC1C,eAAO,MAAM,cAAc,QAAiB,CAAC;AAC7C,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,iBAAiB,QAAS,CAAC;AACxC,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,eAAe,EAAE,MAAM,CAAC;CACzB;AAsCD,wBAAsB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CASlG;AAED,yFAAyF;AACzF,wBAAsB,iBAAiB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAUzF;AAED,kGAAkG;AAClG,wBAAsB,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAYvG;AAED,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO7G;AAED,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9E;AAED,8GAA8G;AAC9G,wBAAsB,YAAY,CAChC,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAOvE;AAED,oFAAoF;AACpF,wBAAgB,cAAc,CAC5B,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;IAAE,eAAe,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAI7C;AAED,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA+BtE;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAElF;AAED,oFAAoF;AACpF,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7E;AAED,+GAA+G;AAC/G,wBAAsB,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAStE"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import{databaseOperation as e}from"./errors.js";var t=9e5,n=36e5,r=6e4,i={account:`SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?`,budgetWindow:`SELECT window_start FROM auth_link_budget WHERE user_id = ?`,consumeToken:`UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3
|
|
2
|
+
RETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,
|
|
3
|
+
(SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,deleteAccount:`DELETE FROM auth_users WHERE id = ?`,deleteToken:`DELETE FROM auth_tokens WHERE token_hash = ?`,ensureUser:`INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email`,insertToken:`INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)`,revokeSessions:`UPDATE auth_users SET session_version = session_version + 1
|
|
4
|
+
WHERE id = ? AND session_version = ? RETURNING session_version`,signInWindow:`SELECT window_start FROM auth_sign_in_budget WHERE id = 1`,spendBudget:`INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)
|
|
5
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
6
|
+
window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,
|
|
7
|
+
sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END
|
|
8
|
+
WHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4
|
|
9
|
+
RETURNING sent`,spendSignInBudget:`INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)
|
|
10
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
11
|
+
window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,
|
|
12
|
+
admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END
|
|
13
|
+
WHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3
|
|
14
|
+
RETURNING admitted`,sweepBudget:`DELETE FROM auth_link_budget WHERE window_start <= ?`,sweepTokens:`DELETE FROM auth_tokens WHERE expires_at <= ?`,sweepUnverified:`DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)`};async function ensureUser(t,n){let r=await e(()=>t.prepare(i.ensureUser).bind(crypto.randomUUID(),n,new Date().toISOString()).first());if(r===null)throw Error(`The account row could not be ensured.`);return r}async function spendSignInBudget(t,n){return e(async()=>{if(await t.prepare(i.spendSignInBudget).bind(n,n-6e4,20).first()!==null)return null;let e=await t.prepare(i.signInWindow).first();return Math.max(1,Math.ceil(((e?.window_start??n)+r-n)/1e3))})}async function spendLinkBudget(t,r,a){return e(async()=>{let e=a-n;if(await t.prepare(i.spendBudget).bind(r,a,e,3).first()!==null)return null;let o=((await t.prepare(i.budgetWindow).bind(r).first())?.window_start??a)+n;return Math.max(1,Math.ceil((o-a)/1e3))})}async function insertToken(n,r,a,o){await e(()=>n.prepare(i.insertToken).bind(r,a,o+t).run())}async function deleteToken(t,n){await e(()=>t.prepare(i.deleteToken).bind(n).run())}async function consumeToken(t,n,r){return e(()=>t.prepare(i.consumeToken).bind(new Date(r).toISOString(),n,r).first())}function revokeSessions(t,n,r){return e(()=>t.prepare(i.revokeSessions).bind(n,r).first())}async function authReadiness(t){return e(async()=>{for(let[e,n]of Object.entries({auth_link_budget:[`user_id`,`window_start`,`sent`],auth_schema_meta:[`id`,`applied`],auth_sign_in_budget:[`id`,`window_start`,`admitted`],auth_tokens:[`token_hash`,`user_id`,`expires_at`,`redeemed_at`],auth_users:[`id`,`email`,`created_at`,`last_sign_in_at`,`session_version`]})){let r=await t.prepare(`PRAGMA table_info(${e})`).all();if(!n.every(e=>r.results?.some(t=>t.name===e)))return null}if((await t.prepare(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')`).all()).results?.length!==2)return null;let e=await t.prepare(`SELECT applied FROM auth_schema_meta WHERE id = 1`).first();return await t.prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,
|
|
15
|
+
t.token_hash, t.user_id, t.expires_at, t.redeemed_at,
|
|
16
|
+
b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted
|
|
17
|
+
FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0
|
|
18
|
+
LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`).first(),typeof e?.applied==`number`&&Number.isSafeInteger(e.applied)&&e.applied>=0?e.applied:null})}function readAccount(t,n){return e(()=>t.prepare(i.account).bind(n).first())}async function deleteAccount(t,n){await e(()=>t.prepare(i.deleteAccount).bind(n).run())}async function sweep(t,r){return(await e(()=>t.batch([t.prepare(i.sweepTokens).bind(r),t.prepare(i.sweepBudget).bind(r-n),t.prepare(i.sweepUnverified)]))).reduce((e,t)=>e+(t.meta?.changes??0),0)}export{t as LINK_TTL_MS,n as LINK_WINDOW_MS,r as SIGN_IN_WINDOW_MS,authReadiness,consumeToken,deleteAccount,deleteToken,ensureUser,insertToken,readAccount,revokeSessions,spendLinkBudget,spendSignInBudget,sweep};
|
|
19
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","names":[],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../worker/env';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}
|