@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.
Files changed (63) hide show
  1. package/CLI.md +121 -0
  2. package/CONTRACT.md +26 -9
  3. package/README.md +105 -22
  4. package/bin/cf-genai.js +11 -0
  5. package/changelog.md +25 -0
  6. package/migrations/0006_jobs.sql +38 -0
  7. package/package.json +27 -7
  8. package/src/admin/index.js +1 -0
  9. package/src/admin/routes.js +64 -0
  10. package/src/api/client.js +8 -0
  11. package/src/api/contracts.js +24 -0
  12. package/src/api/index.js +7 -0
  13. package/src/api/jobs.js +37 -0
  14. package/src/api/testing.js +13 -0
  15. package/src/app.js +5 -0
  16. package/src/auth/constants.js +9 -0
  17. package/src/auth/encoding.js +6 -0
  18. package/src/auth/groups.js +5 -0
  19. package/src/auth/impersonation.js +15 -0
  20. package/src/auth/index.js +8 -0
  21. package/src/auth/oauth-single.js +193 -0
  22. package/src/auth/scopes.js +32 -0
  23. package/src/auth/subscriptions.js +25 -0
  24. package/src/auth/tenants.js +34 -0
  25. package/src/auth/users.js +31 -0
  26. package/src/cli/cli.js +133 -0
  27. package/src/cli/d1.js +165 -0
  28. package/src/cli/operations.js +34 -0
  29. package/src/cli/project.js +560 -0
  30. package/src/cli/status.js +46 -0
  31. package/src/cli/tenants.js +16 -0
  32. package/src/cli/users.js +34 -0
  33. package/src/cli/version.js +54 -0
  34. package/src/{core.js → core/circuits.js} +51 -98
  35. package/src/core/constants.js +5 -0
  36. package/src/core/d1.js +54 -0
  37. package/src/core/event-hub.js +29 -0
  38. package/src/core/events.js +63 -0
  39. package/src/core/identity.js +39 -0
  40. package/src/core/index.js +7 -0
  41. package/src/core/jobs.js +185 -0
  42. package/src/core/security.js +71 -0
  43. package/src/data/context.js +16 -0
  44. package/src/data/index.js +3 -0
  45. package/src/data/reader.js +63 -0
  46. package/src/data/resources.js +42 -0
  47. package/src/index.js +10 -297
  48. package/src/repository.js +63 -0
  49. package/src/runtime/features.js +17 -0
  50. package/src/runtime/health.js +17 -0
  51. package/src/runtime/worker.js +45 -0
  52. package/src/ui/index.js +1 -87
  53. package/src/ui/react/admin-access.jsx +29 -0
  54. package/src/ui/react/admin-catalogs.jsx +39 -0
  55. package/src/ui/react/admin-shell.jsx +17 -0
  56. package/src/ui/react/foundation.jsx +40 -0
  57. package/src/ui/react/index.jsx +6 -0
  58. package/src/ui/react/jobs.jsx +63 -0
  59. package/src/ui/react/live-events.jsx +55 -0
  60. package/src/ui/styles.css +34 -0
  61. package/src/authorization.js +0 -233
  62. package/src/data.js +0 -239
  63. 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` (with `admin_token` supported for compatibility). Missing token means all admin routes return 401.
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
- - `adminPage` optionally renders the authorized platform admin browser pages so a site can keep the shared system menu and its own visual shell consistent.
20
- - `siteAdminPage` optionally renders site-owned pages below `/admin/site/*`, keeping them separate from the reserved platform page paths.
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
- and `migrations/0004_tenants.sql` for tenant membership and subscriptions.
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 `system`.
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 companion
77
- `cf-genai-cli` should lint `cf-genai-*` working folders for direct
93
+ `state.data.tenant`, and add cross-tenant isolation tests. The bundled
94
+ `cf-genai` command lints `cf-genai-*` working folders for direct
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/authorization` and
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
- Sites may provide `adminPage({ request, env, url, state, features })` to render
31
- the shared platform pages (`/admin/users`, `/admin/scopes`, `/admin/groups`,
32
- `/admin/features`, `/admin/healthchecks`, and `/admin/circuit-breakers`) inside
33
- their own shell. The callback runs after the shared authorization boundary and
34
- must return a `Response` or `null`.
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
- Sites may separately provide `siteAdminPage({ request, env, url, state,
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
- The shared `<cf-admin-shell>` accepts an optional `cookbook-links` attribute
42
- containing semicolon-separated `Label|URL|active-key` entries. This lets a site
43
- replace the default Cookbook links while keeping the System links consistent.
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 before
48
- requests, and optionally deliver server-side PostHog events. Use
49
- `assertBoot(env, { bindings: ["DB"], required: ["AUTH_SESSION_SECRET"] })` in a
50
- site initializer to fail closed when its Cloudflare configuration is incomplete.
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
- Apply `migrations/0002_core.sql` after the authorization migration. The package exports `registerHealthcheck`, `updateHealthcheck`, `registerCircuitBreaker`, `setCircuitBreaker`, and `evaluateCircuitBreaker` 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.
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
- 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`. The browser route `/admin/features` renders the same feature catalog for administrators. 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.
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.user`, or `state.data.system`;
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.
@@ -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": "4.1.3",
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
- "./authorization": "./src/authorization.js",
8
- "./data": "./src/data.js",
9
- "./core": "./src/core.js",
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/styles.css": "./src/ui/styles.css"
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/authorization.js && node --check src/data.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",
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) }); }