okengine 0.15.2 → 0.16.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/package.json +1 -1
- package/site/content/docs/elements/flow.mdx +6 -0
- package/site/content/docs/elements/gate.mdx +55 -0
- package/site/content/docs/elements/store.mdx +13 -15
- package/site/content/docs/elements/vault.mdx +3 -0
- package/site/content/docs/reference/client.mdx +3 -0
- package/site/content/docs/reference/fx.mdx +29 -18
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/api-key-sql.test.ts +47 -0
- package/src/auth/api-key-sql.ts +254 -0
- package/src/auth/api-keys.ts +273 -14
- package/src/auth/auth.test.ts +56 -0
- package/src/auth/config.ts +5 -5
- package/src/auth/index.ts +25 -1
- package/src/auth/schema.ts +8 -0
- package/src/cli/attach-host-console.test.ts +37 -0
- package/src/cli/attach-host-console.ts +6 -0
- package/src/compiler/effects-infer.ts +17 -2
- package/src/console/server/access.test.ts +116 -8
- package/src/console/server/access.ts +70 -23
- package/src/console/server/app.ts +1 -0
- package/src/console/server/dev-identities.test.ts +127 -0
- package/src/console/server/dev-identities.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +21 -4
- package/src/console/server/flows.ts +113 -58
- package/src/console/server/gates.test.ts +24 -0
- package/src/console/server/gates.ts +8 -21
- package/src/console/server/index.ts +1 -0
- package/src/console/server/live.test.ts +26 -1
- package/src/console/server/live.ts +4 -2
- package/src/console/server/operator-db.ts +2 -0
- package/src/console/server/state.ts +29 -55
- package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
- package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
- package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
- package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
- package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
- package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
- package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
- package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
- package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
- package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
- package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
- package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
- package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
- package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
- package/src/console/ui-next/dist/index.html +11 -9
- package/src/console/ui-next/index.html +3 -3
- package/src/console/ui-next/src/client.ts +167 -0
- package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
- package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
- package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
- package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
- package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
- package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
- package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
- package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
- package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
- package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
- package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
- package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
- package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
- package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
- package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
- package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
- package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
- package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
- package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
- package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
- package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
- package/src/console/ui-next/src/lib/document-meta.ts +2 -1
- package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
- package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
- package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
- package/src/console/ui-next/src/lib/shortcut.ts +3 -0
- package/src/console/ui-next/src/router.tsx +10 -1
- package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
- package/src/console/ui-next/ui-next-seed-store.ts +43 -15
- package/src/console/xss.gate.test.ts +1 -0
- package/src/elements/gate/declare.ts +2 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/runtime.ts +14 -0
- package/src/elements/store/schema-decl.ts +99 -41
- package/src/elements/store/sql-select.types.test.ts +71 -0
- package/src/elements/store/sql-session.ts +54 -32
- package/src/elements/store.ts +3 -0
- package/src/index.ts +1 -0
- package/src/kernel/api-key-host-persist.test.ts +143 -0
- package/src/kernel/app.ts +14 -2
- package/src/kernel/auth-resolve.ts +10 -5
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/fx-auth-keys.test.ts +88 -0
- package/src/kernel/fx-auth-keys.ts +198 -0
- package/src/kernel/fx.test.ts +17 -1
- package/src/kernel/fx.ts +36 -18
- package/src/kernel/pipeline-api-key.test.ts +192 -7
- package/src/kernel/pipeline.ts +4 -2
- package/src/manifest/types.ts +9 -3
- package/src/release/build-lib.ts +1 -0
- package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
- package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
- package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
package/package.json
CHANGED
|
@@ -104,6 +104,12 @@ Built-in codes attach a localized `message` ([Errors](/docs/reference/errors));
|
|
|
104
104
|
[Store](/docs/elements/store#handwritten-http-lists).
|
|
105
105
|
</Callout>
|
|
106
106
|
|
|
107
|
+
<Callout title="Helpers take the real Fx">
|
|
108
|
+
Type a helper's `fx` as the exported `Fx` (`import type { Fx } from
|
|
109
|
+
"okengine"`). A narrower `{ store: … }` will not match `store()` overloads
|
|
110
|
+
— that is the `as never` trap. See [fx](/docs/reference/fx).
|
|
111
|
+
</Callout>
|
|
112
|
+
|
|
107
113
|
## The five triggers
|
|
108
114
|
|
|
109
115
|
<FlowTriggers />
|
|
@@ -256,6 +256,38 @@ Customize tables, then `oke schema generate` (`.oke/schema/oke.ts`; `--check` in
|
|
|
256
256
|
sign-in shapes: [Plugins](/docs/plugins). Call auth from
|
|
257
257
|
[createClient](/docs/reference/client) — helpers in `okengine/client/auth`.
|
|
258
258
|
|
|
259
|
+
## API keys
|
|
260
|
+
|
|
261
|
+
A key is the **issuer with fewer gates** — not a second permission system.
|
|
262
|
+
Bearer secrets authenticate as the creator; `fx.auth.scopes` shrink to the key.
|
|
263
|
+
|
|
264
|
+
| Field | Session | API key |
|
|
265
|
+
| ----------------------------------- | -------------- | ----------------------- |
|
|
266
|
+
| `fx.auth.userId` / `fx.operator.id` | subject | creator (`creatorId`) |
|
|
267
|
+
| `fx.auth.scopes` | session scopes | key scopes (attenuated) |
|
|
268
|
+
| `fx.auth.apiKeyId` | `null` | key id |
|
|
269
|
+
| WideEvent `principal` | subject | issuer |
|
|
270
|
+
| `dimensions.api_key` | absent | key id |
|
|
271
|
+
|
|
272
|
+
`gate.auth` always creates a shared key store (HMAC-SHA-256 with `gate.auth.secret`).
|
|
273
|
+
Do not pass `apiKeyStore` unless you are injecting a test Map.
|
|
274
|
+
|
|
275
|
+
<Callout title="Keys live on the app database">
|
|
276
|
+
With `store.sql()`, boot hydrates public `oke_api_keys` on that connection (`DATABASE_URL`) and
|
|
277
|
+
write-through persists create / rotate / revoke. Console **Access** attaches this host store — it
|
|
278
|
+
does not keep a second key database.
|
|
279
|
+
</Callout>
|
|
280
|
+
|
|
281
|
+
Session Flows manage keys via [fx](/docs/reference/fx) (`createApiKey`, `listApiKeys`,
|
|
282
|
+
`revokeApiKey`, `rotateApiKey`, `updateApiKey`). A key-authenticated caller cannot.
|
|
283
|
+
|
|
284
|
+
Declare `effects.reads: ["auth:api-keys"]` / `effects.writes: ["auth:api-keys"]` — never
|
|
285
|
+
`sql:oke_api_keys`. Console **Access** (`:6533` → `/access`) mints operator keys as the
|
|
286
|
+
operator, and user-plane keys only as a picked issuer (`creatorUserId`).
|
|
287
|
+
|
|
288
|
+
Verify rejects expired, revoked, allowlist-miss, and over-rate secrets with `401`
|
|
289
|
+
and no `api_key` dimension.
|
|
290
|
+
|
|
259
291
|
## Identity and decisions
|
|
260
292
|
|
|
261
293
|
Gates decide on principals that already exist. `gate.auth` issues hybrid sessions onto
|
|
@@ -310,6 +342,29 @@ character classes.
|
|
|
310
342
|
`"ip"` for public surfaces (sign-up, password reset), `"user"` for authenticated quotas.
|
|
311
343
|
Keying an authenticated endpoint by IP punishes shared NAT.
|
|
312
344
|
|
|
345
|
+
</Accordion>
|
|
346
|
+
<Accordion title="API key Bearer returns 401">
|
|
347
|
+
|
|
348
|
+
Expired, revoked, allowlist miss, or over the key's `rateLimit`. Check
|
|
349
|
+
`ipAllowlist` against the same X-Forwarded-For hop as `ip-allowlist`.
|
|
350
|
+
Entries are IPs or hostnames — a host resolves at verify time and must
|
|
351
|
+
include the client IP (lookup failure is closed). Rotate if the secret
|
|
352
|
+
was shown once and then lost.
|
|
353
|
+
|
|
354
|
+
</Accordion>
|
|
355
|
+
<Accordion title="Keys vanish after an app restart">
|
|
356
|
+
|
|
357
|
+
No SQL store, or a new `gate.auth.secret`. Declare `store.sql()`, keep
|
|
358
|
+
`DATABASE_URL` (or the same PGlite datadir) and the auth secret stable.
|
|
359
|
+
Console does not persist keys in `oke_console`.
|
|
360
|
+
|
|
361
|
+
</Accordion>
|
|
362
|
+
<Accordion title="Key can call a route the issuer cannot">
|
|
363
|
+
|
|
364
|
+
That is a bug in the mint. `createApiKey` / `fx.auth.createApiKey` re-run
|
|
365
|
+
`assertAttenuated` against the issuer's live scopes. Custom `gate.policy`
|
|
366
|
+
predicates that ignore scopes still see `verified: true`.
|
|
367
|
+
|
|
313
368
|
</Accordion>
|
|
314
369
|
</Accordions>
|
|
315
370
|
|
|
@@ -91,22 +91,17 @@ Install reviews the `CREATE EXTENSION` SQL; **Advanced** can set `SCHEMA`,
|
|
|
91
91
|
When a newer packaged version exists, the name chip shows **Upgrade**
|
|
92
92
|
(`ALTER EXTENSION … UPDATE`). Names link to the project or Postgres docs.
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
columns); two-finger click empty grid to create. Create policy pins
|
|
103
|
-
SQL | Code (`store.schema.policy` + `pgPolicy`); drag the handle to
|
|
104
|
-
resize the preview.
|
|
94
|
+
SQL table rows and the resource header show RLS (emerald / muted shield;
|
|
95
|
+
**N RLS** when policies exist) before **PII**. KV, files, and index have
|
|
96
|
+
no badge — enabling RLS on create flips the shield.
|
|
97
|
+
|
|
98
|
+
**RLS Policies** lists `pg_policies` plus Manifest `declared` / `live`.
|
|
99
|
+
Two-finger click a row for its sheet, or empty grid to create. Create
|
|
100
|
+
pins SQL | Code (`store.schema.policy` + `pgPolicy`); drag to resize.
|
|
101
|
+
|
|
105
102
|
**Templates** use `oke.user()` / `oke.gate()` / `oke.has_scope()`.
|
|
106
|
-
Owner cards
|
|
107
|
-
|
|
108
|
-
marks as schema cards. Create stays blocked until a real column is
|
|
109
|
-
picked.
|
|
103
|
+
Owner cards pick a **Column** (guess `creator_email` / `owner_email`)
|
|
104
|
+
with PK / FK marks. Create stays blocked until a column is picked.
|
|
110
105
|
|
|
111
106
|
Create policy picks a Gate posture (Read / Write / Both), or **Policy &
|
|
112
107
|
scope**. Those picks fill `USING` / `WITH CHECK`. `TO` stays `public` —
|
|
@@ -296,6 +291,9 @@ await fx.store(db).update(notes).set({ title: input.title }).where(eq(notes.id,
|
|
|
296
291
|
await fx.store(db).delete(notes).where(lt(notes.createdAt, cutoff));
|
|
297
292
|
```
|
|
298
293
|
|
|
294
|
+
`select().from(notes)` infers the declared row (`string` / `number` / `null` from
|
|
295
|
+
`field.*`). A `select({ title: notes.title })` projection stays untyped.
|
|
296
|
+
|
|
299
297
|
<Callout title="One table per call — no relational with:">
|
|
300
298
|
`fx.store` is deliberately **single-table**: Drizzle's relational `findMany({ with: … })` is not
|
|
301
299
|
available through `fx`. Compose joins as separate single-table reads (or `fx.call`) so every
|
|
@@ -201,6 +201,9 @@ The **Console** (`:6533`) **Vault** page (`/vault`) groups **Secrets** and
|
|
|
201
201
|
**Config**. Secrets are write-only: a salted fingerprint (`sha256:…`), never
|
|
202
202
|
the cleartext. Config rows show the value — that is `vault.config()`.
|
|
203
203
|
|
|
204
|
+
API keys are principals — mint them on **Access** (`/access`), not Vault.
|
|
205
|
+
See [Gate](/docs/elements/gate#api-keys).
|
|
206
|
+
|
|
204
207
|
Add (`+`) creates a contract from Console as well as from source. Those rows
|
|
205
208
|
show `console` until you declare the same name in the Manifest.
|
|
206
209
|
|
|
@@ -180,6 +180,9 @@ React: `useSession(api, session)` from `okengine/client-react`.
|
|
|
180
180
|
| HTTP **401** | `refresh()` runs **once**, then the same call retries |
|
|
181
181
|
| HTTP **403** / **429** | No refresh — decode the failure envelope as usual |
|
|
182
182
|
|
|
183
|
+
`getToken` may return a session access token or an API key secret — both are Bearer.
|
|
184
|
+
See [Gate](/docs/elements/gate#api-keys). `refresh` applies to sessions only.
|
|
185
|
+
|
|
183
186
|
**Consequence:** `refresh` must mutate whatever `getToken` reads. Returning a new string alone does
|
|
184
187
|
nothing if storage was not updated.
|
|
185
188
|
|
|
@@ -14,11 +14,16 @@ flow("orders.example", {
|
|
|
14
14
|
});
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
<Callout title="Extracted helpers">
|
|
18
|
+
Type a helper's `fx` parameter as `Fx` (`import type {Fx} from "okengine"`). A narrower structural
|
|
19
|
+
type will not match `store()` overloads. See [Flow](/docs/elements/flow).
|
|
20
|
+
</Callout>
|
|
21
|
+
|
|
17
22
|
## Stores
|
|
18
23
|
|
|
19
24
|
| Signature | Records | Returns |
|
|
20
25
|
| ------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------- |
|
|
21
|
-
| `fx.store(sqlDecl).select().from(t)…` | `read` | rows (`where` · `orderBy` · `limit` · `offset` chainable)
|
|
26
|
+
| `fx.store(sqlDecl).select().from(t)…` | `read` | inferred rows (`where` · `orderBy` · `limit` · `offset` chainable) |
|
|
22
27
|
| `fx.store(sqlDecl).insert(t).values(v)` | `write` | `Promise<void>` |
|
|
23
28
|
| `fx.store(sqlDecl).update(t).set(v).where(…)` | `write` | `Promise<void>` |
|
|
24
29
|
| `fx.store(sqlDecl).delete(t).where(…)` | `write` | `Promise<void>` |
|
|
@@ -72,16 +77,21 @@ on(
|
|
|
72
77
|
|
|
73
78
|
## Flows
|
|
74
79
|
|
|
75
|
-
| Signature
|
|
76
|
-
|
|
|
77
|
-
| `fx.call(flow, input?)`
|
|
78
|
-
| `fx.step(name, fn)`
|
|
79
|
-
| `fx.all([...thunks])`
|
|
80
|
-
| `fx.race([...thunks])`
|
|
81
|
-
| `fx.retry(fn, opts?)`
|
|
82
|
-
| `fx.using(acq, rel, use)`
|
|
83
|
-
| `fx.signal`
|
|
84
|
-
| `fx.fail(code, data, opts?)`
|
|
80
|
+
| Signature | Records | Returns / notes |
|
|
81
|
+
| ------------------------------------------------------------------------------ | ----------------------- | -------------------------------------------------------------------------------------------------- |
|
|
82
|
+
| `fx.call(flow, input?)` | `call` | The callee's `out` — runs through the same pipeline |
|
|
83
|
+
| `fx.step(name, fn)` | — | Durable step: replays from the journal, never re-runs |
|
|
84
|
+
| `fx.all([...thunks])` | — | Parallel; first rejection aborts siblings |
|
|
85
|
+
| `fx.race([...thunks])` | — | First settle wins; losers aborted |
|
|
86
|
+
| `fx.retry(fn, opts?)` | — | Exponential backoff + jitter (plain Promise) |
|
|
87
|
+
| `fx.using(acq, rel, use)` | — | `release` runs once on settle or ambient abort |
|
|
88
|
+
| `fx.signal` | — | Ambient `AbortSignal` for the current branch |
|
|
89
|
+
| `fx.fail(code, data, opts?)` | — | Typed failure value (`opts.message` overrides) |
|
|
90
|
+
| `fx.auth.createApiKey({ name, scopes, expiresIn?, ipAllowlist?, rateLimit? })` | `write` `auth:api-keys` | Secret once. Creator is live `userId` / `scopes`. Session only. `ipAllowlist` is IPs or hostnames. |
|
|
91
|
+
| `fx.auth.listApiKeys()` | `read` `auth:api-keys` | Keys this session minted |
|
|
92
|
+
| `fx.auth.revokeApiKey(id)` | `write` `auth:api-keys` | Owner only |
|
|
93
|
+
| `fx.auth.rotateApiKey(id)` | `write` `auth:api-keys` | New secret once. Owner only |
|
|
94
|
+
| `fx.auth.updateApiKey(id, …)` | `write` `auth:api-keys` | Name / scopes / expiry / allowlist / rate. Re-attenuates |
|
|
85
95
|
|
|
86
96
|
`fx.call` starts the callee with an **empty** `fx.auth` (fail-closed for authorization). For
|
|
87
97
|
audit/attribution only, read `fx.principal` — it propagates the originating identity without
|
|
@@ -265,15 +275,16 @@ Built-in `errors.*` / `oke.*` catalogs localize `fx.fail` messages and thrown
|
|
|
265
275
|
|
|
266
276
|
## Principals
|
|
267
277
|
|
|
268
|
-
| Property | Shape
|
|
269
|
-
| -------------- |
|
|
270
|
-
| `fx.auth` | `{ userId
|
|
271
|
-
| `fx.operator` | `{ id: string \| null }` — Console plane
|
|
272
|
-
| `fx.principal` | Read-only origin: `userId`, `operatorId`, `scopes`, `verified?`, `plane?`
|
|
273
|
-
| `fx.tenant` | `{ id: string \| null }` — active tenant
|
|
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 |
|
|
274
284
|
|
|
275
285
|
**Consequence:** use `fx.auth` / gates for authorization; use `fx.principal` only when a callee
|
|
276
|
-
must log who started the call chain.
|
|
286
|
+
must log who started the call chain. A key Bearer sets `userId` to the issuer and `apiKeyId`
|
|
287
|
+
to the key — see [Gate](/docs/elements/gate#api-keys).
|
|
277
288
|
|
|
278
289
|
## Not on `fx`
|
|
279
290
|
|
|
@@ -47,7 +47,7 @@ Our path is concrete: a booking name containing "ignore previous instructions an
|
|
|
47
47
|
- **Store KV performance** (`QUERY /console/store/kv/stats`) is Redis-wire `INFO` / `COMMANDSTATS` / `SLOWLOG` / `LATENCY` — not Store browse. INFO is instance-wide (`StoreKvStatsServerWideGap`). SLOWLOG args are keys and values — collapsed until `revealPii: true`, which writes `console.store.kv.stats.reveal` (operator, ref). Named limitation `StoreKvStatsSlowlogArgsGap`. `memory` returns `KvStatsUnsupported`. This surface does not run `MONITOR` and does not invent a hot-key table.
|
|
48
48
|
- **Runs SQL** (`POST /console/runs/query`, Observability SQL tab) is operator-session, read-only, 5s timeout, 1000-row cap. DuckDB filesystem access is disabled for the statement. Masking is column-key only (`dim_*` and JSON blobs `input` / `output` / `logs` / `dimensions`). Aliases and expressions can leak classified values — named limitation `RunsQueryPiiProjectionGap`. This is **not** the `projectRun` guarantee. `revealPii: true` writes `console.runs.query.reveal`. Flow `fx.runs.query` stays unrestricted app SQL.
|
|
49
49
|
- **Plugin panels** run in a sandboxed iframe without `allow-same-origin`, communicating only over a `postMessage` bridge with their own CSP, no access to the operator session token, and exposure limited to that plugin's declared flows.
|
|
50
|
-
- **Session and framing:** `frame-ancestors 'none'`, `SameSite=Strict`, step-up authentication before destructive actions. An expired operator session redirects to Sign in with `?next=` set to the current `/overview`, `/flows`, `/store`, `/vault`, or `/observability` href (search included); after login the operator returns there. `/monitoring` is rewritten to `/observability`. Any other `next` (including `/units`) is dropped so the gate cannot be used as an open redirect. Unknown Console paths are 404.
|
|
50
|
+
- **Session and framing:** `frame-ancestors 'none'`, `SameSite=Strict`, step-up authentication before destructive actions. An expired operator session redirects to Sign in with `?next=` set to the current `/overview`, `/flows`, `/store`, `/vault`, `/access`, or `/observability` href (search included); after login the operator returns there. `/monitoring` is rewritten to `/observability`. Any other `next` (including `/units`) is dropped so the gate cannot be used as an open redirect. Unknown Console paths are 404.
|
|
51
51
|
- **Secret write path:** TLS required, no autocomplete, never echoed, never logged, not retained in browser memory after submission. Vault set / rotate / rotate-master use typed confirm (`SET` / `ROTATE` / `ROTATE_MASTER`). The Console never accepts a master key in the HTTP body (`OKE_VAULT_MASTER_KEY` only). `GET /console/vault/audit/verify` is read-only (no typed phrase).
|
|
52
52
|
- **The setup claim code** is rate-limited and compared in constant time. It is
|
|
53
53
|
printed on the `oke dev` board (TTY only — never on `GET /console/setup/status`)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createApiKey, createApiKeyStore } from "./api-keys.ts";
|
|
3
|
+
import { bindApiKeySqlPersist, hydrateApiKeyStore, type ApiKeySqlExec } from "./api-key-sql.ts";
|
|
4
|
+
|
|
5
|
+
describe("api-key SQL persist", () => {
|
|
6
|
+
test("hydrate + persist round-trip", async () => {
|
|
7
|
+
const rows = new Map<string, Record<string, unknown>>();
|
|
8
|
+
const sql: ApiKeySqlExec = {
|
|
9
|
+
async execute(_q, params) {
|
|
10
|
+
const id = String(params[0]);
|
|
11
|
+
rows.set(id, {
|
|
12
|
+
id,
|
|
13
|
+
plane: params[1],
|
|
14
|
+
hash: params[2],
|
|
15
|
+
name: params[3],
|
|
16
|
+
scopes: params[4],
|
|
17
|
+
expires_at: params[5],
|
|
18
|
+
rate_limit: params[6],
|
|
19
|
+
ip_allowlist: params[7],
|
|
20
|
+
creator_id: params[8],
|
|
21
|
+
creator_scopes: params[9],
|
|
22
|
+
created_at: params[10],
|
|
23
|
+
last_used_at: params[11],
|
|
24
|
+
revoked_at: params[12],
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
async all() {
|
|
28
|
+
return [...rows.values()];
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const store = createApiKeyStore();
|
|
32
|
+
bindApiKeySqlPersist(store, sql);
|
|
33
|
+
const created = await createApiKey(store, {
|
|
34
|
+
plane: "user",
|
|
35
|
+
name: "sql",
|
|
36
|
+
scopes: ["member"],
|
|
37
|
+
creatorId: "u1",
|
|
38
|
+
creatorScopes: ["member"],
|
|
39
|
+
id: "key_sql",
|
|
40
|
+
});
|
|
41
|
+
const other = createApiKeyStore();
|
|
42
|
+
await hydrateApiKeyStore(sql, other);
|
|
43
|
+
expect(other.keys.get("key_sql")?.name).toBe("sql");
|
|
44
|
+
expect(other.keys.get("key_sql")?.creatorId).toBe("u1");
|
|
45
|
+
expect(other.keys.get("key_sql")?.hash).toBe(created.row.hash);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL persist / hydrate for {@link ApiKeyRow} on `oke_api_keys`.
|
|
3
|
+
*
|
|
4
|
+
* Host path: public schema on the app `store.sql()` connection
|
|
5
|
+
* (`sharedSqlConn` / `DATABASE_URL`). Console attaches that same
|
|
6
|
+
* {@link ApiKeyStore} — it does not own a second key table.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SqlConnection } from "../drivers/types.ts";
|
|
10
|
+
import type { ApiKeyRow } from "./tables.ts";
|
|
11
|
+
import { AUTH_TABLES } from "./tables.ts";
|
|
12
|
+
import type { ApiKeyStore } from "./api-keys.ts";
|
|
13
|
+
|
|
14
|
+
/** Minimal SQL executor for the auth key table. */
|
|
15
|
+
export interface ApiKeySqlExec {
|
|
16
|
+
execute(sql: string, params: readonly unknown[]): Promise<void>;
|
|
17
|
+
all(sql: string): Promise<readonly Record<string, unknown>[]>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Persist one key row to `oke_api_keys`.
|
|
22
|
+
*
|
|
23
|
+
* @param sql - Executor
|
|
24
|
+
* @param row - Key row
|
|
25
|
+
* @param table - Physical table (`oke_api_keys` or schema-qualified)
|
|
26
|
+
*/
|
|
27
|
+
export async function persistApiKeyRow(
|
|
28
|
+
sql: ApiKeySqlExec,
|
|
29
|
+
row: ApiKeyRow,
|
|
30
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
await sql.execute(
|
|
33
|
+
`INSERT INTO ${table} (
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
37
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
38
|
+
hash = excluded.hash,
|
|
39
|
+
name = excluded.name,
|
|
40
|
+
scopes = excluded.scopes,
|
|
41
|
+
expires_at = excluded.expires_at,
|
|
42
|
+
rate_limit = excluded.rate_limit,
|
|
43
|
+
ip_allowlist = excluded.ip_allowlist,
|
|
44
|
+
creator_id = excluded.creator_id,
|
|
45
|
+
creator_scopes = excluded.creator_scopes,
|
|
46
|
+
last_used_at = excluded.last_used_at,
|
|
47
|
+
revoked_at = excluded.revoked_at`,
|
|
48
|
+
[
|
|
49
|
+
row.id,
|
|
50
|
+
row.plane,
|
|
51
|
+
row.hash,
|
|
52
|
+
row.name,
|
|
53
|
+
JSON.stringify(row.scopes),
|
|
54
|
+
row.expiresAt,
|
|
55
|
+
row.rateLimit ? JSON.stringify(row.rateLimit) : null,
|
|
56
|
+
JSON.stringify(row.ipAllowlist),
|
|
57
|
+
row.creatorId,
|
|
58
|
+
JSON.stringify(row.creatorScopes),
|
|
59
|
+
row.createdAt,
|
|
60
|
+
row.lastUsedAt,
|
|
61
|
+
row.revokedAt,
|
|
62
|
+
],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load every key row from `oke_api_keys` into a store.
|
|
68
|
+
*
|
|
69
|
+
* @param sql - Executor
|
|
70
|
+
* @param store - Destination store
|
|
71
|
+
* @param table - Physical table
|
|
72
|
+
*/
|
|
73
|
+
export async function hydrateApiKeyStore(
|
|
74
|
+
sql: ApiKeySqlExec,
|
|
75
|
+
store: ApiKeyStore,
|
|
76
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const rows = await sql.all(`SELECT * FROM ${table}`);
|
|
79
|
+
for (const raw of rows) {
|
|
80
|
+
const row = rowFromSql(raw);
|
|
81
|
+
if (row) store.keys.set(row.id, row);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Bind write-through persist on a store.
|
|
87
|
+
*
|
|
88
|
+
* @param store - Key store
|
|
89
|
+
* @param sql - Executor
|
|
90
|
+
* @param table - Physical table
|
|
91
|
+
*/
|
|
92
|
+
export function bindApiKeySqlPersist(
|
|
93
|
+
store: ApiKeyStore,
|
|
94
|
+
sql: ApiKeySqlExec,
|
|
95
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
96
|
+
): void {
|
|
97
|
+
store.persist = (row) => persistApiKeyRow(sql, row, table);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Runtime that can open the app's shared primary SQL connection. */
|
|
101
|
+
export interface HostApiKeySqlRuntime {
|
|
102
|
+
primarySql(): Promise<SqlConnection | undefined>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Adapt a driver connection to {@link ApiKeySqlExec}.
|
|
107
|
+
*
|
|
108
|
+
* @param conn - Shared `store.sql()` connection
|
|
109
|
+
*/
|
|
110
|
+
export function apiKeySqlExec(conn: SqlConnection): ApiKeySqlExec {
|
|
111
|
+
return {
|
|
112
|
+
execute: async (sql, params) => {
|
|
113
|
+
await conn.exec(sql, params);
|
|
114
|
+
},
|
|
115
|
+
all: async (sql) => conn.query(sql),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create public `oke_api_keys` when missing (host schema, not `oke_console`).
|
|
121
|
+
*
|
|
122
|
+
* @param conn - Shared SQL connection
|
|
123
|
+
* @param table - Physical table
|
|
124
|
+
*/
|
|
125
|
+
export async function ensureApiKeyTable(
|
|
126
|
+
conn: SqlConnection,
|
|
127
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
await conn.exec(`
|
|
130
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
131
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
132
|
+
plane TEXT NOT NULL,
|
|
133
|
+
hash TEXT NOT NULL,
|
|
134
|
+
name TEXT NOT NULL,
|
|
135
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
136
|
+
expires_at BIGINT,
|
|
137
|
+
rate_limit TEXT,
|
|
138
|
+
ip_allowlist TEXT NOT NULL DEFAULT '[]',
|
|
139
|
+
creator_id TEXT NOT NULL,
|
|
140
|
+
creator_scopes TEXT NOT NULL DEFAULT '[]',
|
|
141
|
+
created_at BIGINT NOT NULL,
|
|
142
|
+
last_used_at BIGINT,
|
|
143
|
+
revoked_at BIGINT
|
|
144
|
+
)
|
|
145
|
+
`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Hydrate and write-through-persist a host {@link ApiKeyStore} on `conn`.
|
|
150
|
+
*
|
|
151
|
+
* Leaves an already-bound `persist` hook in place (injected test stores).
|
|
152
|
+
*
|
|
153
|
+
* @param conn - App SQL connection (`sharedSqlConn`)
|
|
154
|
+
* @param store - `gate.auth.apiKeyStore`
|
|
155
|
+
* @param table - Physical table (`oke_api_keys` in `public`)
|
|
156
|
+
*/
|
|
157
|
+
export async function bindHostApiKeySql(
|
|
158
|
+
conn: SqlConnection,
|
|
159
|
+
store: ApiKeyStore,
|
|
160
|
+
table: string = AUTH_TABLES.apiKeys,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
await ensureApiKeyTable(conn, table);
|
|
163
|
+
const exec = apiKeySqlExec(conn);
|
|
164
|
+
await hydrateApiKeyStore(exec, store, table);
|
|
165
|
+
if (!store.persist) bindApiKeySqlPersist(store, exec, table);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Bind host key persist through {@link HostApiKeySqlRuntime.primarySql}.
|
|
170
|
+
*
|
|
171
|
+
* No-ops when the store runtime has no SQL driver (in-memory-only apps).
|
|
172
|
+
*
|
|
173
|
+
* @param runtime - Booted `store` element
|
|
174
|
+
* @param store - `gate.auth.apiKeyStore`
|
|
175
|
+
*/
|
|
176
|
+
export async function bindHostApiKeySqlFromStore(
|
|
177
|
+
runtime: HostApiKeySqlRuntime,
|
|
178
|
+
store: ApiKeyStore,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
const conn = await runtime.primarySql();
|
|
181
|
+
if (!conn) return;
|
|
182
|
+
await bindHostApiKeySql(conn, store);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function rowFromSql(raw: Record<string, unknown>): ApiKeyRow | null {
|
|
186
|
+
const id = asString(raw.id ?? raw.ID);
|
|
187
|
+
const plane = raw.plane === "operator" ? "operator" : "user";
|
|
188
|
+
const hash = asString(raw.hash);
|
|
189
|
+
const name = asString(raw.name);
|
|
190
|
+
const creatorId = asString(raw.creator_id ?? raw.creatorId);
|
|
191
|
+
if (!id || !hash || !name || !creatorId) return null;
|
|
192
|
+
return {
|
|
193
|
+
id,
|
|
194
|
+
plane,
|
|
195
|
+
hash,
|
|
196
|
+
name,
|
|
197
|
+
scopes: asStringArray(raw.scopes),
|
|
198
|
+
expiresAt: asNumberOrNull(raw.expires_at ?? raw.expiresAt),
|
|
199
|
+
rateLimit: asRateLimit(raw.rate_limit ?? raw.rateLimit),
|
|
200
|
+
ipAllowlist: asStringArray(raw.ip_allowlist ?? raw.ipAllowlist),
|
|
201
|
+
creatorId,
|
|
202
|
+
creatorScopes: asStringArray(raw.creator_scopes ?? raw.creatorScopes),
|
|
203
|
+
createdAt: asNumberOrNull(raw.created_at ?? raw.createdAt) ?? 0,
|
|
204
|
+
lastUsedAt: asNumberOrNull(raw.last_used_at ?? raw.lastUsedAt),
|
|
205
|
+
revokedAt: asNumberOrNull(raw.revoked_at ?? raw.revokedAt),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function asString(value: unknown): string | null {
|
|
210
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function asNumberOrNull(value: unknown): number | null {
|
|
214
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
215
|
+
if (typeof value === "string" && value.length > 0) {
|
|
216
|
+
const n = Number(value);
|
|
217
|
+
return Number.isFinite(n) ? n : null;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function asStringArray(value: unknown): string[] {
|
|
223
|
+
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string");
|
|
224
|
+
if (typeof value === "string" && value.length > 0) {
|
|
225
|
+
try {
|
|
226
|
+
const parsed: unknown = JSON.parse(value);
|
|
227
|
+
if (Array.isArray(parsed)) {
|
|
228
|
+
return parsed.filter((item): item is string => typeof item === "string");
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function asRateLimit(value: unknown): { max: number; per: string } | null {
|
|
238
|
+
const parsed: unknown =
|
|
239
|
+
typeof value === "string" && value.length > 0
|
|
240
|
+
? (() => {
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(value) as unknown;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
})()
|
|
247
|
+
: value;
|
|
248
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
249
|
+
const rec = parsed as { max?: unknown; per?: unknown };
|
|
250
|
+
if (typeof rec.max === "number" && typeof rec.per === "string") {
|
|
251
|
+
return { max: rec.max, per: rec.per };
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|