@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 +121 -0
- package/CONTRACT.md +2 -2
- package/README.md +49 -0
- package/bin/cf-genai.js +11 -0
- package/changelog.md +10 -0
- package/package.json +14 -4
- package/src/admin/routes.js +1 -1
- package/src/auth/index.js +1 -0
- package/src/auth/oauth-single.js +204 -0
- package/src/auth/subscriptions.js +19 -2
- package/src/auth/users.js +16 -7
- package/src/cli/cli.js +133 -0
- package/src/cli/d1.js +165 -0
- package/src/cli/operations.js +34 -0
- package/src/cli/project.js +560 -0
- package/src/cli/status.js +46 -0
- package/src/cli/tenants.js +16 -0
- package/src/cli/users.js +34 -0
- package/src/cli/version.js +54 -0
- package/src/core/security.js +22 -5
- package/src/runtime/worker.js +16 -3
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
|
|
94
|
-
`cf-genai
|
|
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
|
package/bin/cf-genai.js
ADDED
|
@@ -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.
|
|
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
|
}
|
package/src/admin/routes.js
CHANGED
|
@@ -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
|
@@ -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;
|
|
19
|
-
|
|
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) {
|
|
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,
|
|
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
|
|
26
|
-
|
|
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; }
|