@agilesyndrome/cf-genai-base 4.1.2 → 5.0.0
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/CONTRACT.md +24 -7
- package/README.md +59 -25
- package/changelog.md +15 -0
- package/migrations/0006_jobs.sql +38 -0
- package/package.json +15 -5
- 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 +7 -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/{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 -289
- 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 -188
- package/src/data.js +0 -239
- package/src/ui/groups.js +0 -2
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
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Use D1 bindings for durable application data and R2 bindings for binary assets;
|
|
|
7
7
|
do not put either into module-level state.
|
|
8
8
|
|
|
9
9
|
Base also provides provider-neutral authorization helpers and browser components
|
|
10
|
-
through `@agilesyndrome/cf-genai-base/
|
|
10
|
+
through `@agilesyndrome/cf-genai-base/auth` and
|
|
11
11
|
`@agilesyndrome/cf-genai-base/ui`. Applications declare their scope manifest,
|
|
12
12
|
while base owns the user, scope, and grant records plus the generic user-access
|
|
13
13
|
API. The UI components are themeable with CSS custom properties and do not
|
|
@@ -27,34 +27,31 @@ export default createWorker({
|
|
|
27
27
|
|
|
28
28
|
Features expose `middleware(request, env, ctx, next, state)` and may short-circuit reserved routes, attach request state, or call `next()`.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
must return a `Response` or `null`.
|
|
35
|
-
|
|
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.
|
|
40
|
-
|
|
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.
|
|
30
|
+
Base protects `/admin` and `/api/admin`; the React UI package owns the browser
|
|
31
|
+
pages. Import `AdminShell` and the platform catalogs from
|
|
32
|
+
`@agilesyndrome/cf-genai-base/ui`. Sites provide their own application links and
|
|
33
|
+
theme while the base components consume the shared JSON admin APIs.
|
|
44
34
|
|
|
45
35
|
## Shared platform helpers
|
|
46
36
|
|
|
47
|
-
`createWorker` can own `/health` and `/api/health`, run a boot validator
|
|
48
|
-
requests
|
|
49
|
-
|
|
50
|
-
|
|
37
|
+
`createWorker` can own `/health` and `/api/health`, and run a boot validator
|
|
38
|
+
before requests. Use `assertBoot(env, { bindings: ["DB"], required:
|
|
39
|
+
["AUTH_SESSION_SECRET"] })` in a site initializer to fail closed when its
|
|
40
|
+
Cloudflare configuration is incomplete.
|
|
51
41
|
|
|
52
42
|
|
|
53
43
|
## Core operational services
|
|
54
44
|
|
|
55
|
-
|
|
45
|
+
The package is organized by responsibility: `runtime` composes Workers,
|
|
46
|
+
`core` owns request/event/security/D1 primitives, `auth` owns authorization
|
|
47
|
+
data access, `api` owns route contracts and the browser client, `admin` owns
|
|
48
|
+
platform administration, and `ui` owns shared browser components. Each
|
|
49
|
+
responsibility has a canonical folder entrypoint; import from `core`, `auth`,
|
|
50
|
+
`data`, `api`, `admin`, or `ui` as appropriate.
|
|
56
51
|
|
|
57
|
-
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
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
55
|
|
|
59
56
|
|
|
60
57
|
## User administration
|
|
@@ -70,14 +67,15 @@ Use the selected D1 target (local by default) to inspect and update users:
|
|
|
70
67
|
cf-genai user get someone.com --target staging
|
|
71
68
|
cf-genai user update someone.com --roles admin --target production
|
|
72
69
|
|
|
73
|
-
`user:get` also reports scopes and
|
|
70
|
+
`user:get` also reports scopes, groups, and tenant memberships. The shared admin user page displays each user’s tenant memberships and lets an administrator attach or detach tenants. The admin API exposes `GET|POST /api/admin/tenants`, `GET|PUT /api/admin/tenants/:id`, and `GET|PUT /api/admin/users/:id/tenants`. Production commands should be run through the repository credentials wrapper and reviewed as an administrative change.
|
|
74
71
|
|
|
75
72
|
## Scoped data access
|
|
76
73
|
|
|
77
74
|
Features may register D1 resources with `dataResources` and receive the
|
|
78
75
|
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.
|
|
76
|
+
`tenant`, `public`, or `system` scope, their physical table, and an explicit column
|
|
77
|
+
allowlist. Use `state.data.tenant`, `state.data.public`, `state.data.user`, or
|
|
78
|
+
`state.data.system`;
|
|
81
79
|
the reader applies ownership predicates, supports bounded native pagination via
|
|
82
80
|
page with limit/offset, count, and safe bulk updateWhere/deleteWhere
|
|
83
81
|
operations, and never accepts raw SQL. Resources can explicitly restrict
|
|
@@ -87,7 +85,9 @@ Anonymous tenant reads require both publicTenantId on createWorker and a
|
|
|
87
85
|
resource-level publicRead declaration. Use publicRead true only when the
|
|
88
86
|
whole resource is public; for opt-in rows use a publicRead column/value
|
|
89
87
|
declaration such as visibility=public. Anonymous reads never grant anonymous
|
|
90
|
-
system access.
|
|
88
|
+
system access. A `public` resource is read-only and always predicates on the
|
|
89
|
+
worker's `publicTenantId`, including authenticated users; use it for shared
|
|
90
|
+
catalog data such as GTA's `gta-public` tenant.
|
|
91
91
|
|
|
92
92
|
Applications may pass subscriptionManifest to createWorker to register their
|
|
93
93
|
own subscription IDs and entitlement values. Base exposes
|
|
@@ -108,3 +108,37 @@ resource used with the wrong scope returns no rows; writes fail closed.
|
|
|
108
108
|
Applications using scoped data must stop passing unrestricted `env.DB` to
|
|
109
109
|
domain features. Their migrations still add and backfill ownership columns,
|
|
110
110
|
and their resources must be registered with base.
|
|
111
|
+
|
|
112
|
+
Request handlers receive a request-scoped environment containing `data`,
|
|
113
|
+
`user`, `authUser`, `userId`, `context`, `event`, and an audited D1 binding.
|
|
114
|
+
Call `await env.event("thing.happened", "domain", details)` to emit a
|
|
115
|
+
normalized event. Features may provide `eventHandler(event, { env, ctx })`;
|
|
116
|
+
this is the extension point for feature integrations.
|
|
117
|
+
|
|
118
|
+
Long-running features can call `dispatchJob` with a Cloudflare Workflow binding;
|
|
119
|
+
base creates the durable record first and passes its ID to the Workflow as
|
|
120
|
+
`params.jobId`. Workflow code calls `executeJob`, which supplies a progress
|
|
121
|
+
reporter and completes or fails the record. `runJob` is the same lifecycle for
|
|
122
|
+
work already executing in the current invocation. Lower-level features may call
|
|
123
|
+
`createJob`, `startJob`, `updateJobProgress`, `completeJob`, `failJob`, and
|
|
124
|
+
`cancelJob` directly. Every lifecycle change is stored in `core_job_events` and emitted
|
|
125
|
+
to the owning user's live event room. Configure the optional live transport by
|
|
126
|
+
exporting `EventHub` from `@agilesyndrome/cf-genai-base/event-hub` and binding
|
|
127
|
+
an `EVENT_HUB` Durable Object in the application Worker. The React package's
|
|
128
|
+
`LiveEventsProvider`, `useJob`, `useJobs`, and `JobNotificationList` handle
|
|
129
|
+
reconnects and refreshes; the feature remains responsible for its own Workflow,
|
|
130
|
+
job type, executor, and result UI.
|
|
131
|
+
|
|
132
|
+
The exported `Event`, `emitEvent`, `requestContext`, `userId`, `sameOrigin`,
|
|
133
|
+
`readJson`, `secureJson`, `featureCircuit`, and `requireFeatureCircuit` helpers
|
|
134
|
+
are the shared identity, request, security, and feature-gating contracts.
|
|
135
|
+
|
|
136
|
+
The layered web surface is React-first: `/api` exports route contracts, scoped
|
|
137
|
+
repositories, the browser `apiFetch`/`apiJson` client, and job/event endpoints;
|
|
138
|
+
`/ui` exports React admin primitives, live event hooks, and durable job
|
|
139
|
+
notifications. Repositories are registered with
|
|
140
|
+
`createWorker({ repositories })`, can be supplied by applications or features,
|
|
141
|
+
and may declare links to other repositories while remaining behind the scoped
|
|
142
|
+
data reader. `GET /api/jobs` and `GET /api/jobs/:id` expose an authenticated
|
|
143
|
+
user's durable job records. `GET /api/events` upgrades to the authenticated live
|
|
144
|
+
event stream when the optional `EVENT_HUB` Durable Object binding is configured.
|
package/changelog.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 5.0.0
|
|
4
|
+
|
|
5
|
+
- Replace `@agilesyndrome/cf-genai-base/authorization` imports with `/auth`.
|
|
6
|
+
- Replace direct `src/core.js` imports with `src/core/index.js` or the package `/core` entrypoint.
|
|
7
|
+
- Replace direct `src/data.js` imports with `src/data/index.js` or the package `/data` entrypoint.
|
|
8
|
+
- Replace direct `src/api/router.js` imports with `src/api/contracts.js` for route definitions and dispatch.
|
|
9
|
+
- Update internal imports to the canonical folder modules; the old facade files are removed.
|
|
10
|
+
- Update package consumers and lockfiles to version `5.0.0`.
|
|
11
|
+
- Replace server-rendered admin HTML and custom elements with the React UI entrypoint; apps own the shell and theme around base's admin components.
|
|
12
|
+
- Apply `migrations/0006_jobs.sql`; features now use generic durable job helpers and `GET /api/jobs` instead of inventing job tables and status endpoints.
|
|
13
|
+
- Configure an `EVENT_HUB` Durable Object and export `EventHub` from `@agilesyndrome/cf-genai-base/event-hub` to enable authenticated live job events over WebSockets.
|
|
14
|
+
- Dispatch long-running work through a Cloudflare Workflow with `dispatchJob`; execute its durable lifecycle with `executeJob` instead of relying on request-lifetime background work.
|
|
15
|
+
- 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,17 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilesyndrome/cf-genai-base",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.js",
|
|
7
|
-
"./
|
|
8
|
-
"./data": "./src/data.js",
|
|
9
|
-
"./
|
|
7
|
+
"./auth": "./src/auth/index.js",
|
|
8
|
+
"./data": "./src/data/index.js",
|
|
9
|
+
"./api": "./src/api/index.js",
|
|
10
|
+
"./event-hub": "./src/core/event-hub.js",
|
|
11
|
+
"./repository": "./src/repository.js",
|
|
12
|
+
"./app": "./src/app.js",
|
|
13
|
+
"./admin": "./src/admin/index.js",
|
|
14
|
+
"./core": "./src/core/index.js",
|
|
10
15
|
"./ui": "./src/ui/index.js",
|
|
16
|
+
"./ui/react": "./src/ui/react/index.jsx",
|
|
11
17
|
"./ui/styles.css": "./src/ui/styles.css"
|
|
12
18
|
},
|
|
13
19
|
"description": "Lean Worker lifecycle and security helpers for Cloudflare sites.",
|
|
14
20
|
"license": "MIT",
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": "^19.0.0"
|
|
23
|
+
},
|
|
15
24
|
"publishConfig": {
|
|
16
25
|
"access": "public",
|
|
17
26
|
"provenance": true
|
|
@@ -20,6 +29,7 @@
|
|
|
20
29
|
"src",
|
|
21
30
|
"migrations",
|
|
22
31
|
"README.md",
|
|
32
|
+
"changelog.md",
|
|
23
33
|
"CONTRACT.md",
|
|
24
34
|
"LICENSE"
|
|
25
35
|
],
|
|
@@ -29,7 +39,7 @@
|
|
|
29
39
|
},
|
|
30
40
|
"homepage": "https://github.com/agilesyndrome/cf-genai-base#readme",
|
|
31
41
|
"scripts": {
|
|
32
|
-
"check": "node --check src/index.js && node --check src/core.js && node --check src/
|
|
42
|
+
"check": "node --check src/index.js && node --check src/core/index.js && node --check src/core/jobs.js && node --check src/core/event-hub.js && node --check src/auth/index.js && node --check src/runtime/worker.js && node --check src/runtime/features.js && node --check src/runtime/health.js && node --check src/admin/index.js && node --check src/admin/routes.js && node --check src/data/index.js && node --check src/data/resources.js && node --check src/data/reader.js && node --check src/data/context.js && node --check src/api/index.js && node --check src/api/contracts.js && node --check src/api/client.js && node --check src/api/jobs.js && node --check src/ui/index.js",
|
|
33
43
|
"test": "node --test tests/*.test.mjs",
|
|
34
44
|
"build": "npm run check && npm test && npm pack --dry-run"
|
|
35
45
|
}
|
|
@@ -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) }); }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { json, requestIdentity, sameOrigin } from "../core/index.js";
|
|
2
|
+
import { hasScope } from "../auth/index.js";
|
|
3
|
+
|
|
4
|
+
export function defineRoute({ method = "GET", path, auth = "public", scope = null, csrf = false, handler } = {}) {
|
|
5
|
+
if (!path || typeof handler !== "function") throw new TypeError("API routes require path and handler");
|
|
6
|
+
return { method: String(method).toUpperCase(), path: String(path), auth, scope, csrf, handler };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function matchRoute(request, route) {
|
|
10
|
+
const url = new URL(request.url); const methods = Array.isArray(route.method) ? route.method : [route.method];
|
|
11
|
+
return methods.map((method) => String(method).toUpperCase()).includes(request.method.toUpperCase()) && (typeof route.path === "function" ? route.path(url.pathname, request) : route.path === url.pathname);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function dispatchRoutes(request, env, ctx, state, routes = []) {
|
|
15
|
+
for (const route of routes) if (matchRoute(request, route)) {
|
|
16
|
+
const identity = requestIdentity(state);
|
|
17
|
+
if (route.auth === "user" && !identity.isAuthenticated) return json({ error: "Authentication is required." }, 401);
|
|
18
|
+
if (route.auth === "admin" && !identity.isAdmin) return json({ error: "Administrator access is required." }, 403);
|
|
19
|
+
if (route.csrf && !sameOrigin(request)) return json({ error: "A same-origin request is required." }, 403);
|
|
20
|
+
if (route.scope && !(await hasScope(env, state?.user || state?.authUser, route.scope, { who: identity.who }))) return json({ error: "Required scope is missing." }, 403);
|
|
21
|
+
return route.handler({ request, env, ctx, state, route, identity });
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
package/src/api/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { Event, emitEvent, json, readJson, readJsonClone, requireAdmin, requireFeatureCircuit, requireUser, sameOrigin, secureJson, secureText, userId, requestContext, requestIdentity } from "../core/index.js";
|
|
2
|
+
export { defineRepository, createRepositories, RepositoryError } from "../repository.js";
|
|
3
|
+
export { defineRoute, matchRoute, dispatchRoutes } from "./contracts.js";
|
|
4
|
+
export { apiFetch, apiJson } from "./client.js";
|
|
5
|
+
export { jobResponse, liveEventsResponse } from "./jobs.js";
|
|
6
|
+
export { defineApp } from "../app.js";
|
|
7
|
+
export { assertSecurityHeaders, assertJsonError } from "./testing.js";
|
package/src/api/jobs.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getJob, listJobEvents, listJobs } from "../core/jobs.js";
|
|
2
|
+
import { requestIdentity } from "../core/identity.js";
|
|
3
|
+
import { secureJson } from "../core/security.js";
|
|
4
|
+
|
|
5
|
+
export async function jobResponse(request, env, state) {
|
|
6
|
+
const pathname = new URL(request.url).pathname;
|
|
7
|
+
if (pathname !== "/api/jobs" && !pathname.startsWith("/api/jobs/")) return null;
|
|
8
|
+
if (request.method !== "GET") return secureJson({ error: "Method not allowed" }, 405, { Allow: "GET" });
|
|
9
|
+
const identity = requestIdentity(state);
|
|
10
|
+
const ownerId = identity.authUser?.id || state?.user?.sub || (state?.user?.auth_strategy === "http_basic" ? "basic:admin" : null);
|
|
11
|
+
if (!ownerId) return secureJson({ error: "Authentication is required." }, 401);
|
|
12
|
+
const url = new URL(request.url);
|
|
13
|
+
const match = url.pathname.match(/^\/api\/jobs\/([^/]+)(?:\/events)?$/);
|
|
14
|
+
const isEvents = url.pathname.endsWith("/events");
|
|
15
|
+
if (!match) {
|
|
16
|
+
const jobs = await listJobs(env, { ownerId, type: url.searchParams.get("type") || undefined, status: url.searchParams.get("status") || undefined, limit: url.searchParams.get("limit") || 50 }, { who: identity.who });
|
|
17
|
+
return secureJson({ jobs });
|
|
18
|
+
}
|
|
19
|
+
const job = await getJob(env, decodeURIComponent(match[1]), { who: identity.who });
|
|
20
|
+
if (!job || (job.ownerId !== ownerId && !identity.isAdmin)) return secureJson({ error: "Job not found" }, 404);
|
|
21
|
+
if (isEvents) return secureJson({ events: await listJobEvents(env, job.id, { who: identity.who }) });
|
|
22
|
+
return secureJson({ job });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function liveEventsResponse(request, env, state, bindingName = "EVENT_HUB") {
|
|
26
|
+
const url = new URL(request.url);
|
|
27
|
+
if (url.pathname !== "/api/events") return null;
|
|
28
|
+
const identity = requestIdentity(state);
|
|
29
|
+
const ownerId = identity.authUser?.id || state?.user?.sub || (state?.user?.auth_strategy === "http_basic" ? "basic:admin" : null);
|
|
30
|
+
if (!ownerId) return secureJson({ error: "Authentication is required." }, 401);
|
|
31
|
+
const namespace = env?.[bindingName];
|
|
32
|
+
if (!namespace || typeof namespace.idFromName !== "function") return secureJson({ error: "Live events are not configured." }, 501);
|
|
33
|
+
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return secureJson({ error: "WebSocket upgrade required." }, 426, { Upgrade: "websocket" });
|
|
34
|
+
const room = `user:${ownerId}`;
|
|
35
|
+
const stub = namespace.get(namespace.idFromName(room));
|
|
36
|
+
return stub.fetch("https://cf-genai-event-hub/connect", { headers: { Upgrade: "websocket" } });
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function assertSecurityHeaders(response) {
|
|
2
|
+
for (const header of ["Content-Security-Policy", "X-Content-Type-Options", "X-Frame-Options", "Referrer-Policy", "Permissions-Policy", "Strict-Transport-Security"]) {
|
|
3
|
+
if (!response?.headers?.get(header)) throw new Error(`Missing security header: ${header}`);
|
|
4
|
+
}
|
|
5
|
+
return response;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function assertJsonError(response, status) {
|
|
9
|
+
if (response.status !== status) throw new Error(`Expected ${status}, received ${response.status}`);
|
|
10
|
+
const payload = await response.clone().json();
|
|
11
|
+
if (!payload?.error) throw new Error("Expected a JSON error envelope");
|
|
12
|
+
return payload;
|
|
13
|
+
}
|
package/src/app.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function defineApp({ name, ui = true, api = true, admin = true, features = [], repositories = [] } = {}) {
|
|
2
|
+
if (!/^[a-z][a-z0-9-]*$/.test(String(name || ""))) throw new TypeError("Apps require a safe name");
|
|
3
|
+
if (!ui && !api) throw new TypeError("An app must expose ui or api");
|
|
4
|
+
return { name: String(name), ui: Boolean(ui), api: Boolean(api), admin: Boolean(admin), features: [...features], repositories: [...repositories] };
|
|
5
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const AUTH_USER_TABLE = "auth_users";
|
|
2
|
+
export const AUTH_SCOPE_TABLE = "auth_scopes";
|
|
3
|
+
export const AUTH_GRANT_TABLE = "auth_user_scopes";
|
|
4
|
+
export const DEFAULT_TENANT_ID = "easley-family";
|
|
5
|
+
export const DEFAULT_TENANT_NAME = "Easley Family";
|
|
6
|
+
|
|
7
|
+
export class SubscriptionError extends Error {
|
|
8
|
+
constructor(message) { super(message); this.name = "SubscriptionError"; }
|
|
9
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function parseJsonValue(value) { try { return JSON.parse(value); } catch { return value; } }
|
|
2
|
+
export function deepEqual(left, right) { return JSON.stringify(left) === JSON.stringify(right); }
|
|
3
|
+
export async function signValue(value, secret) { const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(String(secret)), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); return base64url(new Uint8Array(signature)); }
|
|
4
|
+
export function base64url(bytes) { return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); }
|
|
5
|
+
export function base64urlDecode(value) { const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4); return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); }
|
|
6
|
+
export function constantTimeEqual(left, right) { const a = new TextEncoder().encode(String(left)), b = new TextEncoder().encode(String(right)); let result = a.length ^ b.length; for (let index = 0; index < Math.max(a.length, b.length); index += 1) result |= (a[index] || 0) ^ (b[index] || 0); return result === 0; }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { createD1 } from "../core/d1.js";
|
|
2
|
+
|
|
3
|
+
export async function listGroups(env, { who = "system:read" } = {}) { const result = await createD1(env, { who }).prepare("SELECT name,display_name,description,created_at,updated_at FROM auth_groups ORDER BY display_name COLLATE NOCASE").all(); return result.results || []; }
|
|
4
|
+
export async function listUserGroups(env, userId, { who = "system:read" } = {}) { const result = await createD1(env, { who }).prepare("SELECT group_name,granted_at FROM auth_user_groups WHERE user_id=? ORDER BY group_name").bind(userId).all(); return result.results || []; }
|
|
5
|
+
export async function replaceUserGroups(env, userId, groups, grantedBy, { who = "system:read" } = {}) { const db = createD1(env, { who }); await db.batch([db.prepare("DELETE FROM auth_user_groups WHERE user_id=?").bind(userId), ...[...new Set(groups)].map((group) => db.prepare("INSERT INTO auth_user_groups (user_id,group_name,granted_by) VALUES (?,?,?)").bind(userId, group, grantedBy || null))]); return listUserGroups(env, userId, { who }); }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { base64url, base64urlDecode, constantTimeEqual, signValue } from "./encoding.js";
|
|
2
|
+
|
|
3
|
+
export async function createImpersonationToken(env, adminUserId, targetUserId, { ttlSeconds = 900 } = {}) {
|
|
4
|
+
if (!env?.AUTH_SESSION_SECRET) throw new Error("AUTH_SESSION_SECRET is required for impersonation.");
|
|
5
|
+
const payload = { adminUserId: String(adminUserId || "admin"), targetUserId: String(targetUserId), exp: Math.floor(Date.now() / 1000) + Math.min(Math.max(Number(ttlSeconds) || 900, 60), 3600) };
|
|
6
|
+
const encoded = base64url(new TextEncoder().encode(JSON.stringify(payload)));
|
|
7
|
+
return `${encoded}.${await signValue(encoded, env?.AUTH_SESSION_SECRET || "")}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function verifyImpersonationToken(token, env) {
|
|
11
|
+
if (!env?.AUTH_SESSION_SECRET) return null;
|
|
12
|
+
const [encoded, signature] = String(token || "").split(".");
|
|
13
|
+
if (!encoded || !signature || !constantTimeEqual(signature, await signValue(encoded, env?.AUTH_SESSION_SECRET || ""))) return null;
|
|
14
|
+
try { const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(encoded))); return payload.exp > Date.now() / 1000 && payload.targetUserId ? payload : null; } catch { return null; }
|
|
15
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createD1 } from "../core/d1.js";
|
|
2
|
+
import { AUTH_GRANT_TABLE, AUTH_SCOPE_TABLE } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
export function normalizeScopes(scopes = []) {
|
|
5
|
+
return scopes.map((scope) => typeof scope === "string" ? { name: scope, label: scope, description: "", system: false } : scope)
|
|
6
|
+
.filter((scope) => scope && /^[a-z0-9]+(?::[a-z0-9-]+)+$/.test(String(scope.name || "")))
|
|
7
|
+
.map((scope) => ({ name: String(scope.name), label: String(scope.label || scope.name), description: String(scope.description || ""), system: Boolean(scope.system) }));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function ensureScopes(env, scopes = [], { who = "system:read" } = {}) {
|
|
11
|
+
if (!env?.DB) return;
|
|
12
|
+
const db = createD1(env, { who });
|
|
13
|
+
for (const scope of normalizeScopes(scopes)) await db.prepare(`INSERT INTO ${AUTH_SCOPE_TABLE} (name,label,description,system) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET label=excluded.label,description=excluded.description,system=excluded.system`).bind(scope.name, scope.label, scope.description, scope.system ? 1 : 0).run();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function listAuthorizationScopes(env, { who = "system:read" } = {}) {
|
|
17
|
+
const { results } = await createD1(env, { who }).prepare(`SELECT name,label,description,system FROM ${AUTH_SCOPE_TABLE} ORDER BY name`).all();
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function listUserGrants(env, userId, { who = "system:read" } = {}) {
|
|
22
|
+
const { results } = await createD1(env, { who }).prepare(`SELECT scope_name,granted_at FROM ${AUTH_GRANT_TABLE} WHERE user_id=? ORDER BY scope_name`).bind(userId).all();
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function replaceUserGrants(env, userId, scopes, grantedBy, { who = "system:read" } = {}) {
|
|
27
|
+
const valid = new Set((await listAuthorizationScopes(env, { who })).map((scope) => scope.name));
|
|
28
|
+
const db = createD1(env, { who });
|
|
29
|
+
const requested = [...new Set(scopes)].filter((scope) => valid.has(scope));
|
|
30
|
+
await db.batch([db.prepare(`DELETE FROM ${AUTH_GRANT_TABLE} WHERE user_id=?`).bind(userId), ...requested.map((scope) => db.prepare(`INSERT INTO ${AUTH_GRANT_TABLE} (user_id,scope_name,granted_by) VALUES (?,?,?)`).bind(userId, scope, grantedBy || null))]);
|
|
31
|
+
return requested;
|
|
32
|
+
}
|