@agilesyndrome/cf-genai-base 5.0.0 → 5.0.2

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/CLI.md ADDED
@@ -0,0 +1,121 @@
1
+ # `cf-genai` command
2
+
3
+ The `cf-genai` executable is published by `@agilesyndrome/cf-genai-base` and
4
+ provides safe operational commands for Cloudflare Worker repositories using
5
+ Wrangler and D1. The refresh command surface contains only local and staging
6
+ targets.
7
+
8
+ Projects that previously installed `@agilesyndrome/cf-genai-cli` should remove
9
+ that dependency. Installing `@agilesyndrome/cf-genai-base` now installs the
10
+ same `cf-genai` executable.
11
+
12
+ Automation can import the command runner from
13
+ `@agilesyndrome/cf-genai-base/cli`. The migrated helper modules remain
14
+ available through subpaths such as `@agilesyndrome/cf-genai-base/cli/d1` and
15
+ `@agilesyndrome/cf-genai-base/cli/project`.
16
+
17
+ It also provides shared project automation so dependent repositories do not
18
+ need to duplicate their build and release logic:
19
+
20
+ ```sh
21
+ cf-genai check
22
+ cf-genai lint data-access
23
+ cf-genai test
24
+ cf-genai ci
25
+ cf-genai dev
26
+ cf-genai upgrade base latest
27
+ cf-genai upgrade auth latest
28
+ cf-genai upgrade llm 5.0.0
29
+ cf-genai upgrade messaging latest
30
+ cf-genai release --confirm
31
+ cf-genai release --first --confirm
32
+ cf-genai release --add-trust --confirm
33
+ cf-genai release --dry-run
34
+ cf-genai release --version 4.1 --confirm
35
+ cf-genai release-status
36
+ cf-genai release-status --wait 3 --json
37
+ cf-genai version
38
+ cf-genai status
39
+ cf-genai status --json
40
+ ```
41
+
42
+ `dev` runs through `op run --env-file=.env.dev --`, using the repository's
43
+ `npm run dev` script when present, otherwise starting `wrangler dev`. This
44
+ loads all `.env.dev` variables and resolves any `op://` values automatically.
45
+ `check` also rejects direct `env.DB.prepare(...)` and `DB.prepare(...)` calls
46
+ in `cf-genai-*` application source; domain code must use the scoped data reader
47
+ provided by `cf-genai-base`. Tests and migrations are excluded from this lint.
48
+ `release` owns versioning, tagging, and pushing the release trigger used by
49
+ GitHub Actions. `version` shows the installed CLI version and the latest npm
50
+ version, with an upgrade command when one is available.
51
+
52
+ For the initial npm publication, use `cf-genai release --first --confirm`.
53
+ It checks npm authentication, publishes the current package once with public
54
+ access and provenance disabled, and then creates the matching npm Trusted
55
+ Publisher rule for `.github/workflows/publish.yml`. npm 11.15.0 or newer and
56
+ account-level 2FA are required for the trust step. If the package is already
57
+ published, use `cf-genai release --add-trust --confirm`.
58
+ Subsequent releases use the normal tag-triggered workflow.
59
+
60
+ ```sh
61
+ cf-genai d1 refresh local
62
+ cf-genai d1 refresh staging --yes
63
+ cf-genai d1 migrate local
64
+ cf-genai d1 migrate staging
65
+ cf-genai d1 migrate production --confirm-production
66
+ cf-genai d1 status staging
67
+ cf-genai d1 check production
68
+ cf-genai config check
69
+ ```
70
+
71
+ `refresh local` exports production data, applies local migrations, clears local
72
+ application tables, and imports the data. `refresh staging` does the same for a
73
+ remote staging environment. Both exclude Wrangler's migration ledger and
74
+ internal tables. The default D1 binding is `DB`; override it with `--database`.
75
+
76
+ Credential loading stays outside the CLI, so repositories can use their normal
77
+ wrapper:
78
+
79
+ ```sh
80
+ op run --env-file=.env.op -- cf-genai d1 refresh local
81
+ ```
82
+
83
+ Production migration requires `--confirm-production`. Remote staging refresh
84
+ requires `--yes`. Releases require `--confirm` (or can be inspected
85
+ with `--dry-run`); the CLI verifies a
86
+ clean checkout on `main`, fetches and compares `origin/main`, pushes and
87
+ verifies the release commit before creating the tag, and only then pushes the
88
+ tag that triggers npm publishing. The release tag publishes the package through GitHub Actions with provenance. `config check` runs a Wrangler deploy dry-run. Production refresh remains intentionally unavailable; backups and restores are available with explicit file paths.
89
+
90
+ `upgrade PACKAGE VERSION` upgrades the first-party `base`, `auth`, `llm`, or
91
+ `messaging` package with npm and vendors package migrations not already represented in the repository's
92
+ `migrations/` directory. The generated files are ordinary committed Wrangler
93
+ migrations, so the same schema change is applied consistently to local,
94
+ staging, and production D1 databases. Review and commit the package files,
95
+ `package.json`, `package-lock.json`, and generated migrations together.
96
+ Operational status can be read directly from the current site directory through Wrangler (no CLI login prompt):
97
+ cf-genai healthcheck:list --env local
98
+ cf-genai healthcheck:list --env staging
99
+ cf-genai circuit-breaker:list --env prod
100
+ cf-genai circuit-breaker:set llm:openai-models on --env staging
101
+ The standardized admin surface mirrors cf-genai-base and uses Wrangler authentication from the current machine:
102
+ cf-genai admin status --env staging
103
+ cf-genai admin features --env staging
104
+ cf-genai admin users --env staging
105
+ cf-genai admin tenants --env staging
106
+ cf-genai admin scopes --env staging
107
+ cf-genai admin groups --env staging
108
+ cf-genai admin healthchecks --env staging
109
+ cf-genai admin circuit-breakers --env staging
110
+ cf-genai tenant list --env staging
111
+ cf-genai tenant get easley-family --env staging
112
+ cf-genai tenant create acme --name "Acme Corporation" --env staging
113
+ cf-genai tenant update acme --name "Acme Inc." --env staging
114
+ cf-genai user get someone@example.com --env staging
115
+ cf-genai user update someone@example.com --tenants easley-family,acme --env staging
116
+ cf-genai healthchecks set llm:provider red --env staging
117
+ cf-genai circuit-breakers set llm:provider tripped --env staging
118
+ Back up and restore a complete D1 database with explicit files:
119
+ cf-genai d1 backup production --output ./backup.sql --confirm-production
120
+ cf-genai d1 restore staging --file ./backup.sql --yes
121
+ `release` creates the version commit and tag; the tag-triggered workflow publishes to npm. `release-status` verifies a clean, pushed workspace, the remote release tag, successful GitHub Actions runs for that tag, npm publication, and whether the declared `cf-genai-base` version is current. An older base version is shown in yellow with an upgrade command. `--wait` is one total timeout in minutes shared by GitHub Actions and npm polling; the default is five minutes. Use `release --version MAJOR.MINOR` to explicitly jump to a version such as `5.0`; the CLI assigns patch `0`, accepts an equal prepared manifest version when its tag and npm version do not exist, rejects older versions, and refuses any version already present on npm or GitHub. This is useful for synchronizing packages onto a common release line.
package/CONTRACT.md CHANGED
@@ -90,8 +90,8 @@ them explicitly. Base cannot provide row-level security to
90
90
  direct D1 calls, so applications must keep raw database access out of domain
91
91
  features. The cookbook migration must add and backfill `tenant_id` on recipe
92
92
  tables, register recipes as tenant-scoped, replace direct D1 reads/writes with
93
- `state.data.tenant`, and add cross-tenant isolation tests. The companion
94
- `cf-genai-cli` should lint `cf-genai-*` working folders for direct
93
+ `state.data.tenant`, and add cross-tenant isolation tests. The bundled
94
+ `cf-genai` command lints `cf-genai-*` working folders for direct
95
95
  `env.DB.prepare(` usage as a follow-up enforcement check.
96
96
  Scoped write violations are returned as a generic 403 response; the detailed
97
97
  scope/resource identity is retained in the audit log only.
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Opinionated startup boilerplate for small Cloudflare Workers.
4
4
 
5
+ The package also ships the `cf-genai` command. Runtime, administration, D1,
6
+ project automation, and release tooling are versioned and published together;
7
+ there is no separate CLI dependency.
8
+
5
9
  The base owns the shared security boundary as well as Worker lifecycle concerns. It reserves `/admin` and `/api/admin` routes, authenticates them using `AUTH_STRATEGY` (default `http_basic`, or `oauth` when an auth provider is supplied), and applies the optional `authorize` policy. Sites still own their router, HTML, D1 queries, R2 keys, and scheduled jobs.
6
10
  Use D1 bindings for durable application data and R2 bindings for binary assets;
7
11
  do not put either into module-level state.
@@ -32,6 +36,51 @@ pages. Import `AdminShell` and the platform catalogs from
32
36
  `@agilesyndrome/cf-genai-base/ui`. Sites provide their own application links and
33
37
  theme while the base components consume the shared JSON admin APIs.
34
38
 
39
+ ## Command-line tools
40
+
41
+ Install base in a project (or globally) and use the bundled executable:
42
+
43
+ ```sh
44
+ npm install @agilesyndrome/cf-genai-base
45
+ npx cf-genai version
46
+
47
+ cf-genai check
48
+ cf-genai test
49
+ cf-genai ci
50
+ cf-genai dev
51
+ cf-genai upgrade base latest
52
+ cf-genai release --confirm
53
+ cf-genai release-status --wait 3
54
+ ```
55
+
56
+ Projects upgrading from the standalone package should remove
57
+ `@agilesyndrome/cf-genai-cli`; their existing base dependency now supplies the
58
+ same `cf-genai` executable.
59
+
60
+ The CLI includes all commands formerly published by
61
+ `@agilesyndrome/cf-genai-cli`: project checks and releases, scoped-data linting,
62
+ package upgrades with migration vendoring, D1 refresh/backup/restore/migration,
63
+ site status, and user, tenant, healthcheck, circuit-breaker, and platform admin
64
+ operations.
65
+
66
+ ```sh
67
+ cf-genai status --env staging
68
+ cf-genai d1 refresh local
69
+ cf-genai d1 migrate production --confirm-production
70
+ cf-genai d1 backup production --output ./backup.sql --confirm-production
71
+ cf-genai admin features --env staging
72
+ cf-genai admin users --env staging
73
+ cf-genai tenant list --env staging
74
+ cf-genai user get someone@example.com --env staging
75
+ cf-genai healthchecks set llm:provider red --env staging
76
+ cf-genai circuit-breakers set llm:provider tripped --env staging
77
+ ```
78
+
79
+ See [CLI.md](CLI.md) or run `cf-genai --help` for the complete command grammar. Credential loading
80
+ stays outside the command, so a repository can continue to wrap it with
81
+ `op run --env-file=.env.op --`. Destructive remote operations retain their
82
+ existing explicit confirmation flags.
83
+
35
84
  ## Shared platform helpers
36
85
 
37
86
  `createWorker` can own `/health` and `/api/health`, and run a boot validator
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/cli/cli.js";
4
+
5
+ try {
6
+ const result = await main(process.argv.slice(2));
7
+ if (result?.ok === false) process.exitCode = 1;
8
+ } catch (error) {
9
+ console.error(`Error: ${error.message}`);
10
+ process.exitCode = 1;
11
+ }
package/changelog.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Fold the complete `cf-genai` operational CLI into this package so Worker,
6
+ admin, healthcheck, circuit-breaker, D1, project, and release tooling share a
7
+ single version and installation.
8
+ - Publish the `cf-genai` executable and the programmatic `/cli` entrypoint from
9
+ `@agilesyndrome/cf-genai-base`.
10
+ - Make base CI validate the bundled CLI directly instead of downloading the
11
+ retired `@agilesyndrome/cf-genai-cli` package.
12
+
3
13
  ## 5.0.0
4
14
 
5
15
  - Replace `@agilesyndrome/cf-genai-base/authorization` imports with `/auth`.
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "5.0.0",
3
+ "version": "5.0.2",
4
4
  "type": "module",
5
+ "bin": {
6
+ "cf-genai": "bin/cf-genai.js"
7
+ },
5
8
  "exports": {
6
9
  ".": "./src/index.js",
7
10
  "./auth": "./src/auth/index.js",
@@ -14,9 +17,14 @@
14
17
  "./core": "./src/core/index.js",
15
18
  "./ui": "./src/ui/index.js",
16
19
  "./ui/react": "./src/ui/react/index.jsx",
17
- "./ui/styles.css": "./src/ui/styles.css"
20
+ "./ui/styles.css": "./src/ui/styles.css",
21
+ "./cli": "./src/cli/cli.js",
22
+ "./cli/*": "./src/cli/*.js"
23
+ },
24
+ "description": "Cloudflare Worker lifecycle, security, administration, and operational CLI helpers.",
25
+ "engines": {
26
+ "node": ">=20"
18
27
  },
19
- "description": "Lean Worker lifecycle and security helpers for Cloudflare sites.",
20
28
  "license": "MIT",
21
29
  "peerDependencies": {
22
30
  "react": "^19.0.0"
@@ -26,9 +34,11 @@
26
34
  "provenance": true
27
35
  },
28
36
  "files": [
37
+ "bin",
29
38
  "src",
30
39
  "migrations",
31
40
  "README.md",
41
+ "CLI.md",
32
42
  "changelog.md",
33
43
  "CONTRACT.md",
34
44
  "LICENSE"
@@ -39,7 +49,7 @@
39
49
  },
40
50
  "homepage": "https://github.com/agilesyndrome/cf-genai-base#readme",
41
51
  "scripts": {
42
- "check": "node --check src/index.js && node --check src/core/index.js && node --check src/core/jobs.js && node --check src/core/event-hub.js && node --check src/auth/index.js && node --check src/runtime/worker.js && node --check src/runtime/features.js && node --check src/runtime/health.js && node --check src/admin/index.js && node --check src/admin/routes.js && node --check src/data/index.js && node --check src/data/resources.js && node --check src/data/reader.js && node --check src/data/context.js && node --check src/api/index.js && node --check src/api/contracts.js && node --check src/api/client.js && node --check src/api/jobs.js && node --check src/ui/index.js",
52
+ "check": "node --check bin/cf-genai.js && node --check src/index.js && node --check src/core/index.js && node --check src/core/jobs.js && node --check src/core/event-hub.js && node --check src/auth/index.js && node --check src/auth/oauth-single.js && node --check src/runtime/worker.js && node --check src/runtime/features.js && node --check src/runtime/health.js && node --check src/admin/index.js && node --check src/admin/routes.js && node --check src/data/index.js && node --check src/data/resources.js && node --check src/data/reader.js && node --check src/data/context.js && node --check src/api/index.js && node --check src/api/contracts.js && node --check src/api/client.js && node --check src/api/jobs.js && node --check src/ui/index.js && node --check src/cli/cli.js && node --check src/cli/d1.js && node --check src/cli/operations.js && node --check src/cli/project.js && node --check src/cli/status.js && node --check src/cli/tenants.js && node --check src/cli/users.js && node --check src/cli/version.js",
43
53
  "test": "node --test tests/*.test.mjs",
44
54
  "build": "npm run check && npm test && npm pack --dry-run"
45
55
  }
@@ -6,7 +6,7 @@ export async function adminBoundary(request, env, ctx, next, state, { provider,
6
6
  if (!isAdminPath(url.pathname)) return next(request);
7
7
  const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
8
8
  if (strategy === "http_basic") { const user = basicUser(request, env); if (!user) return adminUnauthorized(request); state.user = user; }
9
- else if (strategy === "oauth") { const user = provider?.getUser ? await provider.getUser(request, env) : null; if (!user) return oauthUnauthorized(request, url); state.user = user; }
9
+ else if (strategy === "oauth") { const user = Object.hasOwn(state, "user") ? state.user : provider?.getUser ? await provider.getUser(request, env) : null; if (!user) return oauthUnauthorized(request, url); state.user = user; }
10
10
  else return new Response("Unsupported AUTH_STRATEGY", { status: 500, headers: { "Cache-Control": "no-store" } });
11
11
  if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method) && url.pathname.startsWith("/api/")) { const origin = request.headers.get("Origin"); if (!origin || (() => { try { return new URL(origin).origin !== url.origin; } catch { return true; } })()) return Response.json({ error: "A same-origin request is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }); }
12
12
  const who = state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}`;
package/src/auth/index.js CHANGED
@@ -5,3 +5,4 @@ export * from "./tenants.js";
5
5
  export * from "./groups.js";
6
6
  export * from "./subscriptions.js";
7
7
  export * from "./impersonation.js";
8
+ export * from "./oauth-single.js";
@@ -0,0 +1,204 @@
1
+ import { sameOrigin, secureJson, secureText } from "../core/security.js";
2
+ import { ensureUser } from "./users.js";
3
+
4
+ const encoder = new TextEncoder();
5
+ const decoder = new TextDecoder();
6
+ const configurationCache = new Map();
7
+ const configurationRequests = new Map();
8
+ const jwksCache = new Map();
9
+ const jwksRequests = new Map();
10
+ const OIDC_CACHE_MS = 15 * 60 * 1000;
11
+ const OIDC_TIMEOUT_MS = 10_000;
12
+ export const OAUTH_SINGLE = "OAUTH_SINGLE";
13
+ export const PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
14
+ export const VERSION = "5.0.0";
15
+
16
+ export const authRepositoryDefinitions = [
17
+ { name: "users", resource: "auth_users", scope: "system", resourceDefinition: { name: "auth_users", table: "auth_users", scope: "system", columns: ["id", "provider", "subject", "email", "display_name", "is_admin", "created_at", "updated_at"], readableColumns: ["id", "provider", "subject", "email", "display_name", "is_admin", "created_at", "updated_at"], orderableColumns: ["id", "email", "created_at"], writableColumns: ["display_name", "is_admin"] } },
18
+ { name: "groups", resource: "auth_groups", scope: "system", resourceDefinition: { name: "auth_groups", table: "auth_groups", scope: "system", columns: ["name", "display_name", "description", "created_at", "updated_at"], idColumn: "name", readableColumns: ["name", "display_name", "description", "created_at", "updated_at"], orderableColumns: ["name", "created_at"], writableColumns: ["name", "display_name", "description"] } },
19
+ ];
20
+
21
+ /**
22
+ * Generic OIDC auth for Workers. It uses Authorization Code + PKCE and a
23
+ * signed, host-only cookie, so a site needs no auth service of its own.
24
+ * Site-specific roles can be derived from `claims` in `onLogin`.
25
+ */
26
+ export function createAuth(options = {}) {
27
+ const prefix = options.cookiePrefix || "__Host-cfgenai";
28
+ if (!/^(?:__Host-)?[A-Za-z0-9_-]+$/.test(prefix)) throw new Error("cookiePrefix contains invalid characters");
29
+ const names = { state: options.stateCookieName || `${prefix}_state`, session: options.sessionCookieName || `${prefix}_session` };
30
+ const publicPaths = options.publicPaths || ["/auth/", "/favicon.svg", "/robots.txt", "/health"];
31
+ const protectedPath = options.protectedPath || (() => true);
32
+ const envName = (key, fallback) => options.env?.[key] || fallback;
33
+
34
+ return {
35
+ name: "auth", displayName: options.displayName || "Authentication", packageName: PACKAGE_NAME, version: VERSION, strategy: OAUTH_SINGLE, repositories: options.repositories || authRepositoryDefinitions,
36
+ dataResources: options.dataResources || [], routes: options.routes || [],
37
+ healthchecks: options.healthchecks || [], circuitBreakers: options.circuitBreakers || [],
38
+ async handle(request, env, _ctx, state) {
39
+ const url = new URL(request.url);
40
+ if (url.pathname === "/auth/login") return login(request, env);
41
+ if (url.pathname === "/auth/callback") return callback(request, env);
42
+ if (url.pathname === "/auth/logout") {
43
+ if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST", "Cache-Control": "no-store" } });
44
+ const originResponse = checkOrigin(request, options.allowedOrigins);
45
+ if (originResponse) return originResponse;
46
+ return logout(request, env, names.session, options);
47
+ }
48
+ if (url.pathname === "/api/me") return secureJson({ user: publicUser(await resolveRequestUser(request, env, state)) });
49
+ if (!protectedPath(url.pathname) || publicPaths.some((path) => path === "/" ? url.pathname === "/" : url.pathname.startsWith(path))) return null;
50
+ if (isMutation(request)) { const originResponse = checkOrigin(request, options.allowedOrigins); if (originResponse) return originResponse; }
51
+ const user = await resolveRequestUser(request, env, state);
52
+ if (user && options.authorize && !(await options.authorize({ request, url, user, env }))) {
53
+ return url.pathname.startsWith("/api/") ? Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }) : authError("Administrator access is required.", 403);
54
+ }
55
+ if (user) return null;
56
+ if (url.pathname.startsWith("/api/")) return Response.json({ error: "Authentication is required." }, { status: 401, headers: { "Cache-Control": "no-store" } });
57
+ return Response.redirect(`${url.origin}/auth/login?return_to=${encodeURIComponent(safeReturnTo(url.pathname + url.search))}`, 302);
58
+ },
59
+ getUser: (request, env) => resolveUser(request, env),
60
+ healthcheck: async (env) => ({ feature: "auth", component: "configuration", displayName: "Authentication configuration", state: Boolean(env?.DB) && [envName("issuer", "OIDC_ISSUER"), envName("clientId", "OIDC_CLIENT_ID"), envName("clientSecret", "OIDC_CLIENT_SECRET"), envName("sessionSecret", "AUTH_SESSION_SECRET")].every((key) => env?.[key] && !String(env[key]).startsWith("replace-with-")) ? "green" : "red" }),
61
+ middleware(request, env, ctx, next, state) {
62
+ return this.handle(request, env, ctx, state).then((response) => response || next(request, env, ctx, state));
63
+ },
64
+ };
65
+
66
+ async function login(request, env) {
67
+ if (options.getSession && await resolveUser(request, env)) {
68
+ const target = safeReturnTo(new URL(request.url).searchParams.get("return_to") || "/");
69
+ return redirect(new URL(request.url).origin + target, [clearCookie(names.state)]);
70
+ }
71
+ const config = await configuration(env, options);
72
+ const state = random();
73
+ const verifier = random();
74
+ const nonce = random();
75
+ const challenge = base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(verifier))));
76
+ const returnTo = safeReturnTo(new URL(request.url).searchParams.get("return_to") || "/");
77
+ const stateValue = `${state}.${verifier}.${nonce}.${base64url(encoder.encode(returnTo))}`;
78
+ const authorize = new URL(config.authorization_endpoint);
79
+ authorize.search = new URLSearchParams({ client_id: required(env, envName("clientId", "OIDC_CLIENT_ID")), response_type: "code", redirect_uri: callbackUrl(request, env, options), scope: options.scope || "openid profile email", state, code_challenge: challenge, code_challenge_method: "S256", nonce }).toString();
80
+ return redirect(authorize, [cookie(names.state, stateValue, 600)]);
81
+ }
82
+
83
+ async function callback(request, env) {
84
+ const url = new URL(request.url);
85
+ const value = cookies(request)[names.state] || "";
86
+ const [state, verifier, nonce, encodedReturn] = value.split(".");
87
+ if (!state || !constantTimeEqual(state, url.searchParams.get("state") || "") || !verifier) {
88
+ if (options.getSession && await resolveUser(request, env)) return redirect(new URL(request.url).origin, [clearCookie(names.state)]);
89
+ return authError("The sign-in state was invalid or expired.", 400);
90
+ }
91
+ const config = await configuration(env, options);
92
+ const clientId = required(env, envName("clientId", "OIDC_CLIENT_ID"));
93
+ const token = await fetchWithTimeout(config.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: required(env, envName("clientSecret", "OIDC_CLIENT_SECRET")), grant_type: "authorization_code", code: url.searchParams.get("code") || "", redirect_uri: callbackUrl(request, env, options), code_verifier: verifier }) }).then((response) => response.ok ? response.json() : Promise.reject(new Error("OIDC token exchange failed")));
94
+ const claims = await verify(token.id_token, config, clientId, nonce);
95
+ if (!claims.sub) return authError("The identity provider returned no subject.", 502);
96
+ const user = await resolveLoginUser({ ...(await (options.onLogin ? options.onLogin(claims, env) : normalizeUser(claims))), email_verified: claims.email_verified === true }, env, request);
97
+ const identity = sessionIdentity(user);
98
+ const signed = options.createSession ? await options.createSession(identity, env, request) : await sign(JSON.stringify({ ...identity, exp: Math.floor(Date.now() / 1000) + (options.sessionSeconds || 28800) }), env, envName("sessionSecret", "AUTH_SESSION_SECRET"));
99
+ await env.event?.("auth.login.succeeded", "auth", { userId: user.authUser.id });
100
+ return redirect(`${url.origin}${decodeReturn(encodedReturn)}`, [cookie(names.session, signed, options.sessionSeconds || 28800), clearCookie(names.state)]);
101
+ }
102
+ async function resolveUser(request, env) {
103
+ const user = await hydrateUser(await getUser(request, env, envName("sessionSecret", "AUTH_SESSION_SECRET"), names.session, options), env);
104
+ if (user && options.sessionAuthorize && !(await options.sessionAuthorize({ user, request, env }))) return null;
105
+ return user;
106
+ }
107
+
108
+ function resolveRequestUser(request, env, state) {
109
+ return state && Object.hasOwn(state, "user") ? state.user : resolveUser(request, env);
110
+ }
111
+
112
+ async function resolveLoginUser(user, env, request) {
113
+ const hydrated = await hydrateUser(user, env);
114
+ if (options.loginAuthorize && !(await options.loginAuthorize({ user: hydrated, request, env }))) throw authError("Authentication is not currently permitted.", 403);
115
+ return hydrated;
116
+ }
117
+ }
118
+
119
+ async function hydrateUser(user, env) {
120
+ if (!user) return user;
121
+ if (!env?.DB) throw new Error("OAUTH_SINGLE requires the base DB binding for canonical users");
122
+ const authUser = await ensureUser(env, user, { who: `user:${user.sub || "unknown"}` });
123
+ return { ...user, authUser };
124
+ }
125
+
126
+ async function getUser(request, env, secretName = "AUTH_SESSION_SECRET", sessionName = "__Host-cfgenai_session", options = {}) {
127
+ const token = cookies(request)[sessionName];
128
+ if (!token) return null;
129
+ if (options.getSession) return (await options.getSession(token, env, request)) || null;
130
+ const [payload, signature] = token.split(".");
131
+ if (!payload || !signature || !constantTimeEqual(signature, await sign(payload, env, secretName, false))) return null;
132
+ try { const user = JSON.parse(decoder.decode(decode(payload))); return typeof user.exp === "number" && user.exp > Date.now() / 1000 ? user : null; } catch { return null; }
133
+ }
134
+
135
+ async function configuration(env, options = {}) {
136
+ const discoveryName = envNameFor(options, "discoveryUrl", "OIDC_DISCOVERY_URL");
137
+ const configuredDiscovery = env[discoveryName];
138
+ const configuredIssuer = env[envNameFor(options, "issuer", "OIDC_ISSUER")];
139
+ const discoveryUrl = configuredDiscovery ? (String(configuredDiscovery).endsWith("/.well-known/openid-configuration") ? configuredDiscovery : String(configuredDiscovery).replace(/\/+$/, "") + "/.well-known/openid-configuration") : normalizeIssuer(required(env, envNameFor(options, "issuer", "OIDC_ISSUER"))).slice(0, -1) + "/.well-known/openid-configuration";
140
+ if (new URL(discoveryUrl).protocol !== "https:") throw new Error("OIDC discovery URL must use HTTPS");
141
+ const expectedIssuer = configuredIssuer ? normalizeIssuer(configuredIssuer) : issuerFromDiscoveryUrl(discoveryUrl);
142
+ const cacheKey = `${discoveryUrl}\0${expectedIssuer}`;
143
+
144
+ const cached = configurationCache.get(cacheKey); if (cached && cached.exp > Date.now()) return cached.value;
145
+ const existing = configurationRequests.get(cacheKey); if (existing) return existing;
146
+ const request = fetchWithTimeout(discoveryUrl).then(async (response) => {
147
+ if (!response.ok) throw new Error("Unable to load OIDC configuration");
148
+ const value = await response.json();
149
+ if (!value.issuer || new URL(value.issuer).protocol !== "https:" || normalizeIssuer(value.issuer) !== expectedIssuer) throw new Error("OIDC configuration returned an unexpected issuer");
150
+ requireHttpsEndpoint(value.authorization_endpoint, "authorization");
151
+ requireHttpsEndpoint(value.token_endpoint, "token");
152
+ requireHttpsEndpoint(value.jwks_uri, "JWKS");
153
+ configurationCache.set(cacheKey, { value, exp: Date.now() + OIDC_CACHE_MS }); return value;
154
+ }).finally(() => configurationRequests.delete(cacheKey));
155
+ configurationRequests.set(cacheKey, request); return request;
156
+ }
157
+ async function verify(token, config, clientId, expectedNonce) {
158
+ const [head, body, signature] = String(token || "").split("."); if (!head || !body || !signature) throw new Error("Malformed ID token");
159
+ const header = JSON.parse(decoder.decode(decode(head))); if (header.alg !== "RS256" || !header.kid) throw new Error("Unsupported ID token signature"); const keys = await getJwks(config.jwks_uri); const jwk = keys.keys.find((key) => key.kid === header.kid); if (!jwk) throw new Error("ID token signing key was not found");
160
+ const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
161
+ if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, decode(signature), encoder.encode(`${head}.${body}`))) throw new Error("Invalid ID token");
162
+ const claims = JSON.parse(decoder.decode(decode(body))); const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; const validAzp = !Array.isArray(claims.aud) || claims.aud.length < 2 || claims.azp === clientId;
163
+ if (normalizeIssuer(claims.iss || "") !== normalizeIssuer(config.issuer || "") || !aud.includes(clientId) || !validAzp || claims.exp <= Date.now() / 1000 || claims.nonce !== expectedNonce || !claims.sub) throw new Error("Invalid ID token claims"); return claims;
164
+ }
165
+ async function getJwks(uri) {
166
+ const cached = jwksCache.get(uri); if (cached && cached.exp > Date.now()) return cached.value;
167
+ const existing = jwksRequests.get(uri); if (existing) return existing;
168
+ const request = fetchWithTimeout(uri).then(async (response) => { if (!response.ok) throw new Error("Unable to load OIDC signing keys"); const value = await response.json(); jwksCache.set(uri, { value, exp: Date.now() + OIDC_CACHE_MS }); return value; }).finally(() => jwksRequests.delete(uri));
169
+ jwksRequests.set(uri, request); return request;
170
+ }
171
+ async function fetchWithTimeout(input, init = {}) { return fetch(input, { ...init, signal: AbortSignal.timeout(OIDC_TIMEOUT_MS) }); }
172
+ function normalizeIssuer(value) { return String(value).replace(/\/+$/, "") + "/"; }
173
+ function issuerFromDiscoveryUrl(value) { const url = new URL(value); url.pathname = url.pathname.replace(/\/\.well-known\/openid-configuration$/, "").replace(/\/+$/, ""); url.search = ""; url.hash = ""; return normalizeIssuer(url.href); }
174
+ function requireHttpsEndpoint(value, label) { if (!value || new URL(value).protocol !== "https:") throw new Error(`OIDC configuration returned an invalid ${label} endpoint`); }
175
+ function isMutation(request) { return ["POST", "PUT", "PATCH", "DELETE"].includes(request.method); }
176
+ function checkOrigin(request, allowedOrigins = []) {
177
+ let requestOrigin;
178
+ try { requestOrigin = new URL(request.url).origin; } catch { return authError("This request did not pass the same-origin check.", 403); }
179
+ return sameOrigin(request, [requestOrigin, ...allowedOrigins]) ? null : authError("A same-origin request is required.", 403);
180
+ }
181
+ async function sign(value, env, name, encoded = true) { const secret = required(env, name); if (name === "AUTH_SESSION_SECRET" && secret.length < 32) throw new Error("AUTH_SESSION_SECRET must be at least 32 characters"); const data = encoded ? base64url(encoder.encode(value)) : value; const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const sig = base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(data)))); return encoded ? `${data}.${sig}` : sig; }
182
+ function normalizeUser(claims) { return { sub: claims.sub, email: String(claims.email || "").toLowerCase(), name: claims.name || claims.email || claims.sub, email_verified: claims.email_verified === true, auth_strategy: OAUTH_SINGLE }; }
183
+ function envNameFor(options, key, fallback) { return options.env?.[key] || fallback; }
184
+ function required(env, key) { if (!env[key] || String(env[key]).startsWith("replace-with-")) throw new Error(`${key} is not configured`); return String(env[key]); }
185
+ function random() { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return base64url(bytes); }
186
+ function base64url(bytes) { let s = ""; for (const b of bytes) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
187
+ function decode(value) { return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4)), (c) => c.charCodeAt(0)); }
188
+ function cookies(request) { return Object.fromEntries((request.headers.get("Cookie") || "").split(";").flatMap((part) => { const i = part.indexOf("="); return i < 0 ? [] : [[part.slice(0, i).trim(), part.slice(i + 1).trim()]]; })); }
189
+ function cookie(name, value, age) { return `${name}=${value}; Max-Age=${age}; Path=/; HttpOnly; Secure; SameSite=Lax`; }
190
+ function clearCookie(name) { return `${name}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`; }
191
+ function redirect(location, setCookies) { const response = new Response(null, { status: 302, headers: { Location: String(location), "Cache-Control": "no-store" } }); for (const value of setCookies) response.headers.append("Set-Cookie", value); return response; }
192
+ async function logout(request, env, name, options = {}) {
193
+ const token = cookies(request)[name];
194
+ if (token && options.revokeSession) await options.revokeSession(token, env, request);
195
+ await env.event?.("auth.logout", "auth");
196
+ return redirect(new URL(request.url).origin + "/", [clearCookie(name)]);
197
+ }
198
+ function authError(message, status) { return secureText(message, status); }
199
+ function callbackUrl(request, env, options) { const configuredOrigin = env[envNameFor(options, "publicOrigin", "PUBLIC_ORIGIN")]; return (configuredOrigin ? new URL(String(configuredOrigin)).origin : new URL(request.url).origin) + "/auth/callback"; }
200
+ function safeReturnTo(value) { return value?.startsWith("/") && !value.startsWith("//") && !value.startsWith("/auth/") ? value : "/"; }
201
+ function decodeReturn(value) { try { return safeReturnTo(decoder.decode(decode(value))); } catch { return "/"; } }
202
+ function constantTimeEqual(a, b) { const aa = encoder.encode(a), bb = encoder.encode(b); let n = aa.length ^ bb.length; for (let i = 0; i < Math.max(aa.length, bb.length); i++) n |= (aa[i] || 0) ^ (bb[i] || 0); return n === 0; }
203
+ function sessionIdentity(user) { const { authUser: _authUser, exp: _exp, ...identity } = user || {}; return identity; }
204
+ function publicUser(user) { return user ? { id: user.authUser.id, email: user.email || user.authUser.email, name: user.name || user.authUser.display_name, isAdmin: Boolean(user.authUser.is_admin) } : null; }
@@ -2,6 +2,8 @@ import { createD1 } from "../core/d1.js";
2
2
  import { deepEqual, parseJsonValue } from "./encoding.js";
3
3
  import { SubscriptionError } from "./constants.js";
4
4
 
5
+ const manifestPromises = new WeakMap();
6
+
5
7
  export async function listTenantSubscriptions(env, tenantId, { who = "system:read" } = {}) {
6
8
  const { results } = await createD1(env, { who }).prepare("SELECT s.id,s.name,s.created_at,s.updated_at FROM auth_subscriptions s JOIN auth_tenant_subscriptions ts ON ts.subscription_id=s.id WHERE ts.tenant_id=? ORDER BY s.name COLLATE NOCASE").bind(tenantId).all();
7
9
  return Promise.all((results || []).map(async (subscription) => ({ ...subscription, entitlements: await listSubscriptionEntitlements(env, subscription.id, { who }) })));
@@ -15,8 +17,23 @@ export async function listSubscriptionEntitlements(env, subscriptionId, { who =
15
17
  export function normalizeSubscriptionManifest(manifest = []) { return manifest.map((subscription) => ({ id: String(subscription?.id || "").trim(), name: String(subscription?.name || subscription?.id || "").trim(), entitlements: Object.fromEntries(Object.entries(subscription?.entitlements || {}).map(([key, value]) => [String(key), value])) })).filter((subscription) => /^[a-z0-9][a-z0-9_-]*$/.test(subscription.id) && subscription.name); }
16
18
 
17
19
  export async function ensureSubscriptionManifest(env, manifest = [], { who = "system:update" } = {}) {
18
- if (!env?.DB) return; const db = createD1(env, { who });
19
- for (const subscription of normalizeSubscriptionManifest(manifest)) { await db.prepare("INSERT INTO auth_subscriptions (id,name) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, subscription.name).run(); for (const [entitlement, value] of Object.entries(subscription.entitlements)) await db.prepare("INSERT INTO auth_subscription_entitlements (subscription_id,entitlement,value_json) VALUES (?,?,?) ON CONFLICT(subscription_id,entitlement) DO UPDATE SET value_json=excluded.value_json,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, entitlement, JSON.stringify(value)).run(); }
20
+ if (!env?.DB) return;
21
+ const normalized = normalizeSubscriptionManifest(manifest);
22
+ const key = JSON.stringify(normalized);
23
+ let registrations = manifestPromises.get(env.DB);
24
+ if (!registrations) { registrations = new Map(); manifestPromises.set(env.DB, registrations); }
25
+ let promise = registrations.get(key);
26
+ if (!promise) {
27
+ promise = writeSubscriptionManifest(env, normalized, who);
28
+ registrations.set(key, promise);
29
+ promise.catch(() => registrations.delete(key));
30
+ }
31
+ return promise;
32
+ }
33
+
34
+ async function writeSubscriptionManifest(env, manifest, who) {
35
+ const db = createD1(env, { who });
36
+ for (const subscription of manifest) { await db.prepare("INSERT INTO auth_subscriptions (id,name) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, subscription.name).run(); for (const [entitlement, value] of Object.entries(subscription.entitlements)) await db.prepare("INSERT INTO auth_subscription_entitlements (subscription_id,entitlement,value_json) VALUES (?,?,?) ON CONFLICT(subscription_id,entitlement) DO UPDATE SET value_json=excluded.value_json,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, entitlement, JSON.stringify(value)).run(); }
20
37
  }
21
38
 
22
39
  export async function hasSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) { return Boolean(await createD1(env, { who }).prepare("SELECT 1 FROM auth_tenant_subscriptions WHERE tenant_id=? AND subscription_id=?").bind(tenantId, subscriptionId).first()); }
package/src/auth/users.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import { createD1 } from "../core/d1.js";
2
2
  import { AUTH_GRANT_TABLE, AUTH_USER_TABLE, DEFAULT_TENANT_ID, DEFAULT_TENANT_NAME } from "./constants.js";
3
- import { listUserGrants } from "./scopes.js";
4
- import { listUserTenants } from "./tenants.js";
5
3
 
6
4
  export async function ensureUser(env, user, { who = "system:read" } = {}) {
7
5
  if (!env?.DB || !user?.sub) return null;
8
- const db = createD1(env, { who }); const provider = String(user.auth_strategy || "oauth"); const subject = String(user.sub); const email = String(user.email || "").trim().toLowerCase();
6
+ const db = createD1(env, { who }); const provider = String(user.auth_strategy || "oauth"); const subject = String(user.sub); const email = String(user.email || "").trim().toLowerCase(); const displayName = String(user.name || email || subject);
9
7
  const existing = await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
10
- if (existing) { await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), existing.id).run(); await ensureDefaultTenantMembership(db, existing.id); return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin) }; }
8
+ if (existing) {
9
+ if (existing.email !== email || existing.display_name !== displayName) await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, displayName, existing.id).run();
10
+ return { ...existing, email, display_name: displayName, is_admin: Boolean(existing.is_admin) };
11
+ }
11
12
  const id = await stableId(`${provider}:${subject}`);
12
- await db.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,0) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject)).run();
13
+ await db.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,0) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, displayName).run();
13
14
  await ensureDefaultTenantMembership(db, id);
14
15
  return db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
15
16
  }
@@ -22,10 +23,18 @@ export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
22
23
  }
23
24
 
24
25
  export async function listAuthorizationUsers(env, { who = "system:read" } = {}) {
25
- const { results } = await createD1(env, { who }).prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all();
26
- return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name), tenants: await listUserTenants(env, user.id, { who }) })));
26
+ const db = createD1(env, { who });
27
+ const [{ results: users = [] }, { results: grants = [] }, { results: memberships = [] }] = await Promise.all([
28
+ db.prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all(),
29
+ db.prepare(`SELECT user_id,scope_name FROM ${AUTH_GRANT_TABLE} ORDER BY user_id,scope_name`).all(),
30
+ db.prepare("SELECT ut.user_id,t.id,t.name,t.created_at,t.updated_at FROM auth_user_tenants ut JOIN auth_tenants t ON t.id=ut.tenant_id ORDER BY ut.user_id,t.name COLLATE NOCASE").all(),
31
+ ]);
32
+ const scopesByUser = groupRows(grants, "user_id", (grant) => grant.scope_name);
33
+ const tenantsByUser = groupRows(memberships, "user_id", ({ user_id: _userId, ...tenant }) => tenant);
34
+ return users.map((user) => ({ ...user, scopes: scopesByUser.get(user.id) || [], tenants: tenantsByUser.get(user.id) || [] }));
27
35
  }
28
36
 
29
37
  export async function getAuthorizationUser(env, userId, { who = "system:read" } = {}) { return createD1(env, { who }).prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(userId).first(); }
30
38
 
31
39
  async function stableId(value) { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 32); }
40
+ function groupRows(rows, key, map) { const grouped = new Map(); for (const row of rows) { const values = grouped.get(row[key]) || []; values.push(map(row)); grouped.set(row[key], values); } return grouped; }