okengine 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/manifest.v1.schema.json +21 -2
- package/package.json +1 -1
- package/site/content/docs/ai/llms-txt.mdx +9 -3
- package/site/content/docs/elements/clock.mdx +7 -0
- package/site/content/docs/elements/flow.mdx +14 -12
- package/site/content/docs/elements/gate.mdx +58 -8
- package/site/content/docs/elements/signal.mdx +46 -17
- package/site/content/docs/elements/store.mdx +23 -17
- package/site/content/docs/elements/vault.mdx +23 -0
- package/site/content/docs/get-started/project-structure.mdx +4 -4
- package/site/content/docs/reference/cli.md +1 -1
- package/site/content/docs/reference/client.mdx +72 -17
- package/site/content/docs/reference/configuration.mdx +7 -4
- package/site/content/docs/reference/errors.mdx +24 -17
- package/site/content/docs/reference/fx.mdx +15 -9
- package/src/auth/api-key-sql.ts +11 -4
- package/src/auth/api-keys.ts +3 -0
- package/src/auth/config.ts +19 -0
- package/src/auth/index.ts +37 -0
- package/src/auth/plugin.ts +6 -1
- package/src/auth/sessions.ts +18 -0
- package/src/auth/tables.ts +32 -0
- package/src/auth/tenant-config.ts +74 -0
- package/src/auth/tenant-tables.ts +11 -0
- package/src/auth/tenants.test.ts +63 -0
- package/src/auth/tenants.ts +360 -0
- package/src/cli/build.ts +2 -2
- package/src/client/budget.test.ts +1 -1
- package/src/client/create.ts +75 -5
- package/src/client/index.ts +13 -0
- package/src/client/live.test.ts +422 -0
- package/src/client/live.ts +389 -0
- package/src/client/notes-contract.test.ts +41 -0
- package/src/client/types.ts +85 -6
- package/src/client-react/index.ts +85 -1
- package/src/client-react/use-live.test.ts +129 -0
- package/src/compiler/effects-infer.ts +22 -2
- package/src/compiler/extract.test.ts +143 -11
- package/src/compiler/extract.ts +210 -30
- package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
- package/src/compiler/fixtures/skyport.expected.json +2 -3
- package/src/compiler/response.ts +41 -11
- package/src/console/server/app.ts +44 -11
- package/src/console/server/console.test.ts +6 -8
- package/src/console/server/gates.ts +2 -9
- package/src/console/server/store.test.ts +17 -0
- package/src/console/server/store.ts +43 -1
- package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-9Wwx1g4Q.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-DydRDi70.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-Cg9nxd6m.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-D-OzmFOI.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
- package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-B_zeux2Y.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-CsMg7XDW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
- package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
- package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
- package/src/drivers/index.ts +2 -0
- package/src/drivers/journal-postgres.ts +12 -3
- package/src/drivers/pg-rls.ts +26 -2
- package/src/drivers/pg-vault-rls.ts +68 -0
- package/src/drivers/signal-engine.ts +69 -15
- package/src/drivers/signal-live-iter.ts +65 -0
- package/src/drivers/signal-nats.ts +2 -1
- package/src/drivers/signal-postgres.ts +95 -12
- package/src/drivers/signal-redis.ts +2 -1
- package/src/drivers/signal-retention.ts +64 -0
- package/src/drivers/signal-types.ts +35 -5
- package/src/elements/clock/declare.ts +64 -2
- package/src/elements/clock/reconcile.ts +98 -25
- package/src/elements/clock/runtime.ts +13 -2
- package/src/elements/clock.test.ts +28 -0
- package/src/elements/clock.ts +9 -1
- package/src/elements/gate/permissions.ts +12 -0
- package/src/elements/gate.ts +6 -1
- package/src/elements/signal/declare.ts +44 -10
- package/src/elements/signal/delivery-modes.test.ts +90 -4
- package/src/elements/signal/order-lifecycle.test.ts +20 -4
- package/src/elements/signal/runtime.ts +42 -0
- package/src/elements/signal.test.ts +21 -8
- package/src/elements/signal.ts +1 -1
- package/src/elements/store/declare.ts +7 -0
- package/src/elements/store/rls-identity.test.ts +29 -0
- package/src/elements/store/rls-identity.ts +3 -0
- package/src/elements/store/schema-decl.ts +63 -3
- package/src/elements/store/schema-tenant.ts +41 -0
- package/src/elements/store/sql-rls-isolation.test.ts +39 -0
- package/src/elements/store.ts +2 -0
- package/src/elements/vault/builtin-adapter.ts +15 -2
- package/src/elements/vault/declare.ts +8 -0
- package/src/elements/vault/runtime.ts +7 -0
- package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
- package/src/elements/vault/storage.ts +9 -0
- package/src/elements/vault/test-helpers.ts +2 -1
- package/src/i18n/catalogs/ar.ts +19 -0
- package/src/i18n/catalogs/en.ts +19 -0
- package/src/index.ts +1 -0
- package/src/kernel/adopt-routes.ts +56 -8
- package/src/kernel/app-tenant.ts +122 -0
- package/src/kernel/app.ts +183 -56
- package/src/kernel/auth-resolve.ts +3 -0
- package/src/kernel/boot-bind/clock.ts +9 -3
- package/src/kernel/boot.ts +2 -0
- package/src/kernel/budget.test.ts +1 -1
- package/src/kernel/clock-durable.ts +8 -0
- package/src/kernel/clock-per-tenant-name.ts +5 -0
- package/src/kernel/clock-reconcile.ts +8 -0
- package/src/kernel/errors-live-resume.ts +15 -0
- package/src/kernel/errors-tenant.ts +29 -0
- package/src/kernel/errors.registry.test.ts +31 -3
- package/src/kernel/errors.ts +43 -3
- package/src/kernel/flow.ts +26 -5
- package/src/kernel/fx-auth-keys.ts +6 -1
- package/src/kernel/fx-auth-tenants.test.ts +87 -0
- package/src/kernel/fx-auth-tenants.ts +286 -0
- package/src/kernel/fx-live-stream.ts +149 -0
- package/src/kernel/fx-live.test.ts +157 -0
- package/src/kernel/fx-runtime.ts +15 -0
- package/src/kernel/fx-tenant-store.ts +213 -0
- package/src/kernel/fx.test.ts +91 -0
- package/src/kernel/fx.ts +173 -13
- package/src/kernel/hooks.ts +2 -2
- package/src/kernel/http-resource.ts +9 -18
- package/src/kernel/index.ts +2 -0
- package/src/kernel/journal.ts +12 -0
- package/src/kernel/live-http.test.ts +78 -0
- package/src/kernel/live-http.ts +114 -0
- package/src/kernel/live-resume.test.ts +125 -0
- package/src/kernel/on.ts +51 -0
- package/src/kernel/pipeline-tenant.ts +49 -0
- package/src/kernel/pipeline.test.ts +1 -1
- package/src/kernel/pipeline.ts +37 -2
- package/src/kernel/resource-mount.test.ts +10 -27
- package/src/kernel/tenant-resolve.test.ts +101 -0
- package/src/kernel/tenant-resolve.ts +124 -0
- package/src/kernel/tenant-roles.test.ts +87 -0
- package/src/kernel/triggers.ts +59 -21
- package/src/manifest/diff.test.ts +11 -2
- package/src/manifest/diff.ts +53 -11
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +0 -1
- package/src/manifest/types.ts +52 -6
- package/src/release/build-lib.ts +13 -1
- package/src/release/limits.ts +2 -2
- package/src/release/measure.ts +55 -1
- package/src/client/live-gap.test.ts +0 -35
|
@@ -172,7 +172,7 @@ const { data } = await api.auth.signInEmail({ email, password });
|
|
|
172
172
|
if (data) session.set(data);
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
-
React: `useSession(api, session)` from `okengine/client-react`.
|
|
175
|
+
React: `useSession(api, session)` and `useLive(api, signal, input?)` from `okengine/client-react`.
|
|
176
176
|
|
|
177
177
|
| Step | What happens |
|
|
178
178
|
| ---------------------- | ---------------------------------------------------------------- |
|
|
@@ -204,6 +204,7 @@ A 401 with no `{ data, error }` body becomes `TransportError` with `data.status:
|
|
|
204
204
|
| `memorySession` | `okengine/client/auth` | In-memory access/refresh bag for `auth.getToken` |
|
|
205
205
|
| `AUTH_ERROR_CODES` | `okengine/client/auth` | Common auth Flow / gate codes |
|
|
206
206
|
| `useSession(api, session?)` | `okengine/client-react` | React status + `auth.me` |
|
|
207
|
+
| `useLive(api, signal, …)` | `okengine/client-react` | React `events` / `latest` / `isConnected` |
|
|
207
208
|
|
|
208
209
|
Core `okengine/client` stays under the size budget — helpers are separate exports. Not in
|
|
209
210
|
core today: cookie jars or plugin `.client()` decorations. Browser apps: also see
|
|
@@ -221,7 +222,7 @@ core today: cookie jars or plugin `.client()` decorations. Browser apps: also se
|
|
|
221
222
|
| [Flow](/docs/elements/flow) | Direct | `api.unit.flow(input)` — the only public surface |
|
|
222
223
|
| [Gate](/docs/elements/gate) | Indirect | Bearer via `auth`; denials as `Unauthorized` / `Forbidden` / `RateLimited` |
|
|
223
224
|
| [Store](/docs/elements/store) | Via Flows | `fx.store` inside Flows; `store.resource` + `on(http.resource…)` → five Flows on `$routes` |
|
|
224
|
-
| [Signal](/docs/elements/signal) |
|
|
225
|
+
| [Signal](/docs/elements/signal) | Live SSE | `api.live(signal, input?, { onEvent })` — HTTP GET, callback + unsubscribe |
|
|
225
226
|
| [Clock](/docs/elements/clock) | Via Flows | Schedules fire on the server — the client never ticks a clock |
|
|
226
227
|
| [Vault](/docs/elements/vault) | Via Flows | Secrets stay server-side; never ship them to the browser package |
|
|
227
228
|
| [Channel](/docs/elements/channel) | Via Flows | `fx.send` in a Flow — the client does not send email/SMS/push |
|
|
@@ -233,7 +234,7 @@ Mount a resource, adopt the returned ops, then call the five Flows like any othe
|
|
|
233
234
|
|
|
234
235
|
```typescript
|
|
235
236
|
const notesR = store.resource(db, notes, {/* in, out, list */});
|
|
236
|
-
const mounted = on(http.resource("/notes", notesR.all()).public()
|
|
237
|
+
const mounted = on(http.resource("/notes", notesR.all()).public());
|
|
237
238
|
// .adopt({ notes: mounted }) →
|
|
238
239
|
const page = await api.notes.list({ limit: 20 });
|
|
239
240
|
const more = await page.next();
|
|
@@ -259,24 +260,63 @@ See [Store](/docs/elements/store) for the list query language and schemas. Handw
|
|
|
259
260
|
`fx.json.withQuery(rows, input)` for the same envelope. Auth posture for HTTP triggers is
|
|
260
261
|
covered under [Gate](/docs/elements/gate).
|
|
261
262
|
|
|
262
|
-
###
|
|
263
|
+
### Live signals
|
|
263
264
|
|
|
264
|
-
`delivery: "live"`
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
`delivery: "live"` is HTTP SSE. Expose with `.live(signal)` on GET (or `http.live(signal)` for
|
|
266
|
+
`GET /_oke/live/{name}`), then subscribe with a callback. `for await` stays on the server.
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
const stop = api.live(
|
|
270
|
+
orderStatus,
|
|
271
|
+
{ orderId: "ord_1" },
|
|
272
|
+
{
|
|
273
|
+
onEvent: (event) => {
|
|
274
|
+
/* event: { orderId, status } */
|
|
275
|
+
},
|
|
276
|
+
onError: (err) => {
|
|
277
|
+
/* optional — 4xx, envelope, network drop */
|
|
278
|
+
},
|
|
279
|
+
autoResubscribe: false, // default — true reopens after a drop (500ms…30s backoff)
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
stop(); // useEffect cleanup
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`api.orders.events({ orderId }, { onEvent })` is the same shape on the exposing flow.
|
|
286
|
+
|
|
287
|
+
The client picks the unique exposure whose `matchKey` fields are a subset of the input, preferring
|
|
288
|
+
the largest match (`{ orderId }` beats firehose). A tie needs `via: "unit.flow"`.
|
|
289
|
+
|
|
290
|
+
Reconnects send `Last-Event-ID` from the last `id:` the client actually received.
|
|
291
|
+
|
|
292
|
+
A **410** `LiveResumeGap` (**OKE1014**) means that cursor is gone — `onError` fires, the
|
|
293
|
+
cursor is dropped, and `autoResubscribe` replays the remaining tape after backoff.
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
import { useLive } from "okengine/client-react";
|
|
297
|
+
|
|
298
|
+
const { events, latest, error, isConnected } = useLive(
|
|
299
|
+
api,
|
|
300
|
+
orderStatus,
|
|
301
|
+
{ orderId: "ord_1" },
|
|
302
|
+
{
|
|
303
|
+
autoResubscribe: true,
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
```
|
|
267
307
|
|
|
268
308
|
## Exports
|
|
269
309
|
|
|
270
|
-
| Export | Kind | Role
|
|
271
|
-
| ----------------------------------------- | --------- |
|
|
272
|
-
| `createClient` | function | Typed proxy `api.unit.flow(input?)`
|
|
273
|
-
| `flattenRoutes` | function | `$routes` → flat `unit.flow` REST table
|
|
274
|
-
| `createTransport` | function | Low-level HTTP transport (timeout / retry / auth)
|
|
275
|
-
| `isOk` / `isFail` | function | Envelope predicates
|
|
276
|
-
| `isErrorCode` / `isTransportError` | function | Error narrowing
|
|
277
|
-
| `Client`, `ClientCall`, `ClientResult`, … | types | Contracts, `page.next()` / `for await` of `list()`
|
|
278
|
-
| `Register` | interface | Module-augmentation slot for ambient App types
|
|
279
|
-
| `AppOf` | type | Brand a bare route map as an App
|
|
310
|
+
| Export | Kind | Role |
|
|
311
|
+
| ----------------------------------------- | --------- | --------------------------------------------------- |
|
|
312
|
+
| `createClient` | function | Typed proxy `api.unit.flow(input?)` plus `api.live` |
|
|
313
|
+
| `flattenRoutes` | function | `$routes` → flat `unit.flow` REST table |
|
|
314
|
+
| `createTransport` | function | Low-level HTTP transport (timeout / retry / auth) |
|
|
315
|
+
| `isOk` / `isFail` | function | Envelope predicates |
|
|
316
|
+
| `isErrorCode` / `isTransportError` | function | Error narrowing |
|
|
317
|
+
| `Client`, `ClientCall`, `ClientResult`, … | types | Contracts, `page.next()` / `for await` of `list()` |
|
|
318
|
+
| `Register` | interface | Module-augmentation slot for ambient App types |
|
|
319
|
+
| `AppOf` | type | Brand a bare route map as an App |
|
|
280
320
|
|
|
281
321
|
Budget: the `./client` export stays under the measured client-runtime cap (hard gate in CI).
|
|
282
322
|
|
|
@@ -323,6 +363,20 @@ calls `api.auth.refresh({ refreshToken })`. Re-login when rotation fails or no r
|
|
|
323
363
|
|
|
324
364
|
</Accordion>
|
|
325
365
|
|
|
366
|
+
<Accordion title="api.live throws Multiple live exposures">
|
|
367
|
+
|
|
368
|
+
Two routes share the same match shape. Pass `via: "unit.flow"` or call the exposing flow
|
|
369
|
+
(`api.orders.events(input, { onEvent })`).
|
|
370
|
+
|
|
371
|
+
</Accordion>
|
|
372
|
+
|
|
373
|
+
<Accordion title="onError sees LiveResumeGap / HTTP 410">
|
|
374
|
+
|
|
375
|
+
The last `id:` is not on the server tape. The client drops the cursor. With `autoResubscribe: true`
|
|
376
|
+
the next request omits `Last-Event-ID` and replays what remains.
|
|
377
|
+
|
|
378
|
+
</Accordion>
|
|
379
|
+
|
|
326
380
|
</Accordions>
|
|
327
381
|
|
|
328
382
|
## Learn more
|
|
@@ -332,6 +386,7 @@ calls `api.auth.refresh({ refreshToken })`. Re-login when rotation fails or no r
|
|
|
332
386
|
- [Gate](/docs/elements/gate) — policies, `gate.public`, denials
|
|
333
387
|
- [Store](/docs/elements/store) — `store.resource` and list query language
|
|
334
388
|
- [Flow](/docs/elements/flow) — `in` / `out` / `errors` and `fx.fail`
|
|
389
|
+
- [Signal](/docs/elements/signal) — `api.live` for `delivery: "live"` SSE
|
|
335
390
|
- [Errors](/docs/reference/errors) — framework codes vs failure values
|
|
336
391
|
- [CORS](/docs/plugins/cors) · [CSRF](/docs/plugins/csrf) — browser callers
|
|
337
392
|
- [CLI Reference](/docs/reference/cli) — `oke client add`, `oke dev`
|
|
@@ -181,10 +181,13 @@ i18n: { locales: ["en"], default: "en" },
|
|
|
181
181
|
|
|
182
182
|
## tenancy
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
|
184
|
+
`oke.config.ts` `tenancy` is **isolation posture** (how rows are separated). Identity —
|
|
185
|
+
who the tenant is — is `gate.auth.tenant` on [Gate](/docs/elements/gate#tenants-identity-dimension).
|
|
186
|
+
|
|
187
|
+
| Option | Type | Meaning |
|
|
188
|
+
| ----------- | --------------------------------- | ------------------------------------------------- |
|
|
189
|
+
| `isolation` | `"row" \| "schema" \| "database"` | How tenants are separated in the store |
|
|
190
|
+
| `resolve` | string \| function | Observational resolver (isolation-only manifests) |
|
|
188
191
|
|
|
189
192
|
## privacy
|
|
190
193
|
|
|
@@ -29,18 +29,25 @@ string. Custom app codes stay message-less until registered.
|
|
|
29
29
|
|
|
30
30
|
## OKE numeric codes
|
|
31
31
|
|
|
32
|
-
| Code | Name
|
|
33
|
-
| ------ |
|
|
34
|
-
| `1001` | undeclared read
|
|
35
|
-
| `1002` | undeclared write
|
|
36
|
-
| `1003` | undeclared emit
|
|
37
|
-
| `1004` | undeclared send
|
|
38
|
-
| `1005` | undeclared ask
|
|
39
|
-
| `1006` | undeclared secret
|
|
40
|
-
| `1007` | undeclared call
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
32
|
+
| Code | Name | Cause | Fix |
|
|
33
|
+
| ------ | -------------------- | ------------------------------------------------- | ---------------------------------------------------- |
|
|
34
|
+
| `1001` | undeclared read | Flow reads a resource not in `effects.reads` | Add it to the flow's `effects.reads` |
|
|
35
|
+
| `1002` | undeclared write | Flow writes a resource not in `effects.writes` | Add it to the flow's `effects.writes` |
|
|
36
|
+
| `1003` | undeclared emit | Flow emits a signal not in `effects.emits` | Add it to the flow's `effects.emits` |
|
|
37
|
+
| `1004` | undeclared send | Flow sends a template not in `effects.sends` | Add it to the flow's `effects.sends` |
|
|
38
|
+
| `1005` | undeclared ask | Flow asks a prompt not in `effects.asks` | Add it to the flow's `effects.asks` |
|
|
39
|
+
| `1006` | undeclared secret | Flow reads a secret not in `effects.secrets` | Add it to the flow's `effects.secrets` |
|
|
40
|
+
| `1007` | undeclared call | Flow calls a flow not in `effects.calls` | Add it to the flow's `effects.calls` |
|
|
41
|
+
| `1011` | HTTP route clash | Two HTTP flows share the same method + path | Give each flow a unique method + path |
|
|
42
|
+
| `1012` | HTTP flow unnamed | Adopted HTTP flow still has no `unit.export` | Export from `flows/<unit>/` or pass a named `flow` |
|
|
43
|
+
| `1013` | live exposure dup | Same signal, gates, and match on two GET routes | Change the gate or path-param filter |
|
|
44
|
+
| `1014` | live resume gap | `Last-Event-ID` is not on the retained tape | Reconnect without the cursor; remaining tape replays |
|
|
45
|
+
| `1015` | tenant required | Tenant-scoped op with no `fx.tenant.id` | `switchTenant`, signed `tid`, or tenant header |
|
|
46
|
+
| `1016` | tenant not member | Client-supplied tenant id is not a membership | Pick from `listTenants` or add the user as a member |
|
|
47
|
+
| `1017` | tenant unknown scope | Tenant role used an invented or `console:*` scope | Use a declared application scope |
|
|
48
|
+
| `1042` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
|
|
49
|
+
| `1043` | signal schema | Emit payload failed the signal's `schema` | Pass a payload that matches `schema`, or remove it |
|
|
50
|
+
| `1101` | schema missing | Domain table absent in `prod` — no auto-DDL | Run `oke db migrate` against this environment |
|
|
44
51
|
|
|
45
52
|
<Callout title="Effects are usually inferred">
|
|
46
53
|
The 1001–1007 family exists for flows that declare effects explicitly. Most apps never write an
|
|
@@ -52,11 +59,11 @@ string. Custom app codes stay message-less until registered.
|
|
|
52
59
|
|
|
53
60
|
Returned, not thrown — the request never reached `do`:
|
|
54
61
|
|
|
55
|
-
| Code | When | Payload
|
|
56
|
-
| -------------- | -------------------------------------------- |
|
|
57
|
-
| `Unauthorized` | Policy denied, request not authenticated | —
|
|
58
|
-
| `Forbidden` | Policy denied, authenticated but not allowed | `gate`, `reason` |
|
|
59
|
-
| `RateLimited` | Rate gate budget exhausted | `retryAfterMs`
|
|
62
|
+
| Code | When | Payload |
|
|
63
|
+
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
64
|
+
| `Unauthorized` | Policy denied, request not authenticated | — |
|
|
65
|
+
| `Forbidden` | Policy denied, authenticated but not allowed | `gate`, `reason` (`tenant_required` · `not_member` · `unknown_scope` · `session_only` · …) |
|
|
66
|
+
| `RateLimited` | Rate gate budget exhausted | `retryAfterMs` |
|
|
60
67
|
|
|
61
68
|
## Framework validation failures
|
|
62
69
|
|
|
@@ -42,6 +42,7 @@ See [Store](/docs/elements/store) for the query-builder surface.
|
|
|
42
42
|
| ------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
43
43
|
| `fx.emit(signal, payload?, { key? })` | `emit` | Commits the signal outbox when the call resolves; optional `key` serializes `once` per key; stamps producer run id as `parentRunId` for trace chains; throws **OKE1042** (orphan) or **OKE1043** (schema) |
|
|
44
44
|
| `fx.deadLetters(signal)` | `read` `signal:<name>` | Dead-lettered messages for that signal. Payload typed from `SignalDecl<T>`. Page with `fx.json.withQuery`. Cross-signal throws **OKE1001**. |
|
|
45
|
+
| `fx.live(signal, { match? })` | `read` `signal:<name>` | Live tape as SSE. Returns `JsonStreamResult` (object chunks, `id:` on the wire). Cross-signal throws **OKE1001**. Do not wrap with `fx.json.stream`. |
|
|
45
46
|
|
|
46
47
|
## Runs (observability read)
|
|
47
48
|
|
|
@@ -92,10 +93,14 @@ on(
|
|
|
92
93
|
| `fx.auth.revokeApiKey(id)` | `write` `auth:api-keys` | Owner only |
|
|
93
94
|
| `fx.auth.rotateApiKey(id)` | `write` `auth:api-keys` | New secret once. Owner only |
|
|
94
95
|
| `fx.auth.updateApiKey(id, …)` | `write` `auth:api-keys` | Name / scopes / expiry / allowlist / rate. Re-attenuates |
|
|
96
|
+
| `fx.auth.listTenants()` | `read` `auth:tenants` | Memberships for the live session. Session only |
|
|
97
|
+
| `fx.auth.switchTenant(id)` | `write` `auth:tenants` | New access+refresh, new family, `tid` on both. Never Set-Cookie. Session only |
|
|
98
|
+
| `fx.auth.createTenant({ name, slug?, id? })` | `write` `auth:tenants` | Creator becomes a member. Session only |
|
|
99
|
+
| `fx.auth.upsertTenantRole({ tenantId, roleName, scopes })` | `write` `auth:tenants` | Application scopes only — `console:*` is unknown_scope |
|
|
95
100
|
|
|
96
|
-
`fx.call` starts the callee with an **empty** `fx.auth` (fail-closed for authorization)
|
|
97
|
-
audit/attribution only, read `fx.principal` — it propagates the
|
|
98
|
-
copying into `fx.auth`. Gates never consult `fx.principal`.
|
|
101
|
+
`fx.call` starts the callee with an **empty** `fx.auth` (fail-closed for authorization) and
|
|
102
|
+
propagates `fx.tenant.id`. For audit/attribution only, read `fx.principal` — it propagates the
|
|
103
|
+
originating identity without copying into `fx.auth`. Gates never consult `fx.principal`.
|
|
99
104
|
|
|
100
105
|
## Concurrency and retry
|
|
101
106
|
|
|
@@ -214,6 +219,7 @@ keys. Use `cache: false` to opt out, or `cache: "30s"` for a TTL.
|
|
|
214
219
|
| `fx.json.with(page)` / `with(data, meta)` | 200 | `{ data, meta, error: null }` — already-built pager |
|
|
215
220
|
| `fx.json.withQuery(rows, input, spec?)` | 200 | In-memory list page — zero-config `q` / auto-eq / PostgREST |
|
|
216
221
|
| `fx.json.stream(chunks)` | 200 | `text/event-stream` — JSON `data:` frames, then `data: [DONE]` |
|
|
222
|
+
| `fx.live(signal)` | 200 | Same SSE carrier for a live signal (payload frames + `id:`) |
|
|
217
223
|
|
|
218
224
|
Returning a plain value instead answers 200 with `{ data: value, error: null }` — the helpers exist for status and `meta` control. Pass `fx.stream(...)` into `fx.json.stream` to reach the HTTP client token-by-token.
|
|
219
225
|
|
|
@@ -275,12 +281,12 @@ Built-in `errors.*` / `oke.*` catalogs localize `fx.fail` messages and thrown
|
|
|
275
281
|
|
|
276
282
|
## Principals
|
|
277
283
|
|
|
278
|
-
| Property | Shape
|
|
279
|
-
| -------------- |
|
|
280
|
-
| `fx.auth` | `{ userId, scopes, verified?, apiKeyId? }` plus key methods (session only) |
|
|
281
|
-
| `fx.operator` | `{ id: string \| null }` — Console plane
|
|
282
|
-
| `fx.principal` | Read-only origin: `userId`, `operatorId`, `scopes`, `verified?`, `plane?`
|
|
283
|
-
| `fx.tenant` | `{ id: string \| null }` — active tenant
|
|
284
|
+
| Property | Shape |
|
|
285
|
+
| -------------- | ------------------------------------------------------------------------------------- |
|
|
286
|
+
| `fx.auth` | `{ userId, scopes, verified?, apiKeyId? }` plus key and tenant methods (session only) |
|
|
287
|
+
| `fx.operator` | `{ id: string \| null }` — Console plane |
|
|
288
|
+
| `fx.principal` | Read-only origin: `userId`, `operatorId`, `scopes`, `verified?`, `plane?` |
|
|
289
|
+
| `fx.tenant` | `{ id: string \| null }` — active tenant (propagates on `fx.call`) |
|
|
284
290
|
|
|
285
291
|
**Consequence:** use `fx.auth` / gates for authorization; use `fx.principal` only when a callee
|
|
286
292
|
must log who started the call chain. A key Bearer sets `userId` to the issuer and `apiKeyId`
|
package/src/auth/api-key-sql.ts
CHANGED
|
@@ -32,8 +32,8 @@ export async function persistApiKeyRow(
|
|
|
32
32
|
await sql.execute(
|
|
33
33
|
`INSERT INTO ${table} (
|
|
34
34
|
id, plane, hash, name, scopes, expires_at, rate_limit, ip_allowlist,
|
|
35
|
-
creator_id, creator_scopes, created_at, last_used_at, revoked_at
|
|
36
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
35
|
+
creator_id, creator_scopes, created_at, last_used_at, revoked_at, tenant_id
|
|
36
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
37
37
|
ON CONFLICT(id) DO UPDATE SET
|
|
38
38
|
hash = excluded.hash,
|
|
39
39
|
name = excluded.name,
|
|
@@ -44,7 +44,8 @@ export async function persistApiKeyRow(
|
|
|
44
44
|
creator_id = excluded.creator_id,
|
|
45
45
|
creator_scopes = excluded.creator_scopes,
|
|
46
46
|
last_used_at = excluded.last_used_at,
|
|
47
|
-
revoked_at = excluded.revoked_at
|
|
47
|
+
revoked_at = excluded.revoked_at,
|
|
48
|
+
tenant_id = excluded.tenant_id`,
|
|
48
49
|
[
|
|
49
50
|
row.id,
|
|
50
51
|
row.plane,
|
|
@@ -59,6 +60,7 @@ export async function persistApiKeyRow(
|
|
|
59
60
|
row.createdAt,
|
|
60
61
|
row.lastUsedAt,
|
|
61
62
|
row.revokedAt,
|
|
63
|
+
row.tenantId ?? null,
|
|
62
64
|
],
|
|
63
65
|
);
|
|
64
66
|
}
|
|
@@ -140,9 +142,11 @@ export async function ensureApiKeyTable(
|
|
|
140
142
|
creator_scopes TEXT NOT NULL DEFAULT '[]',
|
|
141
143
|
created_at BIGINT NOT NULL,
|
|
142
144
|
last_used_at BIGINT,
|
|
143
|
-
revoked_at BIGINT
|
|
145
|
+
revoked_at BIGINT,
|
|
146
|
+
tenant_id TEXT
|
|
144
147
|
)
|
|
145
148
|
`);
|
|
149
|
+
await conn.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS tenant_id TEXT`);
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
/**
|
|
@@ -203,6 +207,9 @@ function rowFromSql(raw: Record<string, unknown>): ApiKeyRow | null {
|
|
|
203
207
|
createdAt: asNumberOrNull(raw.created_at ?? raw.createdAt) ?? 0,
|
|
204
208
|
lastUsedAt: asNumberOrNull(raw.last_used_at ?? raw.lastUsedAt),
|
|
205
209
|
revokedAt: asNumberOrNull(raw.revoked_at ?? raw.revokedAt),
|
|
210
|
+
...(asString(raw.tenant_id ?? raw.tenantId) !== null
|
|
211
|
+
? { tenantId: asString(raw.tenant_id ?? raw.tenantId) }
|
|
212
|
+
: {}),
|
|
206
213
|
};
|
|
207
214
|
}
|
|
208
215
|
|
package/src/auth/api-keys.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface CreateApiKeyOptions {
|
|
|
31
31
|
readonly expiresAt?: number | null;
|
|
32
32
|
readonly rateLimit?: { max: number; per: string } | null;
|
|
33
33
|
readonly ipAllowlist?: readonly string[];
|
|
34
|
+
/** Optional tenant claim copied onto the key principal. */
|
|
35
|
+
readonly tenantId?: string | null;
|
|
34
36
|
/** Injectable id / secret / hash (tests). */
|
|
35
37
|
readonly id?: string;
|
|
36
38
|
readonly secret?: string;
|
|
@@ -163,6 +165,7 @@ export async function createApiKey(
|
|
|
163
165
|
createdAt: now(),
|
|
164
166
|
lastUsedAt: null,
|
|
165
167
|
revokedAt: null,
|
|
168
|
+
...(options.tenantId !== undefined ? { tenantId: options.tenantId } : {}),
|
|
166
169
|
};
|
|
167
170
|
store.keys.set(id, row);
|
|
168
171
|
await persistRow(store, row);
|
package/src/auth/config.ts
CHANGED
|
@@ -16,6 +16,12 @@ import {
|
|
|
16
16
|
type ResolvedAuthSchema,
|
|
17
17
|
} from "./schema.ts";
|
|
18
18
|
import { sessionCryptoFromAuthOptions, type AuthSessionOptions } from "./plugin.ts";
|
|
19
|
+
import { createTenantStore, type TenantStore } from "./tenants.ts";
|
|
20
|
+
import {
|
|
21
|
+
resolveTenantAuth,
|
|
22
|
+
type ResolvedTenantAuth,
|
|
23
|
+
type TenantAuthOptions,
|
|
24
|
+
} from "./tenant-config.ts";
|
|
19
25
|
|
|
20
26
|
/** Email + password method knobs. */
|
|
21
27
|
export interface EmailAndPasswordOptions {
|
|
@@ -115,6 +121,15 @@ export interface GateAuthOptions extends AuthSchemaOptions {
|
|
|
115
121
|
* (`gate.auth.secret` is the HMAC pepper).
|
|
116
122
|
*/
|
|
117
123
|
readonly apiKeyStore?: ApiKeyStore;
|
|
124
|
+
/**
|
|
125
|
+
* Multi-tenancy as an identity dimension. `true` enables defaults
|
|
126
|
+
* (`source: "claim"`, `required: false`). Off when omitted.
|
|
127
|
+
*/
|
|
128
|
+
readonly tenant?: boolean | TenantAuthOptions;
|
|
129
|
+
/**
|
|
130
|
+
* Shared tenant registry. Created automatically when {@link tenant} is on.
|
|
131
|
+
*/
|
|
132
|
+
readonly tenantStore?: TenantStore;
|
|
118
133
|
/** Injectable clock. */
|
|
119
134
|
readonly now?: () => number;
|
|
120
135
|
}
|
|
@@ -171,6 +186,8 @@ export interface ResolvedGateAuth {
|
|
|
171
186
|
readonly hooks: AuthDatabaseHooks | undefined;
|
|
172
187
|
readonly sessions: SessionStore | undefined;
|
|
173
188
|
readonly apiKeyStore: ApiKeyStore;
|
|
189
|
+
readonly tenant: ResolvedTenantAuth | undefined;
|
|
190
|
+
readonly tenantStore: TenantStore | undefined;
|
|
174
191
|
readonly now: (() => number) | undefined;
|
|
175
192
|
}
|
|
176
193
|
|
|
@@ -279,6 +296,8 @@ export function resolveGateAuth(options: ResolveGateAuthOptions): ResolvedGateAu
|
|
|
279
296
|
hooks: auth.hooks,
|
|
280
297
|
sessions: auth.sessions,
|
|
281
298
|
apiKeyStore: auth.apiKeyStore ?? createApiKeyStore({ pepper: secret }),
|
|
299
|
+
tenant: auth.tenant ? resolveTenantAuth(auth.tenant) : undefined,
|
|
300
|
+
tenantStore: auth.tenant ? (auth.tenantStore ?? createTenantStore()) : auth.tenantStore,
|
|
282
301
|
now: auth.now,
|
|
283
302
|
};
|
|
284
303
|
}
|
package/src/auth/index.ts
CHANGED
|
@@ -260,4 +260,41 @@ export {
|
|
|
260
260
|
type RoleRow,
|
|
261
261
|
type SessionRow,
|
|
262
262
|
type TablePlane,
|
|
263
|
+
type TenantMemberRow,
|
|
264
|
+
type TenantRoleRow,
|
|
265
|
+
type TenantRow,
|
|
263
266
|
} from "./tables.ts";
|
|
267
|
+
|
|
268
|
+
export { AUTH_TENANT_TABLES } from "./tenant-tables.ts";
|
|
269
|
+
|
|
270
|
+
export {
|
|
271
|
+
AUTH_TENANTS_RESOURCE,
|
|
272
|
+
DEFAULT_TENANT_ROLE,
|
|
273
|
+
TenantError,
|
|
274
|
+
addMember,
|
|
275
|
+
createTenant,
|
|
276
|
+
createTenantStore,
|
|
277
|
+
deleteTenant,
|
|
278
|
+
getMember,
|
|
279
|
+
isMember,
|
|
280
|
+
listMembers,
|
|
281
|
+
listTenantIds,
|
|
282
|
+
listTenantsForUser,
|
|
283
|
+
removeMember,
|
|
284
|
+
tenantRoleScopeFailure,
|
|
285
|
+
tenantScopesForMember,
|
|
286
|
+
upsertTenantRole,
|
|
287
|
+
type CreateTenantOptions,
|
|
288
|
+
type TenantPublicRow,
|
|
289
|
+
type TenantStore,
|
|
290
|
+
type TenantStoreHooks,
|
|
291
|
+
type UpsertTenantRoleOptions,
|
|
292
|
+
} from "./tenants.ts";
|
|
293
|
+
|
|
294
|
+
export {
|
|
295
|
+
resolveTenantAuth,
|
|
296
|
+
type ResolvedTenantAuth,
|
|
297
|
+
type TenantAuthOptions,
|
|
298
|
+
type TenantResolveContext,
|
|
299
|
+
type TenantSource,
|
|
300
|
+
} from "./tenant-config.ts";
|
package/src/auth/plugin.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
|
9
9
|
import type { BreachCheckFn } from "./breach-check.ts";
|
|
10
10
|
import type { PasswordPolicyOptions } from "./password-policy.ts";
|
|
11
11
|
import { AUTH_TABLES } from "./tables.ts";
|
|
12
|
+
import { AUTH_TENANT_TABLES } from "./tenant-tables.ts";
|
|
12
13
|
|
|
13
14
|
/** Session knobs beyond raw access/refresh TTLs. */
|
|
14
15
|
export interface AuthSessionOptions {
|
|
@@ -83,7 +84,11 @@ export function auth(options: AuthPluginOptions = {}): PluginDef {
|
|
|
83
84
|
/* principals resolved by session / API key middleware at runtime */
|
|
84
85
|
});
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
const tables: readonly string[] = [
|
|
88
|
+
...Object.values(AUTH_TABLES),
|
|
89
|
+
...Object.values(AUTH_TENANT_TABLES),
|
|
90
|
+
];
|
|
91
|
+
for (const name of tables) {
|
|
87
92
|
builder = builder.table(name, undefined, {
|
|
88
93
|
plane: name.includes("operator")
|
|
89
94
|
? "operator"
|
package/src/auth/sessions.ts
CHANGED
|
@@ -43,6 +43,11 @@ export interface AccessClaims {
|
|
|
43
43
|
* Validated when the verifier supplies {@link VerifyAccessOptions.audience}.
|
|
44
44
|
*/
|
|
45
45
|
readonly aud?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Active tenant for this access token (request-scoped).
|
|
48
|
+
* Never stored on the session row — switching issues a new token pair.
|
|
49
|
+
*/
|
|
50
|
+
readonly tid?: string;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/** Options for {@link createSessionStore}. */
|
|
@@ -106,6 +111,8 @@ export async function issueSession(
|
|
|
106
111
|
readonly id: string;
|
|
107
112
|
readonly plane: AuthPlane;
|
|
108
113
|
readonly scopes: Iterable<string>;
|
|
114
|
+
/** Optional tenant claim (not stored on the session row). */
|
|
115
|
+
readonly tenantId?: string | null;
|
|
109
116
|
},
|
|
110
117
|
): Promise<IssuedSession> {
|
|
111
118
|
const now = crypto.now ?? (() => Date.now());
|
|
@@ -136,6 +143,7 @@ export async function issueSession(
|
|
|
136
143
|
store.sessions.set(sessionId, session);
|
|
137
144
|
|
|
138
145
|
const refreshRaw = `rt_${cryptoRandomId()}`;
|
|
146
|
+
const tid = principal.tenantId ?? undefined;
|
|
139
147
|
const refreshRow: RefreshTokenRow = {
|
|
140
148
|
id: cryptoRandomId(),
|
|
141
149
|
sessionId,
|
|
@@ -144,6 +152,7 @@ export async function issueSession(
|
|
|
144
152
|
expiresAt: t + refreshTtl,
|
|
145
153
|
usedAt: null,
|
|
146
154
|
revokedAt: null,
|
|
155
|
+
...(tid !== undefined && tid !== "" ? { tid } : {}),
|
|
147
156
|
};
|
|
148
157
|
store.refresh.set(refreshRow.id, refreshRow);
|
|
149
158
|
|
|
@@ -156,6 +165,7 @@ export async function issueSession(
|
|
|
156
165
|
iat: t,
|
|
157
166
|
exp: accessExpiresAt,
|
|
158
167
|
...(crypto.audience !== undefined ? { aud: crypto.audience } : {}),
|
|
168
|
+
...(tid !== undefined && tid !== "" ? { tid } : {}),
|
|
159
169
|
});
|
|
160
170
|
|
|
161
171
|
return { session, accessToken, refreshToken: refreshRaw, accessExpiresAt };
|
|
@@ -211,6 +221,7 @@ export async function rotateRefresh(
|
|
|
211
221
|
const refreshTtl = crypto.refreshTtlMs ?? REFRESH_TTL_MS;
|
|
212
222
|
|
|
213
223
|
const newRefreshRaw = `rt_${cryptoRandomId()}`;
|
|
224
|
+
const rotatedTid = existing.tid;
|
|
214
225
|
const newRefresh: RefreshTokenRow = {
|
|
215
226
|
id: cryptoRandomId(),
|
|
216
227
|
sessionId: session.id,
|
|
@@ -219,6 +230,9 @@ export async function rotateRefresh(
|
|
|
219
230
|
expiresAt: t + refreshTtl,
|
|
220
231
|
usedAt: null,
|
|
221
232
|
revokedAt: null,
|
|
233
|
+
...(rotatedTid !== undefined && rotatedTid !== null && rotatedTid !== ""
|
|
234
|
+
? { tid: rotatedTid }
|
|
235
|
+
: {}),
|
|
222
236
|
};
|
|
223
237
|
store.refresh.set(newRefresh.id, newRefresh);
|
|
224
238
|
|
|
@@ -231,6 +245,9 @@ export async function rotateRefresh(
|
|
|
231
245
|
iat: t,
|
|
232
246
|
exp: accessExpiresAt,
|
|
233
247
|
...(session.audience !== undefined ? { aud: session.audience } : {}),
|
|
248
|
+
...(rotatedTid !== undefined && rotatedTid !== null && rotatedTid !== ""
|
|
249
|
+
? { tid: rotatedTid }
|
|
250
|
+
: {}),
|
|
234
251
|
});
|
|
235
252
|
|
|
236
253
|
return {
|
|
@@ -291,6 +308,7 @@ export async function issueSessionWithScopes(
|
|
|
291
308
|
readonly id: string;
|
|
292
309
|
readonly plane: AuthPlane;
|
|
293
310
|
readonly scopes: Iterable<string>;
|
|
311
|
+
readonly tenantId?: string | null;
|
|
294
312
|
},
|
|
295
313
|
): Promise<IssuedSession> {
|
|
296
314
|
return issueSession(store, crypto, principal);
|
package/src/auth/tables.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface ApiKeyRow {
|
|
|
98
98
|
lastUsedAt: number | null;
|
|
99
99
|
/** Epoch-ms when revoked; `null` while active. */
|
|
100
100
|
revokedAt: number | null;
|
|
101
|
+
/** Optional tenant claim (tier-1 API-key tenancy). */
|
|
102
|
+
tenantId?: string | null;
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
/** Pending operator invitation (invite-only plane). */
|
|
@@ -136,4 +138,34 @@ export interface RefreshTokenRow {
|
|
|
136
138
|
expiresAt: number;
|
|
137
139
|
usedAt: number | null;
|
|
138
140
|
revokedAt: number | null;
|
|
141
|
+
/** Tenant id stamped on this refresh row (request-scoped; not on the session). */
|
|
142
|
+
tid?: string | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Tenant registry row. */
|
|
146
|
+
export interface TenantRow {
|
|
147
|
+
id: string;
|
|
148
|
+
name: string;
|
|
149
|
+
slug: string | null;
|
|
150
|
+
createdAt: number;
|
|
151
|
+
createdBy: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** N:N membership — one user, many tenants; one tenant, many users. */
|
|
155
|
+
export interface TenantMemberRow {
|
|
156
|
+
id: string;
|
|
157
|
+
tenantId: string;
|
|
158
|
+
userId: string;
|
|
159
|
+
/** Tenant role name (expanded via {@link TenantRoleRow}). */
|
|
160
|
+
role: string;
|
|
161
|
+
createdAt: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Tenant-scoped role → application scope names. */
|
|
165
|
+
export interface TenantRoleRow {
|
|
166
|
+
tenantId: string;
|
|
167
|
+
roleName: string;
|
|
168
|
+
scopes: string[];
|
|
169
|
+
createdAt: number;
|
|
170
|
+
updatedAt: number;
|
|
139
171
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gate.auth.tenant` config — identity dimension opt-in.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** How the request names a tenant. */
|
|
6
|
+
export type TenantSource = "claim" | "header" | "subdomain" | "resolve";
|
|
7
|
+
|
|
8
|
+
/** Public `gate.auth.tenant` bag (boolean shorthand or options). */
|
|
9
|
+
export interface TenantAuthOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Pure B2B: every user-plane request needs a tenant.
|
|
12
|
+
* Default `false` (B2C+B2B coexistence).
|
|
13
|
+
*/
|
|
14
|
+
readonly required?: boolean;
|
|
15
|
+
/** Resolution source. Default `"claim"`. */
|
|
16
|
+
readonly source?: TenantSource;
|
|
17
|
+
/** Header name for `source: "header"`. Default `x-oke-tenant`. */
|
|
18
|
+
readonly header?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Tier-3 escape hatch. When it returns an id, membership is still
|
|
21
|
+
* checked unless {@link TenantAuthOptions.authoritative} is true.
|
|
22
|
+
*/
|
|
23
|
+
readonly resolve?: (ctx: TenantResolveContext) => string | null | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* When true, {@link TenantAuthOptions.resolve} is trusted without a
|
|
26
|
+
* membership query. Default false (fail-safe).
|
|
27
|
+
*/
|
|
28
|
+
readonly authoritative?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Context passed to {@link TenantAuthOptions.resolve}. */
|
|
32
|
+
export interface TenantResolveContext {
|
|
33
|
+
readonly auth: {
|
|
34
|
+
readonly userId: string | null;
|
|
35
|
+
readonly tenantId?: string | null;
|
|
36
|
+
};
|
|
37
|
+
readonly request?: Request;
|
|
38
|
+
readonly claimTenantId: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Fully resolved tenant auth config. */
|
|
42
|
+
export interface ResolvedTenantAuth {
|
|
43
|
+
readonly enabled: true;
|
|
44
|
+
readonly required: boolean;
|
|
45
|
+
readonly source: TenantSource;
|
|
46
|
+
readonly header: string;
|
|
47
|
+
readonly resolve?: TenantAuthOptions["resolve"];
|
|
48
|
+
readonly authoritative: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Normalize `gate.auth.tenant`.
|
|
53
|
+
*
|
|
54
|
+
* @param input - `true` or options bag
|
|
55
|
+
*/
|
|
56
|
+
export function resolveTenantAuth(input: true | TenantAuthOptions): ResolvedTenantAuth {
|
|
57
|
+
if (input === true) {
|
|
58
|
+
return {
|
|
59
|
+
enabled: true,
|
|
60
|
+
required: false,
|
|
61
|
+
source: "claim",
|
|
62
|
+
header: "x-oke-tenant",
|
|
63
|
+
authoritative: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
enabled: true,
|
|
68
|
+
required: input.required === true,
|
|
69
|
+
source: input.source ?? "claim",
|
|
70
|
+
header: input.header ?? "x-oke-tenant",
|
|
71
|
+
...(input.resolve !== undefined ? { resolve: input.resolve } : {}),
|
|
72
|
+
authoritative: input.authoritative === true,
|
|
73
|
+
};
|
|
74
|
+
}
|