@agilesyndrome/cf-genai-base 4.1.3 → 5.0.1
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 +26 -9
- package/README.md +105 -22
- package/bin/cf-genai.js +11 -0
- package/changelog.md +25 -0
- package/migrations/0006_jobs.sql +38 -0
- package/package.json +27 -7
- package/src/admin/index.js +1 -0
- package/src/admin/routes.js +64 -0
- package/src/api/client.js +8 -0
- package/src/api/contracts.js +24 -0
- package/src/api/index.js +7 -0
- package/src/api/jobs.js +37 -0
- package/src/api/testing.js +13 -0
- package/src/app.js +5 -0
- package/src/auth/constants.js +9 -0
- package/src/auth/encoding.js +6 -0
- package/src/auth/groups.js +5 -0
- package/src/auth/impersonation.js +15 -0
- package/src/auth/index.js +8 -0
- package/src/auth/oauth-single.js +193 -0
- package/src/auth/scopes.js +32 -0
- package/src/auth/subscriptions.js +25 -0
- package/src/auth/tenants.js +34 -0
- package/src/auth/users.js +31 -0
- 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.js → core/circuits.js} +51 -98
- package/src/core/constants.js +5 -0
- package/src/core/d1.js +54 -0
- package/src/core/event-hub.js +29 -0
- package/src/core/events.js +63 -0
- package/src/core/identity.js +39 -0
- package/src/core/index.js +7 -0
- package/src/core/jobs.js +185 -0
- package/src/core/security.js +71 -0
- package/src/data/context.js +16 -0
- package/src/data/index.js +3 -0
- package/src/data/reader.js +63 -0
- package/src/data/resources.js +42 -0
- package/src/index.js +10 -297
- package/src/repository.js +63 -0
- package/src/runtime/features.js +17 -0
- package/src/runtime/health.js +17 -0
- package/src/runtime/worker.js +45 -0
- package/src/ui/index.js +1 -87
- package/src/ui/react/admin-access.jsx +29 -0
- package/src/ui/react/admin-catalogs.jsx +39 -0
- package/src/ui/react/admin-shell.jsx +17 -0
- package/src/ui/react/foundation.jsx +40 -0
- package/src/ui/react/index.jsx +6 -0
- package/src/ui/react/jobs.jsx +63 -0
- package/src/ui/react/live-events.jsx +55 -0
- package/src/ui/styles.css +34 -0
- package/src/authorization.js +0 -233
- package/src/data.js +0 -239
- package/src/ui/groups.js +0 -2
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
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
Every site built from this foundation follows the same edge contract.
|
|
4
4
|
|
|
5
|
+
## API, UI, and repositories
|
|
6
|
+
|
|
7
|
+
`defineRoute({ method, path, auth, scope, csrf, handler })` defines a route
|
|
8
|
+
contract. Applications pass contracts through `createWorker({ apiRoutes })`;
|
|
9
|
+
base enforces authentication, administrator status, same-origin mutation
|
|
10
|
+
rules, and scope checks before invoking the handler. `@agilesyndrome/cf-genai-base/api`
|
|
11
|
+
also exposes `apiFetch` and `apiJson` for browser clients.
|
|
12
|
+
|
|
13
|
+
`createRepositories(env, definitions)` creates named application or feature
|
|
14
|
+
repositories over the request-scoped data reader. Definitions may declare
|
|
15
|
+
relations to other repositories. Repositories must not expose raw D1 or accept
|
|
16
|
+
unvalidated table, column, or SQL fragments from callers.
|
|
17
|
+
|
|
5
18
|
## Worker entrypoint
|
|
6
19
|
|
|
7
20
|
`createWorker({ fetch, features?, middleware?, auth?, authorize?, scheduled?, security? })` owns the Worker lifecycle and reserved admin boundary. Features run in declaration order and may call `next()` or return a response. A feature may also declare `{ routes: [{ match, handle }] }`; matching handlers receive `{ request, env, ctx, state, next }` and run before the site handler. The site router owns pages, APIs, D1 queries, and R2 object keys. `scheduled`
|
|
@@ -13,14 +26,15 @@ is optional and must use `ctx.waitUntil` for background work.
|
|
|
13
26
|
- `GET /api/me` returns `{ user: null | { sub, email, name, ...roles } }`.
|
|
14
27
|
- `/auth/login`, `/auth/callback`, and `/auth/logout` are reserved for auth.
|
|
15
28
|
- `/admin` and `/admin/*` are browser admin routes; `/api/admin` and `/api/admin/*` are admin API routes.
|
|
16
|
-
- Admin routes use `AUTH_STRATEGY`; omitted or empty means `http_basic`. Basic auth accepts username `admin` and the value of `ADMIN_TOKEN
|
|
29
|
+
- Admin routes use `AUTH_STRATEGY`; omitted or empty means `http_basic`. Basic auth accepts username `admin` and the value of `ADMIN_TOKEN`. Missing token means all admin routes return 401.
|
|
17
30
|
- `AUTH_STRATEGY=oauth` delegates identity establishment to the configured auth provider and uses `authorize` for admin policy.
|
|
18
31
|
- `scopes` registers an application scope manifest. `scopeRoutes` associates route prefixes or match functions with required scopes.
|
|
19
|
-
-
|
|
20
|
-
- `
|
|
21
|
-
- Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/features`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. It also provides short-lived `/api/admin/users/:id/impersonate` and `/api/admin/impersonate/clear` controls. `GET /api/tenant` returns the authenticated active tenant and validated memberships; invalid `X-Tenant-ID` values return 400. `GET /api/admin/features` returns the installed runtime feature manifests, package names and versions, per-feature health rollups, healthchecks, and circuit breakers. The browser route `/admin/features` renders that catalog. Feature manifests may provide `name`, `displayName`, `packageName`, and `version`. The exported UI includes users, scopes, groups, healthchecks, and circuit-breaker catalogs.
|
|
32
|
+
- Base protects `/admin` and `/api/admin`; browser pages are React applications that consume the JSON admin APIs. Import `AdminShell` and the platform catalogs from `@agilesyndrome/cf-genai-base/ui` and apply the application's theme around them.
|
|
33
|
+
- Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/features`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. It also provides short-lived `/api/admin/users/:id/impersonate` and `/api/admin/impersonate/clear` controls. `GET /api/tenant` returns the authenticated active tenant and validated memberships; invalid `X-Tenant-ID` values return 400. `GET /api/admin/features` returns the installed runtime feature manifests, package names and versions, per-feature health rollups, healthchecks, and circuit breakers. React platform components consume these JSON APIs. Feature manifests may provide `name`, `displayName`, `packageName`, and `version`.
|
|
22
34
|
- Public APIs must be explicitly listed in provider-specific auth configuration.
|
|
23
35
|
- Mutating `/api/*` requests require a same-origin `Origin` header.
|
|
36
|
+
- `createWorker` supplies request-scoped `data`, `user`, `authUser`, `userId`, `context`, `event`, and audited D1 access to route handlers. `Event(who, what, where, when, details)` creates normalized events; installed features may consume them through `eventHandler`.
|
|
37
|
+
- `migrations/0006_jobs.sql` adds generic durable jobs and job events. Features create and update jobs with the exported lifecycle helpers; `GET /api/jobs`, `GET /api/jobs/:id`, and `GET /api/jobs/:id/events` expose only the authenticated owner's records. `GET /api/events` is an optional authenticated WebSocket stream backed by the configured `EVENT_HUB` Durable Object.
|
|
24
38
|
|
|
25
39
|
## Environment and bindings
|
|
26
40
|
|
|
@@ -40,8 +54,9 @@ Standard bindings:
|
|
|
40
54
|
Build metadata is optional: `BUILD_SHA` and `BUILD_NUMBER`.
|
|
41
55
|
|
|
42
56
|
The package includes ordered migrations. Each site must apply
|
|
43
|
-
`migrations/0001_authorization.sql` before enabling the generic user/scope APIs
|
|
44
|
-
|
|
57
|
+
`migrations/0001_authorization.sql` before enabling the generic user/scope APIs,
|
|
58
|
+
`migrations/0004_tenants.sql` for tenant membership and subscriptions, and
|
|
59
|
+
`migrations/0006_jobs.sql` before creating or reading durable jobs.
|
|
45
60
|
The tenant migration seeds the `Easley Family` tenant and `VIP` subscription,
|
|
46
61
|
and migrates existing authorization users into that tenant.
|
|
47
62
|
|
|
@@ -59,7 +74,9 @@ must remain in the application router rather than in the shared auth package.
|
|
|
59
74
|
|
|
60
75
|
`createWorker` accepts `dataResources`, and features may expose the same
|
|
61
76
|
manifest through `feature.dataResources`. Each resource must declare a safe
|
|
62
|
-
name, table, explicit columns, and one scope: `user`, `tenant`, or
|
|
77
|
+
name, table, explicit columns, and one scope: `user`, `tenant`, `public`, or
|
|
78
|
+
`system`. Public resources are read-only and predicate on the worker's
|
|
79
|
+
`publicTenantId`, including for authenticated users.
|
|
63
80
|
Resources may also declare allowed operations (`read`, `create`, `update`, and
|
|
64
81
|
`delete`); reads support bounded native pagination through
|
|
65
82
|
`reader.page({ limit, offset })` and `reader.count()`, plus safe filtered
|
|
@@ -73,8 +90,8 @@ them explicitly. Base cannot provide row-level security to
|
|
|
73
90
|
direct D1 calls, so applications must keep raw database access out of domain
|
|
74
91
|
features. The cookbook migration must add and backfill `tenant_id` on recipe
|
|
75
92
|
tables, register recipes as tenant-scoped, replace direct D1 reads/writes with
|
|
76
|
-
`state.data.tenant`, and add cross-tenant isolation tests. The
|
|
77
|
-
`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
|
|
78
95
|
`env.DB.prepare(` usage as a follow-up enforcement check.
|
|
79
96
|
Scoped write violations are returned as a generic 403 response; the detailed
|
|
80
97
|
scope/resource identity is retained in the audit log only.
|
package/README.md
CHANGED
|
@@ -2,12 +2,16 @@
|
|
|
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.
|
|
8
12
|
|
|
9
13
|
Base also provides provider-neutral authorization helpers and browser components
|
|
10
|
-
through `@agilesyndrome/cf-genai-base/
|
|
14
|
+
through `@agilesyndrome/cf-genai-base/auth` and
|
|
11
15
|
`@agilesyndrome/cf-genai-base/ui`. Applications declare their scope manifest,
|
|
12
16
|
while base owns the user, scope, and grant records plus the generic user-access
|
|
13
17
|
API. The UI components are themeable with CSS custom properties and do not
|
|
@@ -27,34 +31,76 @@ export default createWorker({
|
|
|
27
31
|
|
|
28
32
|
Features expose `middleware(request, env, ctx, next, state)` and may short-circuit reserved routes, attach request state, or call `next()`.
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
Base protects `/admin` and `/api/admin`; the React UI package owns the browser
|
|
35
|
+
pages. Import `AdminShell` and the platform catalogs from
|
|
36
|
+
`@agilesyndrome/cf-genai-base/ui`. Sites provide their own application links and
|
|
37
|
+
theme while the base components consume the shared JSON admin APIs.
|
|
38
|
+
|
|
39
|
+
## Command-line tools
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
features })` for a `/admin/site/*` namespace. This is useful when a site wants
|
|
38
|
-
its own admin pages to have an explicit boundary beside the shared platform
|
|
39
|
-
pages.
|
|
41
|
+
Install base in a project (or globally) and use the bundled executable:
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
44
83
|
|
|
45
84
|
## Shared platform helpers
|
|
46
85
|
|
|
47
|
-
`createWorker` can own `/health` and `/api/health`, run a boot validator
|
|
48
|
-
requests
|
|
49
|
-
|
|
50
|
-
|
|
86
|
+
`createWorker` can own `/health` and `/api/health`, and run a boot validator
|
|
87
|
+
before requests. Use `assertBoot(env, { bindings: ["DB"], required:
|
|
88
|
+
["AUTH_SESSION_SECRET"] })` in a site initializer to fail closed when its
|
|
89
|
+
Cloudflare configuration is incomplete.
|
|
51
90
|
|
|
52
91
|
|
|
53
92
|
## Core operational services
|
|
54
93
|
|
|
55
|
-
|
|
94
|
+
The package is organized by responsibility: `runtime` composes Workers,
|
|
95
|
+
`core` owns request/event/security/D1 primitives, `auth` owns authorization
|
|
96
|
+
data access, `api` owns route contracts and the browser client, `admin` owns
|
|
97
|
+
platform administration, and `ui` owns shared browser components. Each
|
|
98
|
+
responsibility has a canonical folder entrypoint; import from `core`, `auth`,
|
|
99
|
+
`data`, `api`, `admin`, or `ui` as appropriate.
|
|
56
100
|
|
|
57
|
-
|
|
101
|
+
Apply `migrations/0002_core.sql` and `migrations/0006_jobs.sql` after the authorization migration. The package exports `registerHealthcheck`, `updateHealthcheck`, `registerCircuitBreaker`, `setCircuitBreaker`, `evaluateCircuitBreaker`, and generic job lifecycle helpers from `/cf-genai-base`. Healthchecks use `red`, `yellow` (unknown/transient), or `green`; breakers use `off`, `tripped`, or `on`, with `any` or `all` healthcheck evaluation. Automated evaluation may only move `on` to `tripped`, or self-healing `tripped` to `on`; admin API writes are the human control plane for the `off` state.
|
|
102
|
+
|
|
103
|
+
Admin APIs are `GET /api/admin/healthchecks`, `PUT /api/admin/healthchecks/:id`, `GET /api/admin/circuit-breakers`, `GET|PUT /api/admin/circuit-breakers/:id`, and `GET /api/admin/features`. React platform components consume these JSON responses. The catalog lists each installed runtime feature, its `packageName` and `version`, its most severe healthcheck state, all feature healthchecks, and its circuit breakers (including the feature roll-up breaker). Feature manifests may expose `healthchecks` and `circuitBreakers`; add `displayName`, `packageName`, and `version` to make the installation identity explicit. Use `createD1(env, { who })` for downstream D1 calls; it emits EventLog and AuditLog console records with the requesting actor.
|
|
58
104
|
|
|
59
105
|
|
|
60
106
|
## User administration
|
|
@@ -76,8 +122,9 @@ Use the selected D1 target (local by default) to inspect and update users:
|
|
|
76
122
|
|
|
77
123
|
Features may register D1 resources with `dataResources` and receive the
|
|
78
124
|
scoped reader on the request state as `state.data`. Resources declare `user`,
|
|
79
|
-
`tenant`, or `system` scope, their physical table, and an explicit column
|
|
80
|
-
allowlist. Use `state.data.tenant`, `state.data.
|
|
125
|
+
`tenant`, `public`, or `system` scope, their physical table, and an explicit column
|
|
126
|
+
allowlist. Use `state.data.tenant`, `state.data.public`, `state.data.user`, or
|
|
127
|
+
`state.data.system`;
|
|
81
128
|
the reader applies ownership predicates, supports bounded native pagination via
|
|
82
129
|
page with limit/offset, count, and safe bulk updateWhere/deleteWhere
|
|
83
130
|
operations, and never accepts raw SQL. Resources can explicitly restrict
|
|
@@ -87,7 +134,9 @@ Anonymous tenant reads require both publicTenantId on createWorker and a
|
|
|
87
134
|
resource-level publicRead declaration. Use publicRead true only when the
|
|
88
135
|
whole resource is public; for opt-in rows use a publicRead column/value
|
|
89
136
|
declaration such as visibility=public. Anonymous reads never grant anonymous
|
|
90
|
-
system access.
|
|
137
|
+
system access. A `public` resource is read-only and always predicates on the
|
|
138
|
+
worker's `publicTenantId`, including authenticated users; use it for shared
|
|
139
|
+
catalog data such as GTA's `gta-public` tenant.
|
|
91
140
|
|
|
92
141
|
Applications may pass subscriptionManifest to createWorker to register their
|
|
93
142
|
own subscription IDs and entitlement values. Base exposes
|
|
@@ -108,3 +157,37 @@ resource used with the wrong scope returns no rows; writes fail closed.
|
|
|
108
157
|
Applications using scoped data must stop passing unrestricted `env.DB` to
|
|
109
158
|
domain features. Their migrations still add and backfill ownership columns,
|
|
110
159
|
and their resources must be registered with base.
|
|
160
|
+
|
|
161
|
+
Request handlers receive a request-scoped environment containing `data`,
|
|
162
|
+
`user`, `authUser`, `userId`, `context`, `event`, and an audited D1 binding.
|
|
163
|
+
Call `await env.event("thing.happened", "domain", details)` to emit a
|
|
164
|
+
normalized event. Features may provide `eventHandler(event, { env, ctx })`;
|
|
165
|
+
this is the extension point for feature integrations.
|
|
166
|
+
|
|
167
|
+
Long-running features can call `dispatchJob` with a Cloudflare Workflow binding;
|
|
168
|
+
base creates the durable record first and passes its ID to the Workflow as
|
|
169
|
+
`params.jobId`. Workflow code calls `executeJob`, which supplies a progress
|
|
170
|
+
reporter and completes or fails the record. `runJob` is the same lifecycle for
|
|
171
|
+
work already executing in the current invocation. Lower-level features may call
|
|
172
|
+
`createJob`, `startJob`, `updateJobProgress`, `completeJob`, `failJob`, and
|
|
173
|
+
`cancelJob` directly. Every lifecycle change is stored in `core_job_events` and emitted
|
|
174
|
+
to the owning user's live event room. Configure the optional live transport by
|
|
175
|
+
exporting `EventHub` from `@agilesyndrome/cf-genai-base/event-hub` and binding
|
|
176
|
+
an `EVENT_HUB` Durable Object in the application Worker. The React package's
|
|
177
|
+
`LiveEventsProvider`, `useJob`, `useJobs`, and `JobNotificationList` handle
|
|
178
|
+
reconnects and refreshes; the feature remains responsible for its own Workflow,
|
|
179
|
+
job type, executor, and result UI.
|
|
180
|
+
|
|
181
|
+
The exported `Event`, `emitEvent`, `requestContext`, `userId`, `sameOrigin`,
|
|
182
|
+
`readJson`, `secureJson`, `featureCircuit`, and `requireFeatureCircuit` helpers
|
|
183
|
+
are the shared identity, request, security, and feature-gating contracts.
|
|
184
|
+
|
|
185
|
+
The layered web surface is React-first: `/api` exports route contracts, scoped
|
|
186
|
+
repositories, the browser `apiFetch`/`apiJson` client, and job/event endpoints;
|
|
187
|
+
`/ui` exports React admin primitives, live event hooks, and durable job
|
|
188
|
+
notifications. Repositories are registered with
|
|
189
|
+
`createWorker({ repositories })`, can be supplied by applications or features,
|
|
190
|
+
and may declare links to other repositories while remaining behind the scoped
|
|
191
|
+
data reader. `GET /api/jobs` and `GET /api/jobs/:id` expose an authenticated
|
|
192
|
+
user's durable job records. `GET /api/events` upgrades to the authenticated live
|
|
193
|
+
event stream when the optional `EVENT_HUB` Durable Object binding is configured.
|
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
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
13
|
+
## 5.0.0
|
|
14
|
+
|
|
15
|
+
- Replace `@agilesyndrome/cf-genai-base/authorization` imports with `/auth`.
|
|
16
|
+
- Replace direct `src/core.js` imports with `src/core/index.js` or the package `/core` entrypoint.
|
|
17
|
+
- Replace direct `src/data.js` imports with `src/data/index.js` or the package `/data` entrypoint.
|
|
18
|
+
- Replace direct `src/api/router.js` imports with `src/api/contracts.js` for route definitions and dispatch.
|
|
19
|
+
- Update internal imports to the canonical folder modules; the old facade files are removed.
|
|
20
|
+
- Update package consumers and lockfiles to version `5.0.0`.
|
|
21
|
+
- Replace server-rendered admin HTML and custom elements with the React UI entrypoint; apps own the shell and theme around base's admin components.
|
|
22
|
+
- Apply `migrations/0006_jobs.sql`; features now use generic durable job helpers and `GET /api/jobs` instead of inventing job tables and status endpoints.
|
|
23
|
+
- Configure an `EVENT_HUB` Durable Object and export `EventHub` from `@agilesyndrome/cf-genai-base/event-hub` to enable authenticated live job events over WebSockets.
|
|
24
|
+
- Dispatch long-running work through a Cloudflare Workflow with `dispatchJob`; execute its durable lifecycle with `executeJob` instead of relying on request-lifetime background work.
|
|
25
|
+
- Remove the lowercase `admin_token` environment alias; use `ADMIN_TOKEN` only.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS core_jobs (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
type TEXT NOT NULL,
|
|
4
|
+
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
|
|
5
|
+
owner_id TEXT,
|
|
6
|
+
tenant_id TEXT,
|
|
7
|
+
resource_type TEXT,
|
|
8
|
+
resource_id TEXT,
|
|
9
|
+
input_json TEXT NOT NULL DEFAULT '{}',
|
|
10
|
+
result_json TEXT NOT NULL DEFAULT '{}',
|
|
11
|
+
error_json TEXT,
|
|
12
|
+
progress_json TEXT NOT NULL DEFAULT '{}',
|
|
13
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
14
|
+
started_at TEXT,
|
|
15
|
+
finished_at TEXT,
|
|
16
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
17
|
+
expires_at TEXT
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX IF NOT EXISTS core_jobs_owner_status_idx
|
|
21
|
+
ON core_jobs(owner_id, status, updated_at DESC);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS core_jobs_type_status_idx
|
|
24
|
+
ON core_jobs(type, status, updated_at DESC);
|
|
25
|
+
|
|
26
|
+
CREATE INDEX IF NOT EXISTS core_jobs_resource_idx
|
|
27
|
+
ON core_jobs(resource_type, resource_id, updated_at DESC);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS core_job_events (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
job_id TEXT NOT NULL REFERENCES core_jobs(id) ON DELETE CASCADE,
|
|
32
|
+
type TEXT NOT NULL,
|
|
33
|
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
34
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE INDEX IF NOT EXISTS core_job_events_job_created_idx
|
|
38
|
+
ON core_job_events(job_id, created_at DESC);
|
package/package.json
CHANGED
|
@@ -1,25 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilesyndrome/cf-genai-base",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"cf-genai": "bin/cf-genai.js"
|
|
7
|
+
},
|
|
5
8
|
"exports": {
|
|
6
9
|
".": "./src/index.js",
|
|
7
|
-
"./
|
|
8
|
-
"./data": "./src/data.js",
|
|
9
|
-
"./
|
|
10
|
+
"./auth": "./src/auth/index.js",
|
|
11
|
+
"./data": "./src/data/index.js",
|
|
12
|
+
"./api": "./src/api/index.js",
|
|
13
|
+
"./event-hub": "./src/core/event-hub.js",
|
|
14
|
+
"./repository": "./src/repository.js",
|
|
15
|
+
"./app": "./src/app.js",
|
|
16
|
+
"./admin": "./src/admin/index.js",
|
|
17
|
+
"./core": "./src/core/index.js",
|
|
10
18
|
"./ui": "./src/ui/index.js",
|
|
11
|
-
"./ui/
|
|
19
|
+
"./ui/react": "./src/ui/react/index.jsx",
|
|
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"
|
|
12
27
|
},
|
|
13
|
-
"description": "Lean Worker lifecycle and security helpers for Cloudflare sites.",
|
|
14
28
|
"license": "MIT",
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": "^19.0.0"
|
|
31
|
+
},
|
|
15
32
|
"publishConfig": {
|
|
16
33
|
"access": "public",
|
|
17
34
|
"provenance": true
|
|
18
35
|
},
|
|
19
36
|
"files": [
|
|
37
|
+
"bin",
|
|
20
38
|
"src",
|
|
21
39
|
"migrations",
|
|
22
40
|
"README.md",
|
|
41
|
+
"CLI.md",
|
|
42
|
+
"changelog.md",
|
|
23
43
|
"CONTRACT.md",
|
|
24
44
|
"LICENSE"
|
|
25
45
|
],
|
|
@@ -29,7 +49,7 @@
|
|
|
29
49
|
},
|
|
30
50
|
"homepage": "https://github.com/agilesyndrome/cf-genai-base#readme",
|
|
31
51
|
"scripts": {
|
|
32
|
-
"check": "node --check src/index.js && node --check src/core.js && node --check src/
|
|
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",
|
|
33
53
|
"test": "node --test tests/*.test.mjs",
|
|
34
54
|
"build": "npm run check && npm test && npm pack --dry-run"
|
|
35
55
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./routes.js";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createAuthorizationTenant, createImpersonationToken, ensureScopes, ensureUser, getAuthorizationTenant, getAuthorizationUser, hasScope, listAuthorizationScopes, listAuthorizationTenants, listAuthorizationUsers, listGroups, listUserGroups, listUserGrants, listUserTenants, replaceUserGroups, replaceUserTenants, replaceUserGrants, updateAuthorizationTenant } from "../auth/index.js";
|
|
2
|
+
import { getCircuitBreaker, listCircuitBreakers, listFeatureCatalog, listFeatureHealth, listHealthchecks, requestActor, setCircuitBreaker, updateHealthcheck } from "../core/index.js";
|
|
3
|
+
|
|
4
|
+
export async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }) {
|
|
5
|
+
const url = new URL(request.url);
|
|
6
|
+
if (!isAdminPath(url.pathname)) return next(request);
|
|
7
|
+
const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
|
|
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; }
|
|
10
|
+
else return new Response("Unsupported AUTH_STRATEGY", { status: 500, headers: { "Cache-Control": "no-store" } });
|
|
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
|
+
const who = state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}`;
|
|
13
|
+
await ensureScopes(env, scopes, { who });
|
|
14
|
+
state.authUser = state.user?.authUser || await ensureUser(env, state.user, { who }); state.requestedBy = requestActor(state);
|
|
15
|
+
const requiredScope = requiredScopeFor(url.pathname, scopeRoutes); const scopeAllowed = !requiredScope || await hasScope(env, state.user, requiredScope, { who: requestActor(state) });
|
|
16
|
+
if (!scopeAllowed || (authorize && state.user.auth_strategy !== "http_basic" && !(await authorize({ request, url, user: state.user, env, ctx, state })))) return url.pathname.startsWith("/api/") ? Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }) : new Response("Administrator access is required.", { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
17
|
+
const platformResponse = await authorizationApi(request, env, url, state, features); if (platformResponse) return platformResponse;
|
|
18
|
+
return next(request);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function authorizationApi(request, env, url, state, features = []) {
|
|
22
|
+
const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
|
|
23
|
+
const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/tenants" || url.pathname.startsWith("/api/admin/tenants/") || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/groups" || url.pathname.startsWith("/api/admin/users/") || url.pathname.startsWith("/api/admin/impersonate") || url.pathname === "/api/admin/status" || url.pathname === "/api/admin/features" || url.pathname === "/api/admin/healthchecks" || url.pathname === "/api/admin/circuit-breakers" || url.pathname.startsWith("/api/admin/healthchecks/") || url.pathname.startsWith("/api/admin/circuit-breakers/") || Boolean(grantsMatch);
|
|
24
|
+
if (!platformPath) return null;
|
|
25
|
+
if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
26
|
+
const who = { who: requestActor(state) };
|
|
27
|
+
if (url.pathname === "/api/admin/impersonate/clear" && request.method === "POST") return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json; charset=utf-8", "Set-Cookie": "__Host-cfgenai_impersonation=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax" } });
|
|
28
|
+
if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, who) });
|
|
29
|
+
if (url.pathname === "/api/admin/tenants" && request.method === "GET") return Response.json({ tenants: await listAuthorizationTenants(env, who) });
|
|
30
|
+
if (url.pathname === "/api/admin/tenants" && request.method === "POST") { const body = await request.json().catch(() => null); if (!body?.id || !body?.name) return Response.json({ error: "id and name are required" }, { status: 400 }); try { return Response.json({ tenant: await createAuthorizationTenant(env, body.id, body.name, who) }, { status: 201 }); } catch (error) { return Response.json({ error: error.message }, { status: 400 }); } }
|
|
31
|
+
const tenantMatch = url.pathname.match(/^\/api\/admin\/tenants\/([^/]+)$/);
|
|
32
|
+
if (tenantMatch && request.method === "GET") return Response.json({ tenant: await getAuthorizationTenant(env, decodeURIComponent(tenantMatch[1]), who) });
|
|
33
|
+
if (tenantMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body?.name) return Response.json({ error: "name is required" }, { status: 400 }); return Response.json({ tenant: await updateAuthorizationTenant(env, decodeURIComponent(tenantMatch[1]), body.name, who) }); }
|
|
34
|
+
const impersonateMatch = url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/impersonate$/);
|
|
35
|
+
if (impersonateMatch && request.method === "POST") { const targetUser = await getAuthorizationUser(env, decodeURIComponent(impersonateMatch[1]), who); if (!targetUser) return Response.json({ error: "User not found." }, { status: 404 }); const token = await createImpersonationToken(env, state.authUser?.id || state.user?.sub || "admin", targetUser.id); return new Response(JSON.stringify({ ok: true, user: { id: targetUser.id, email: targetUser.email, display_name: targetUser.display_name }, expires_in: 900 }), { headers: { "content-type": "application/json; charset=utf-8", "Set-Cookie": `__Host-cfgenai_impersonation=${token}; Max-Age=900; Path=/; Secure; HttpOnly; SameSite=Lax` } }); }
|
|
36
|
+
if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env, who) });
|
|
37
|
+
if (url.pathname === "/api/admin/status" && request.method === "GET") return Response.json({ features: await listFeatureHealth(env, who) });
|
|
38
|
+
if (url.pathname === "/api/admin/features" && request.method === "GET") return Response.json({ features: await listFeatureCatalog(env, features, who) });
|
|
39
|
+
if (url.pathname === "/api/admin/groups" && request.method === "GET") return Response.json({ groups: await listGroups(env, who) });
|
|
40
|
+
if (url.pathname === "/api/admin/healthchecks" && request.method === "GET") return Response.json({ healthchecks: await listHealthchecks(env, who) });
|
|
41
|
+
if (url.pathname === "/api/admin/circuit-breakers" && request.method === "GET") return Response.json({ circuit_breakers: await listCircuitBreakers(env, who) });
|
|
42
|
+
const healthcheckMatch = url.pathname.match(/\/api\/admin\/healthchecks\/([^/]+)$/);
|
|
43
|
+
if (healthcheckMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body?.state) return Response.json({ error: "state is required" }, { status: 400 }); const healthcheck = await updateHealthcheck(env, decodeURIComponent(healthcheckMatch[1]), body.state, who); return healthcheck ? Response.json({ healthcheck }) : Response.json({ error: "Healthcheck not found" }, { status: 404 }); }
|
|
44
|
+
const breakerMatch = url.pathname.match(/\/api\/admin\/circuit-breakers\/([^/]+)$/);
|
|
45
|
+
if (breakerMatch && request.method === "GET") return Response.json({ circuit_breaker: await getCircuitBreaker(env, decodeURIComponent(breakerMatch[1]), who) });
|
|
46
|
+
if (breakerMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body?.state) return Response.json({ error: "state is required" }, { status: 400 }); const breaker = await setCircuitBreaker(env, decodeURIComponent(breakerMatch[1]), body.state, who); return breaker ? Response.json({ circuit_breaker: breaker }) : Response.json({ error: "Circuit breaker not found" }, { status: 404 }); }
|
|
47
|
+
const groupsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/groups$/);
|
|
48
|
+
if (groupsMatch && request.method === "GET") return Response.json({ groups: await listUserGroups(env, decodeURIComponent(groupsMatch[1]), who) });
|
|
49
|
+
if (groupsMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.groups)) return Response.json({ error: "groups must be an array" }, { status: 400 }); return Response.json({ groups: await replaceUserGroups(env, decodeURIComponent(groupsMatch[1]), body.groups, state.authUser && state.authUser.id, who) }); }
|
|
50
|
+
const tenantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/tenants$/);
|
|
51
|
+
if (tenantsMatch && request.method === "GET") return Response.json({ tenants: await listUserTenants(env, decodeURIComponent(tenantsMatch[1]), who) });
|
|
52
|
+
if (tenantsMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.tenants)) return Response.json({ error: "tenants must be an array" }, { status: 400 }); return Response.json({ tenants: await replaceUserTenants(env, decodeURIComponent(tenantsMatch[1]), body.tenants, who) }); }
|
|
53
|
+
if (grantsMatch && request.method === "GET") return Response.json({ grants: await listUserGrants(env, decodeURIComponent(grantsMatch[1]), who) });
|
|
54
|
+
if (grantsMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.scopes)) return Response.json({ error: "scopes must be an array" }, { status: 400 }); const grants = await replaceUserGrants(env, decodeURIComponent(grantsMatch[1]), body.scopes, state.authUser && state.authUser.id, who); return Response.json({ grants }); }
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isAdminPath(pathname) { return pathname === "/admin" || pathname.startsWith("/admin/") || pathname === "/api/admin" || pathname.startsWith("/api/admin/"); }
|
|
59
|
+
function requiredScopeFor(pathname, routes) { const route = routes.find((entry) => typeof entry.match === "function" ? entry.match(pathname) : pathname === entry.path || pathname.startsWith(String(entry.path || "") + "/")); return route && route.scope ? route.scope : null; }
|
|
60
|
+
function basicUser(request, env) { const token = String(env?.ADMIN_TOKEN || ""); if (!token) return null; const header = request.headers.get("Authorization") || ""; if (!header.toLowerCase().startsWith("basic ")) return null; let decoded; try { decoded = atob(header.slice(6).trim()); } catch { return null; } const separator = decoded.indexOf(":"); if (separator < 0 || !constantTimeEqual(decoded.slice(0, separator), "admin") || !constantTimeEqual(decoded.slice(separator + 1), token)) return null; return { sub: "basic:admin", email: "", name: "admin", roles: ["admin"], auth_strategy: "http_basic" }; }
|
|
61
|
+
function adminUnauthorized(request) { const headers = { "Cache-Control": "no-store", "WWW-Authenticate": "Basic realm=\"admin\", charset=\"UTF-8\"" }; return new URL(request.url).pathname.startsWith("/api/") ? Response.json({ error: "Authentication is required." }, { status: 401, headers }) : new Response("Authentication is required.", { status: 401, headers }); }
|
|
62
|
+
function oauthUnauthorized(request, url) { if (url.pathname.startsWith("/api/")) return Response.json({ error: "Authentication is required." }, { status: 401, headers: { "Cache-Control": "no-store" } }); return Response.redirect(url.origin + "/auth/login?return_to=" + encodeURIComponent(safeReturnTo(url.pathname + url.search)), 302); }
|
|
63
|
+
function safeReturnTo(value) { return value?.startsWith("/") && !value.startsWith("//") && !value.startsWith("/auth/") ? value : "/"; }
|
|
64
|
+
function constantTimeEqual(a, b) { const aa = new TextEncoder().encode(a), bb = new TextEncoder().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; }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export async function apiFetch(input, options = {}) {
|
|
2
|
+
const response = await fetch(input, { credentials: "same-origin", headers: { Accept: "application/json", ...(options.body ? { "Content-Type": "application/json" } : {}), ...(options.headers || {}) }, ...options });
|
|
3
|
+
const payload = await response.clone().json().catch(() => null);
|
|
4
|
+
if (!response.ok) { const error = new Error(payload?.error || `Request failed (${response.status})`); error.status = response.status; error.requestId = response.headers.get("X-Request-ID") || payload?.request_id || null; throw error; }
|
|
5
|
+
return payload === null ? response : payload;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function apiJson(input, payload, options = {}) { return apiFetch(input, { ...options, method: options.method || "POST", body: JSON.stringify(payload) }); }
|