okengine 0.23.0 → 0.23.2
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/ai/mcp.mdx +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "okengine",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.2",
|
|
4
4
|
"description": "One law. Eight elements. One contract. The backend model stays small; operational surfaces are derived from it instead of maintained separately.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -16,7 +16,7 @@ Two genuinely distinct things share the protocol in OKE:
|
|
|
16
16
|
|
|
17
17
|
OKE does not create separate security models for users, operators, and agents. They enter the same execution model through different triggers and planes.
|
|
18
18
|
|
|
19
|
-
The server on **6535** speaks JSON-RPC over HTTP
|
|
19
|
+
The server on **6535** speaks JSON-RPC over HTTP. It implements `initialize`, `ping`, `tools/list`, and `tools/call`, and advertises protocol `2024-11-05`. A client that sends another `protocolVersion` is rejected. An omitted version gets `2024-11-05`. It requires a Bearer token **even on localhost**, and never forwards that token upstream — adapters receive structured operator ids instead. The outbound client that calls external MCP servers probes `2026-07-28` (`_meta` on each request) and falls back to a `2024-11-05` `initialize`. That probe is not what this server advertises.
|
|
20
20
|
|
|
21
21
|
<Callout title="The one rule">
|
|
22
22
|
MCP inherits the operator's capability and can never exceed it. Server-level controls alone are
|
|
@@ -52,16 +52,16 @@ Two actions are write-class, and each call needs a **fresh, single-use** confirm
|
|
|
52
52
|
<Steps>
|
|
53
53
|
|
|
54
54
|
<Step>
|
|
55
|
-
###
|
|
55
|
+
### Ask for confirmation
|
|
56
56
|
|
|
57
|
-
`
|
|
57
|
+
The write returns an opaque `confirmationId`. It does not return a token. Confirm from a **different auth session** (`claims.sid`) than the session that will invoke. The same principal is allowed. A confirmation issued on another process is unknown.
|
|
58
58
|
|
|
59
59
|
</Step>
|
|
60
60
|
|
|
61
61
|
<Step>
|
|
62
62
|
### Call the write tool
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
`oke.action.confirm(confirmationId, reason)` returns a single-use token. Pass it as `confirmToken` plus the phrase `CONFIRM`. The invoking session must be the one that asked, and it must not be the session that confirmed.
|
|
65
65
|
|
|
66
66
|
</Step>
|
|
67
67
|
|
|
@@ -247,7 +247,7 @@ overrides). Manifest stays free of copy.
|
|
|
247
247
|
| Method | Capability / `sends` | Meaning |
|
|
248
248
|
| -------------------------- | -------------------- | ------------------------------------------- |
|
|
249
249
|
| `fx.send(template, opts?)` | template name | Deliver through the medium’s driver chain |
|
|
250
|
-
| `fx.sendOtp(opts)` | `"sms-otp"` | Provider-managed SMS OTP
|
|
250
|
+
| `fx.sendOtp(opts)` | `"sms-otp"` | Provider-managed SMS OTP. |
|
|
251
251
|
| `fx.verifyOtp(opts)` | `"sms-otp"` | Check a provider OTP code |
|
|
252
252
|
| `fx.deliverOtp(opts)` | `"auth-otp"` | App-owned OTP across email / SMS / WhatsApp |
|
|
253
253
|
|
|
@@ -351,9 +351,9 @@ Env knobs: [Environment variables](/docs/reference/environment-variables). Local
|
|
|
351
351
|
</Accordion>
|
|
352
352
|
|
|
353
353
|
<Accordion title="Process-local suppression / receipts warning">
|
|
354
|
-
Boot warns: `Channel suppression/consent/receipts default to process-local memory…`
|
|
355
|
-
|
|
356
|
-
See [Receipts](/docs/elements/channel/receipts).
|
|
354
|
+
Boot warns: `Channel suppression/consent/receipts default to process-local memory…` That is
|
|
355
|
+
`test`, or a boot with no `DATABASE_URL`. Otherwise consent, suppression, and receipts are the
|
|
356
|
+
Postgres ledger. See [Receipts](/docs/elements/channel/receipts).
|
|
357
357
|
</Accordion>
|
|
358
358
|
|
|
359
359
|
</Accordions>
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Receipts"
|
|
3
|
-
description: "Delivery ledger, seven-state outcomes, consent opt-out, and hard-bounce suppression
|
|
3
|
+
description: "Delivery ledger, seven-state outcomes, consent opt-out, and hard-bounce suppression."
|
|
4
4
|
icon: "ReceiptText"
|
|
5
5
|
source: "docs/spec/unified-theory.md"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
Every Channel send records a **receipt** — success (`sent` / `fallback`), suppression, or a
|
|
9
9
|
classified failure. Provider bounces and complaints update that ledger through normalized
|
|
10
|
-
outcomes. There is no `
|
|
10
|
+
outcomes. There is no `fx.channel.getReceipt` helper. Outside `test`, boot stores consent,
|
|
11
|
+
suppression, and receipts in Postgres (`oke_channel_consent`, `oke_channel_bounce`,
|
|
12
|
+
`oke_channel_receipt`) when `DATABASE_URL` is set. `test` stays in memory.
|
|
11
13
|
|
|
12
14
|
For operators watching deliverability — Console projects the ledger; Flows only see send
|
|
13
15
|
results via `fx.send`’s `{ ok: true }` gate.
|
|
14
16
|
|
|
15
17
|
<Callout title="The one rule">
|
|
16
|
-
Consent and prior hard bounces suppress **before** any driver runs.
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
Consent and prior hard bounces suppress **before** any driver runs. With `DATABASE_URL` outside
|
|
19
|
+
`test`, those rows are shared across instances. `test`, and any boot without a SQL URL, keep
|
|
20
|
+
process-local memory and still warn.
|
|
19
21
|
</Callout>
|
|
20
22
|
|
|
21
23
|
## Smallest Example
|
|
@@ -7,7 +7,8 @@ source: "docs/spec/unified-theory.md"
|
|
|
7
7
|
|
|
8
8
|
SMS (`channel.sms`) delivers short text and provider-managed one-time codes. Pin an SMS driver
|
|
9
9
|
(no default in any env), declare templates for app-owned messages, or call `fx.sendOtp` when the
|
|
10
|
-
vendor owns the code.
|
|
10
|
+
vendor owns the code. `fx.sendOtp` and `fx.verifyOtp` stay on `fx`. The `mena` plugin opens the
|
|
11
|
+
same providers when you want that packaging.
|
|
11
12
|
|
|
12
13
|
For developers verifying phones — choose raw Channel OTP vs the [`otp`](/docs/plugins/otp) plugin.
|
|
13
14
|
|
|
@@ -220,17 +220,17 @@ Second argument to `flow(name, options)` — or the only argument to a nameless
|
|
|
220
220
|
Invoke contracts (`in` / `out` / `errors` / `breaking`) belong on the exposure — see
|
|
221
221
|
[Contracts](#contracts) below.
|
|
222
222
|
|
|
223
|
-
| Option | Type | Default | Meaning
|
|
224
|
-
| -------------- | -------------------------------------- | ------------------------- |
|
|
225
|
-
| `do` | `(input, fx) => output \| FlowFailure` | _(required)_ | Handler. Missing `do` throws `flow() expected an options bag with a do handler`
|
|
226
|
-
| `durable` | `boolean` | `false` | Journal `fx.step` / sleep / gated `fx` calls
|
|
227
|
-
| `retry` | `FxRetryOptions` | omitted | Whole-`do` retry on throw (same journal when durable)
|
|
228
|
-
| `cache` | `boolean \| string` | omitted (
|
|
229
|
-
| `compensate` | `(ctx, fx) => unknown` | omitted | After LIFO `{ undo }`, before the run commits `failed`
|
|
230
|
-
| `plane` | `"user" \| "operator"` | `"user"` | Operator bypasses RLS; user must not `fx.call` operator
|
|
231
|
-
| `effects` | `Effects` | inferred | Capability token — write this only when inference cannot see the body
|
|
232
|
-
| `slo` | `{ availability?, latency? }` | omitted | Manifest metadata (Console / docs)
|
|
233
|
-
| `tenantScoped` | `boolean` | `true` when tenancy is on | `false` skips tenant-role scope union
|
|
223
|
+
| Option | Type | Default | Meaning |
|
|
224
|
+
| -------------- | -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
225
|
+
| `do` | `(input, fx) => output \| FlowFailure` | _(required)_ | Handler. Missing `do` throws `flow() expected an options bag with a do handler` |
|
|
226
|
+
| `durable` | `boolean` | `false` | Journal `fx.step` / sleep / gated `fx` calls |
|
|
227
|
+
| `retry` | `FxRetryOptions` | omitted | Whole-`do` retry on throw (same journal when durable) |
|
|
228
|
+
| `cache` | `boolean \| string` | omitted (on when pure) | Pure store reads cache; `false` disables; `"30s"` sets a TTL; app `cache: { auto: false }` turns the default off |
|
|
229
|
+
| `compensate` | `(ctx, fx) => unknown` | omitted | After LIFO `{ undo }`, before the run commits `failed` |
|
|
230
|
+
| `plane` | `"user" \| "operator"` | `"user"` | Operator bypasses RLS; user must not `fx.call` operator |
|
|
231
|
+
| `effects` | `Effects` | inferred | Capability token — write this only when inference cannot see the body |
|
|
232
|
+
| `slo` | `{ availability?, latency? }` | omitted | Manifest metadata (Console / docs) |
|
|
233
|
+
| `tenantScoped` | `boolean` | `true` when tenancy is on | `false` skips tenant-role scope union |
|
|
234
234
|
|
|
235
235
|
**Consequence:** `durable: true` disables automatic read-cache for that Flow.
|
|
236
236
|
|
|
@@ -438,12 +438,12 @@ durable Flow, or split with `fx.emit`. See [Workflows](/docs/elements/flow/workf
|
|
|
438
438
|
|
|
439
439
|
<Tab value="Cache">
|
|
440
440
|
|
|
441
|
-
|
|
442
|
-
|
|
441
|
+
A pure store-read Flow caches on its own. A send, emit, fetch, vault read, ask,
|
|
442
|
+
decide, or `fx.call` is never cached. The key includes the flow, the input, the
|
|
443
|
+
user, the tenant, the locale, and the caller's scopes and membership roles.
|
|
443
444
|
|
|
444
445
|
```typescript
|
|
445
446
|
flow("catalog.get", {
|
|
446
|
-
cache: "30s",
|
|
447
447
|
do: async ({ id }, fx) => {
|
|
448
448
|
const [row] = await fx.store(db).select().from(products).where(eq(products.id, id));
|
|
449
449
|
return row;
|
|
@@ -451,7 +451,10 @@ flow("catalog.get", {
|
|
|
451
451
|
});
|
|
452
452
|
```
|
|
453
453
|
|
|
454
|
-
`cache: false`
|
|
454
|
+
`cache: false` always disables. A duration string adds TTL on top of write
|
|
455
|
+
invalidation. `oke({ cache: { auto: false } })` turns the default off; a Flow
|
|
456
|
+
can still set `cache: true` or `"30s"`. Cross-instance invalidation and writes
|
|
457
|
+
that skip `fx` are not covered.
|
|
455
458
|
|
|
456
459
|
</Tab>
|
|
457
460
|
|
|
@@ -570,9 +573,10 @@ Default is `true` once `gate.auth.tenant` is on.
|
|
|
570
573
|
</Accordion>
|
|
571
574
|
|
|
572
575
|
<Accordion title="Read-only Flow never cache-hits">
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
`
|
|
576
|
+
A pure Store read caches unless the Flow sets `cache: false` or the app sets
|
|
577
|
+
`cache: { auto: false }`. A send, emit, fetch, secret, ask, decide, call, or write
|
|
578
|
+
is never cached, and `durable: true` is never cached. The key includes tenant,
|
|
579
|
+
locale, scopes, and roles, so a hit for one caller is a miss for another.
|
|
576
580
|
</Accordion>
|
|
577
581
|
|
|
578
582
|
<Accordion title='cross-plane call: user flow "…" calls operator flow "…"'>
|
|
@@ -12,8 +12,10 @@ For developers invalidating caches or syncing in-process state — declare the S
|
|
|
12
12
|
more `on(signal, flow)` subscribers, emit with `fx.emit`.
|
|
13
13
|
|
|
14
14
|
<Callout title="The one rule">
|
|
15
|
-
Broadcast is ephemeral.
|
|
16
|
-
receive
|
|
15
|
+
Broadcast is ephemeral. Each running instance handles a message once. If a subscriber is offline
|
|
16
|
+
during the emit, it does not receive that event. Postgres delivery is best-effort within `lagMs`
|
|
17
|
+
(default 30 seconds): a transaction that commits later than that can be missed. Use
|
|
18
|
+
[`live`](/docs/elements/signal/live) when clients need replay. Use
|
|
17
19
|
[`once`](/docs/elements/signal/once) when exactly one worker must claim the job.
|
|
18
20
|
</Callout>
|
|
19
21
|
|
|
@@ -331,11 +331,11 @@ Omit `key` for a pure competing pool with no ordering.
|
|
|
331
331
|
Protocol ids: `memory` · `redis` · `postgres` · `nats`. Defaults (when `oke.config.ts` omits
|
|
332
332
|
`drivers.signal`):
|
|
333
333
|
|
|
334
|
-
| Env | Default | Boot today
|
|
335
|
-
| ------ | -------- |
|
|
336
|
-
| `dev` | `redis` | Emit relays to Redis
|
|
337
|
-
| `test` | `memory` | In-process bus
|
|
338
|
-
| `prod` | `redis` | Same redis
|
|
334
|
+
| Env | Default | Boot today |
|
|
335
|
+
| ------ | -------- | -------------------------------------------------------------------------------- |
|
|
336
|
+
| `dev` | `redis` | Emit relays to Redis. `once` consume is a consumer group (`XREADGROUP` / `XACK`) |
|
|
337
|
+
| `test` | `memory` | In-process bus |
|
|
338
|
+
| `prod` | `redis` | Same redis path as `dev` |
|
|
339
339
|
|
|
340
340
|
```typescript title="oke.config.ts"
|
|
341
341
|
export default defineConfig({
|
|
@@ -345,8 +345,10 @@ export default defineConfig({
|
|
|
345
345
|
});
|
|
346
346
|
```
|
|
347
347
|
|
|
348
|
-
`postgres`
|
|
349
|
-
`
|
|
348
|
+
`postgres` binds Bun.SQL and the scheduler polls `drain` (`FOR UPDATE SKIP LOCKED`). Bun.SQL has no
|
|
349
|
+
`LISTEN` / `NOTIFY`. `nats` still fails loud at boot — never silently fall back to `memory`. Prefer
|
|
350
|
+
`memory` for tests; pin `redis` when Compose provides Redis. Live history stays on the emitting
|
|
351
|
+
process; `once` is the competing path.
|
|
350
352
|
|
|
351
353
|
## Troubleshooting
|
|
352
354
|
|
|
@@ -383,15 +385,14 @@ export default defineConfig({
|
|
|
383
385
|
consumers](#competing-consumers-once-vs-broadcast).
|
|
384
386
|
</Accordion>
|
|
385
387
|
|
|
386
|
-
<Accordion title='oke boot: signal driver "postgres"
|
|
387
|
-
|
|
388
|
-
|
|
388
|
+
<Accordion title='oke boot: signal driver "postgres" needs DATABASE_URL'>
|
|
389
|
+
`drivers.signal` is `postgres` and neither `DATABASE_URL` nor `OKE_STORE_SQL_URL` is set. Boot
|
|
390
|
+
polls `drain` on that connection. There is no `LISTEN` / `NOTIFY` requirement.
|
|
389
391
|
</Accordion>
|
|
390
392
|
|
|
391
|
-
<Accordion title=
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
instance) until Redis Streams consume ships.
|
|
393
|
+
<Accordion title='oke boot: signal driver "nats"'>
|
|
394
|
+
`nats` has no production client yet. Use `"memory"`, `"redis"`, or `"postgres"`, or inject a
|
|
395
|
+
custom `elements.signal` runtime.
|
|
395
396
|
</Accordion>
|
|
396
397
|
|
|
397
398
|
</Accordions>
|
|
@@ -22,9 +22,9 @@ app-owned delivery across the channels you declare.
|
|
|
22
22
|
</Callout>
|
|
23
23
|
|
|
24
24
|
<Callout type="info" title="fx.sendOtp vs otp()">
|
|
25
|
-
`fx.sendOtp` / `fx.verifyOtp` are
|
|
26
|
-
build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path
|
|
27
|
-
|
|
25
|
+
`fx.sendOtp` / `fx.verifyOtp` / `fx.deliverOtp` are supported Channel methods. Direct use means
|
|
26
|
+
you build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path.
|
|
27
|
+
The `mena` plugin (`menaChannels`) is an additional way to open the same providers.
|
|
28
28
|
</Callout>
|
|
29
29
|
|
|
30
30
|
## Quick start
|
|
@@ -237,6 +237,22 @@ Domain schema sync for `oke db push | generate | migrate` (Drizzle). Unrelated t
|
|
|
237
237
|
| `console` | `6533` | Console |
|
|
238
238
|
| `mcp` | `6535` | MCP |
|
|
239
239
|
|
|
240
|
+
## App options
|
|
241
|
+
|
|
242
|
+
These sit on `oke({ ... })`, not in `oke.config.ts`.
|
|
243
|
+
|
|
244
|
+
| Option | Default | Meaning |
|
|
245
|
+
| -------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
246
|
+
| `codeVersion` | unset | Opt-in durable stamp `app:<version>`. Unset checks nothing. A stored `0.23.1`-style stamp is compatible and restamped. A different `app:` stamp fails the run with OKE1076. |
|
|
247
|
+
| `maxRequestBodySize` | `1048576` | Body cap in bytes. A route `maxBodySize` overrides it. Bun's listen backstop uses the same number. |
|
|
248
|
+
| `cache.maxEntries` | `10000` | Auto-cache entry cap. |
|
|
249
|
+
| `cache.defaultTtlMs` | `60000` | Auto-cache TTL when the flow does not set `cache: "5m"`. |
|
|
250
|
+
| `cache.auto` | on | `false` turns automatic caching off. A flow can still opt in. |
|
|
251
|
+
|
|
252
|
+
Postgres broadcast and live poll with `lagMs`, default `30000`. That window is best-effort, not a gap-free cursor.
|
|
253
|
+
|
|
254
|
+
A write drops the auto-cache on every instance. With Redis configured, the notice is published on `oke:cache:invalidate`. Otherwise a Postgres app polls `oke_cache_invalidations`. One process does not broadcast.
|
|
255
|
+
|
|
240
256
|
## console
|
|
241
257
|
|
|
242
258
|
| Option | Type | Default | Meaning |
|
|
@@ -117,25 +117,37 @@ The typed client always includes built-in codes. A declared key still wins.
|
|
|
117
117
|
A bare `404` with body `Not Found` means **no route matched**. `fx.fail.notFound` is a JSON envelope
|
|
118
118
|
at **404**. Clients distinguish by envelope, not status. Domain codes stay **400**.
|
|
119
119
|
|
|
120
|
-
| Code | Status | Helper
|
|
121
|
-
| ------------------------------------------------ | ----------- |
|
|
122
|
-
| `ValidationError` | `422` | none — failed `in`, `do` never runs
|
|
123
|
-
| `IdempotencyKeyMissing` | `400` | none — `idempotency: "required"` and no header
|
|
124
|
-
| `IdempotencyKeyInvalid` | `400` | none — header is not 16–255 printable ASCII
|
|
125
|
-
| `IdempotencyKeyReused` | `422` | none — same key, different payload
|
|
126
|
-
| `IdempotencyInProgress` | `409` | none — `Retry-After` until the in-flight call finishes
|
|
127
|
-
| `Unauthorized` | `401` | `unauthorized` · also a gate denial
|
|
128
|
-
| `Forbidden` | `403` | `forbidden` · also a gate denial
|
|
129
|
-
| `NotFound` | `404` | `notFound`
|
|
130
|
-
| `Conflict` / `ForeignKey` | `409` | `conflict` / `foreignKey`
|
|
131
|
-
| `UnsupportedMediaType` | `415` | none — QUERY
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
|
|
|
120
|
+
| Code | Status | Helper |
|
|
121
|
+
| ------------------------------------------------ | ----------- | ---------------------------------------------------------------------------- |
|
|
122
|
+
| `ValidationError` | `422` | none — failed `in`, `do` never runs |
|
|
123
|
+
| `IdempotencyKeyMissing` | `400` | none — `idempotency: "required"` and no header |
|
|
124
|
+
| `IdempotencyKeyInvalid` | `400` | none — header is not 16–255 printable ASCII |
|
|
125
|
+
| `IdempotencyKeyReused` | `422` | none — same key, different payload |
|
|
126
|
+
| `IdempotencyInProgress` | `409` | none — `Retry-After` until the in-flight call finishes |
|
|
127
|
+
| `Unauthorized` | `401` | `unauthorized` · also a gate denial |
|
|
128
|
+
| `Forbidden` | `403` | `forbidden` · also a gate denial |
|
|
129
|
+
| `NotFound` | `404` | `notFound` |
|
|
130
|
+
| `Conflict` / `ForeignKey` | `409` | `conflict` / `foreignKey` |
|
|
131
|
+
| `UnsupportedMediaType` | `415` | none — QUERY, or a JSON-shaped body that is not `application/json` / `+json` |
|
|
132
|
+
| `PayloadTooLarge` | `413` | none — body exceeds `maxRequestBodySize` or the route `maxBodySize` |
|
|
133
|
+
| `RateLimited` / `AuthRateLimited` | `429` | `rateLimited` · `AuthRateLimited` has none |
|
|
134
|
+
| `InvalidQuery` | `400` | none — QUERY or JSON body that does not parse (`malformed_body`) |
|
|
135
|
+
| `AuthFailed` | `400` | none — credentials / policy, not “no session” |
|
|
136
|
+
| `DatabaseError` | by `reason` | `database` |
|
|
137
|
+
| `ServiceUnavailable` | `503` | `serviceUnavailable` |
|
|
138
|
+
| `InternalError` | `500` | `internal` — never copies a thrown `message` |
|
|
139
|
+
| Domain (`OutOfStock`, `FlightFull`, `Duplicate`) | `400` | `fx.fail("OutOfStock", data)` |
|
|
140
|
+
|
|
141
|
+
`UnsupportedMediaType` (415) is this rule, for a non-empty body. Let `mt` be the media type before `;`:
|
|
142
|
+
|
|
143
|
+
1. `application/json` or `application/*+json` is parsed. A parse failure is 400 `InvalidQuery` with reason `malformed_body`.
|
|
144
|
+
2. A route with `jsonContentType: "any"` tries JSON and otherwise keeps the raw string.
|
|
145
|
+
3. `text/plain`, form-urlencoded, multipart, or a missing type, when the trimmed body starts with `{` or `[` and parses as JSON, is 415.
|
|
146
|
+
4. Anything else is the raw string. `curl -d` needs `-H 'content-type: application/json'`.
|
|
147
|
+
|
|
148
|
+
`OKE1074` means the durable run lost its lease. The step result is not written and compensation does not run. The caller sees a busy lease, not a new failure of the work.
|
|
149
|
+
|
|
150
|
+
`OKE1076` means `codeVersion` was set and the stored stamp is a different `app:` value. Leave the old value pinned until sleeping runs drain, then change it. Unset `codeVersion` checks nothing. A stored stamp from 0.23.1 (no `app:` prefix) is restamped, not failed.
|
|
139
151
|
|
|
140
152
|
### Client chrome
|
|
141
153
|
|
|
@@ -158,7 +158,13 @@ on(
|
|
|
158
158
|
| `fx.auth.upsertTenantRole({ tenantId, roleName, scopes })` | `write` `auth:tenants` | Application scopes only — `console:*` is unknown_scope |
|
|
159
159
|
|
|
160
160
|
`fx.call` starts the callee with an **empty** `fx.auth` (fail-closed) and propagates `fx.tenant.id`.
|
|
161
|
-
Read `fx.principal` for audit — it does not copy into `fx.auth`.
|
|
161
|
+
Read `fx.principal` for audit — it does not copy into `fx.auth`. Passing a Flow handle types
|
|
162
|
+
`input` and the result. A string name stays `Promise<unknown>`.
|
|
163
|
+
|
|
164
|
+
`fx.store(db).transaction(async (tx) => { … })` pins one SQL connection. `fx.emit` inside the
|
|
165
|
+
callback stages on the signal outbox and publishes after commit. A throw rolls that batch back.
|
|
166
|
+
`fx.store(db).run(builder)` runs a Drizzle builder through `toSQL()`. Joins stay on that builder;
|
|
167
|
+
the hand-rolled chain does not grow a join API. Inference still records every joined table.
|
|
162
168
|
|
|
163
169
|
<Callout>
|
|
164
170
|
Gates never consult `fx.principal`. `return fx.fail(...)` is a `FlowFailure`; an unhandled `throw`
|
|
@@ -281,9 +287,11 @@ Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400
|
|
|
281
287
|
|
|
282
288
|
## Cache
|
|
283
289
|
|
|
284
|
-
|
|
285
|
-
`fx.
|
|
286
|
-
|
|
290
|
+
Pure store-read flows cache by default. A send, emit, fetch, vault read, ask,
|
|
291
|
+
decide, or `fx.call` is never cached. The key includes tenant, locale, scopes,
|
|
292
|
+
and membership roles. Writes through `fx` invalidate those keys. `cache: false`
|
|
293
|
+
always disables. `oke({ cache: { auto: false } })` turns the default off.
|
|
294
|
+
Cross-instance invalidation and out-of-band writes are not covered.
|
|
287
295
|
|
|
288
296
|
`fx.cache` is the manual (tier-3) surface:
|
|
289
297
|
|
package/src/auth/tenants.ts
CHANGED
|
@@ -359,3 +359,20 @@ export function tenantScopesForMember(
|
|
|
359
359
|
const role = store.roles.get(roleKey(tenantId, member.role));
|
|
360
360
|
return role?.scopes ?? [];
|
|
361
361
|
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Membership role name for the tier-1 cache key.
|
|
365
|
+
*
|
|
366
|
+
* Same member lookup as {@link tenantScopesForMember}. Not an authorization input.
|
|
367
|
+
*
|
|
368
|
+
* @param store - Tenant store
|
|
369
|
+
* @param tenantId - Tenant id
|
|
370
|
+
* @param userId - User id
|
|
371
|
+
*/
|
|
372
|
+
export function tenantRoleForMember(
|
|
373
|
+
store: TenantStore,
|
|
374
|
+
tenantId: string,
|
|
375
|
+
userId: string,
|
|
376
|
+
): string | null {
|
|
377
|
+
return getMember(store, tenantId, userId)?.role ?? null;
|
|
378
|
+
}
|
|
@@ -7,7 +7,7 @@ import { mkdir, rm } from "node:fs/promises";
|
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import type { Manifest } from "../manifest/types.ts";
|
|
9
9
|
import { parseManifest } from "../manifest/validate.ts";
|
|
10
|
-
import { runDoctorDiff } from "./doctor-diff.ts";
|
|
10
|
+
import { formatManifestDiffComment, runDoctorDiff } from "./doctor-diff.ts";
|
|
11
11
|
|
|
12
12
|
const baseUrl = new URL("../manifest/fixtures/base.manifest.json", import.meta.url);
|
|
13
13
|
|
|
@@ -84,4 +84,32 @@ describe("oke doctor --diff", () => {
|
|
|
84
84
|
await rm(dir, { recursive: true, force: true });
|
|
85
85
|
}
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
test("comment lists contract, permission, and effect changes", () => {
|
|
89
|
+
const body = formatManifestDiffComment([
|
|
90
|
+
{
|
|
91
|
+
path: "flows.a",
|
|
92
|
+
category: "contract-breaking",
|
|
93
|
+
kind: "changed",
|
|
94
|
+
summary: "out schema changed",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
path: "flows.b",
|
|
98
|
+
category: "permission-widening",
|
|
99
|
+
kind: "changed",
|
|
100
|
+
summary: "gate removed",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
path: "flows.c",
|
|
104
|
+
category: "effect-widening",
|
|
105
|
+
kind: "changed",
|
|
106
|
+
summary: "writes grew",
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
expect(body).toContain("Contract breaks");
|
|
110
|
+
expect(body).toContain("Permission widening");
|
|
111
|
+
expect(body).toContain("Effect widening");
|
|
112
|
+
expect(body).toContain("out schema changed");
|
|
113
|
+
expect(body).toContain("widening are comments only");
|
|
114
|
+
});
|
|
87
115
|
});
|
package/src/cli/doctor-diff.ts
CHANGED
|
@@ -55,6 +55,41 @@ export interface DoctorDiffResult {
|
|
|
55
55
|
readonly allChanges: readonly ManifestChange[];
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
const COMMENT_CATEGORIES = ["contract-breaking", "permission-widening", "effect-widening"] as const;
|
|
59
|
+
|
|
60
|
+
const CATEGORY_LABEL: Record<(typeof COMMENT_CATEGORIES)[number], string> = {
|
|
61
|
+
"contract-breaking": "Contract breaks",
|
|
62
|
+
"permission-widening": "Permission widening",
|
|
63
|
+
"effect-widening": "Effect widening",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Plain-language pull-request comment for the three behavioural categories.
|
|
68
|
+
* Widening is informational. Contract breaks are the lines that fail CI
|
|
69
|
+
* when they are undeclared.
|
|
70
|
+
*
|
|
71
|
+
* @param changes - {@link diffManifest} changes
|
|
72
|
+
*/
|
|
73
|
+
export function formatManifestDiffComment(changes: readonly ManifestChange[]): string {
|
|
74
|
+
const lines = ["## Manifest diff", ""];
|
|
75
|
+
for (const category of COMMENT_CATEGORIES) {
|
|
76
|
+
const rows = changes.filter((change) => change.category === category);
|
|
77
|
+
lines.push(`### ${CATEGORY_LABEL[category]}`);
|
|
78
|
+
if (rows.length === 0) {
|
|
79
|
+
lines.push("None.");
|
|
80
|
+
} else {
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
lines.push(`- \`${row.path}\` — ${row.summary}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
lines.push("");
|
|
86
|
+
}
|
|
87
|
+
lines.push(
|
|
88
|
+
"Undeclared contract breaks fail the check. Permission and effect widening are comments only. Acknowledge an intentional break with `breaking: true` on the HTTP, call, MCP, or resource contract.",
|
|
89
|
+
);
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
58
93
|
/**
|
|
59
94
|
* Diff two manifests and fail on undeclared contract breaks.
|
|
60
95
|
*
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull-request manifest diff.
|
|
3
|
+
*
|
|
4
|
+
* Writes the comment body, then exits non-zero only for undeclared
|
|
5
|
+
* contract breaks. Widening changes stay in the comment.
|
|
6
|
+
*
|
|
7
|
+
* bun src/cli/manifest-pr-diff.ts comment.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { writeFileSync } from "node:fs";
|
|
11
|
+
import { formatManifestDiffComment, runDoctorDiff } from "./doctor-diff.ts";
|
|
12
|
+
|
|
13
|
+
const out = process.argv[2] ?? "manifest-diff.md";
|
|
14
|
+
const result = await runDoctorDiff({ write: () => {} });
|
|
15
|
+
writeFileSync(out, formatManifestDiffComment(result.allChanges));
|
|
16
|
+
process.exit(result.code);
|
package/src/compiler/aot.test.ts
CHANGED
|
@@ -97,6 +97,7 @@ describe("compileAot", () => {
|
|
|
97
97
|
const bad = await compiled.parseValidate(
|
|
98
98
|
new Request("http://localhost/bookings", {
|
|
99
99
|
method: "POST",
|
|
100
|
+
headers: { "content-type": "application/json" },
|
|
100
101
|
body: JSON.stringify({ flightId: "SK1", seats: 0 }),
|
|
101
102
|
}),
|
|
102
103
|
{},
|
|
@@ -235,9 +236,9 @@ describe("AoT throughput ≥ 1.5× dynamic", () => {
|
|
|
235
236
|
}
|
|
236
237
|
|
|
237
238
|
const iterations = 4_000;
|
|
238
|
-
// Best of trials — single wall-clock ratio
|
|
239
|
+
// Best of trials — a single wall-clock ratio moves under load.
|
|
239
240
|
let best = 0;
|
|
240
|
-
for (let trial = 0; trial <
|
|
241
|
+
for (let trial = 0; trial < 8; trial++) {
|
|
241
242
|
const t0 = performance.now();
|
|
242
243
|
for (let i = 0; i < iterations; i++) {
|
|
243
244
|
await aot.parseValidate(makeReq(), {});
|
package/src/compiler/aot.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* {@link compileDynamic} / `aot: false` on edge runtimes that ban `eval`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { fail } from "../kernel/errors.ts";
|
|
9
10
|
import { validate, type SchemaInput } from "../validation/standard-schema.ts";
|
|
10
11
|
import {
|
|
11
12
|
assembleInput,
|
|
13
|
+
HttpBodyRejected,
|
|
12
14
|
parseBody,
|
|
13
15
|
parseCookie,
|
|
14
16
|
parseHeaders,
|
|
@@ -33,6 +35,8 @@ export interface CompileRouteOptions {
|
|
|
33
35
|
readonly hooks?: ReadonlyArray<(...args: never[]) => unknown>;
|
|
34
36
|
/** Input schema (Standard Schema when present). */
|
|
35
37
|
readonly schema?: SchemaInput | undefined;
|
|
38
|
+
/** Body cap and JSON content-type policy. */
|
|
39
|
+
readonly body?: import("./http-parse.ts").ParseBodyOptions;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/** Bundle returned by the compilers. */
|
|
@@ -55,6 +59,16 @@ interface AotHelpers {
|
|
|
55
59
|
parseCookie: typeof parseCookie;
|
|
56
60
|
assembleInput: typeof assembleInput;
|
|
57
61
|
validate: typeof validate;
|
|
62
|
+
readonly body?: import("./http-parse.ts").ParseBodyOptions;
|
|
63
|
+
readBody: (
|
|
64
|
+
request: Request,
|
|
65
|
+
) => Promise<
|
|
66
|
+
{ ok: true; value: unknown } | { ok: false; failure: import("../kernel/errors.ts").FlowFailure }
|
|
67
|
+
>;
|
|
68
|
+
bodyFailure: (
|
|
69
|
+
err: unknown,
|
|
70
|
+
request: Request,
|
|
71
|
+
) => { ok: false; failure: import("../kernel/errors.ts").FlowFailure } | undefined;
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
/**
|
|
@@ -82,6 +96,34 @@ export function compileAot(options: CompileRouteOptions): CompiledRoute {
|
|
|
82
96
|
parseCookie,
|
|
83
97
|
assembleInput,
|
|
84
98
|
validate,
|
|
99
|
+
body: options.body,
|
|
100
|
+
async readBody(request: Request) {
|
|
101
|
+
try {
|
|
102
|
+
return { ok: true as const, value: await parseBody(request, options.body) };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const rejected = this.bodyFailure(err, request);
|
|
105
|
+
if (rejected) return rejected;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
bodyFailure(err, request) {
|
|
110
|
+
if (!(err instanceof HttpBodyRejected)) return undefined;
|
|
111
|
+
if (err.code === "InvalidQuery") {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
failure: fail("InvalidQuery", { reason: err.reason ?? "malformed_body" }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (err.code === "UnsupportedMediaType") {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
failure: fail("UnsupportedMediaType", {
|
|
121
|
+
contentType: request.headers.get("content-type") ?? "",
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { ok: false, failure: fail("PayloadTooLarge", {}) };
|
|
126
|
+
},
|
|
85
127
|
};
|
|
86
128
|
|
|
87
129
|
try {
|
|
@@ -122,7 +164,9 @@ function generateParseValidate(
|
|
|
122
164
|
lines.push("parts.cookie = helpers.parseCookie(request);");
|
|
123
165
|
}
|
|
124
166
|
if (inference.body) {
|
|
125
|
-
lines.push("
|
|
167
|
+
lines.push("const bodyResult = await helpers.readBody(request);");
|
|
168
|
+
lines.push("if (bodyResult.ok === false) return bodyResult;");
|
|
169
|
+
lines.push("parts.body = bodyResult.value;");
|
|
126
170
|
}
|
|
127
171
|
|
|
128
172
|
lines.push("const raw = helpers.assembleInput(parts);");
|
package/src/compiler/dynamic.ts
CHANGED
|
@@ -28,7 +28,7 @@ function loadAot(): typeof import("./aot.ts") {
|
|
|
28
28
|
export function compileDynamic(options: CompileRouteOptions): CompiledRoute {
|
|
29
29
|
return {
|
|
30
30
|
inference: FULL_INFERENCE,
|
|
31
|
-
parseValidate: createInterpretedParseValidate(FULL_INFERENCE, options.schema),
|
|
31
|
+
parseValidate: createInterpretedParseValidate(FULL_INFERENCE, options.schema, options.body),
|
|
32
32
|
aot: false,
|
|
33
33
|
};
|
|
34
34
|
}
|