okengine 0.2.8 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/AGENTS.md +17 -15
  2. package/README.md +59 -31
  3. package/package.json +11 -16
  4. package/site/content/docs/ai/llms-txt.mdx +54 -0
  5. package/site/content/docs/ai/mcp.mdx +123 -0
  6. package/site/content/docs/ai/meta.json +5 -0
  7. package/site/content/docs/ai/skills.mdx +53 -0
  8. package/site/content/docs/console/access.mdx +29 -0
  9. package/site/content/docs/console/ai.mdx +35 -0
  10. package/site/content/docs/console/architecture.mdx +35 -0
  11. package/site/content/docs/console/channels.mdx +37 -0
  12. package/site/content/docs/console/clock.mdx +31 -0
  13. package/site/content/docs/console/flows.mdx +31 -0
  14. package/site/content/docs/console/gates.mdx +35 -0
  15. package/site/content/docs/console/manifest-diff.mdx +34 -0
  16. package/site/content/docs/console/meta.json +23 -0
  17. package/site/content/docs/console/overview.mdx +40 -0
  18. package/site/content/docs/console/plugins.mdx +41 -0
  19. package/site/content/docs/console/privacy.mdx +32 -0
  20. package/site/content/docs/console/runs.mdx +40 -0
  21. package/site/content/docs/console/signals.mdx +31 -0
  22. package/site/content/docs/console/store.mdx +32 -0
  23. package/site/content/docs/console/tenancy.mdx +32 -0
  24. package/site/content/docs/console/traces.mdx +34 -0
  25. package/site/content/docs/console/vault.mdx +37 -0
  26. package/site/content/docs/elements/ai.mdx +180 -0
  27. package/site/content/docs/elements/channel.mdx +167 -0
  28. package/site/content/docs/elements/clock.mdx +182 -0
  29. package/site/content/docs/elements/flow.mdx +288 -0
  30. package/site/content/docs/elements/gate.mdx +171 -0
  31. package/site/content/docs/elements/meta.json +5 -0
  32. package/site/content/docs/elements/signal.mdx +171 -0
  33. package/site/content/docs/elements/store.mdx +320 -0
  34. package/site/content/docs/elements/vault.mdx +263 -0
  35. package/site/content/docs/get-started/basic-usage.mdx +124 -0
  36. package/site/content/docs/get-started/comparison.mdx +65 -0
  37. package/site/content/docs/get-started/installation.mdx +113 -0
  38. package/site/content/docs/get-started/introduction.mdx +123 -0
  39. package/site/content/docs/get-started/meta.json +5 -0
  40. package/site/content/docs/index.mdx +63 -0
  41. package/site/content/docs/meta.json +5 -0
  42. package/site/content/docs/plugins/compression.mdx +60 -0
  43. package/site/content/docs/plugins/cors.mdx +98 -0
  44. package/site/content/docs/plugins/csrf.mdx +96 -0
  45. package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
  46. package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
  47. package/site/content/docs/plugins/meta.json +15 -0
  48. package/site/content/docs/plugins/security-headers.mdx +136 -0
  49. package/site/content/docs/reference/cli.md +101 -0
  50. package/site/content/docs/reference/configuration.mdx +159 -0
  51. package/site/content/docs/reference/environment-variables.mdx +87 -0
  52. package/site/content/docs/reference/errors.mdx +80 -0
  53. package/site/content/docs/reference/fx.mdx +117 -0
  54. package/site/content/docs/reference/meta.json +5 -0
  55. package/site/content/docs/reference/plugins.mdx +249 -0
  56. package/site/content/docs/reference/security.md +63 -0
  57. package/src/auth/auth.test.ts +3 -0
  58. package/src/cli/ask-dev-mode.ts +1 -1
  59. package/src/cli/db.ts +87 -17
  60. package/src/cli/dev-db-push.test.ts +32 -2
  61. package/src/cli/dev-schema-sync.test.ts +66 -0
  62. package/src/cli/dev-schema-sync.ts +139 -0
  63. package/src/cli/dev.test.ts +123 -1
  64. package/src/cli/dev.ts +150 -8
  65. package/src/cli/doc-staleness.test.ts +4 -4
  66. package/src/cli/docker-cli.test.ts +20 -0
  67. package/src/cli/docker.ts +10 -0
  68. package/src/cli/drizzle-env.test.ts +67 -0
  69. package/src/cli/drizzle-env.ts +78 -0
  70. package/src/cli/ensure-drizzle-config.ts +50 -0
  71. package/src/cli/hero-meta.test.ts +1 -1
  72. package/src/cli/load-config.ts +6 -0
  73. package/src/cli/mode.ts +24 -4
  74. package/src/cli/openbao-bootstrap.test.ts +149 -0
  75. package/src/cli/openbao-bootstrap.ts +314 -0
  76. package/src/cli/openbao-restart.integration.test.ts +136 -0
  77. package/src/cli/ports.test.ts +7 -5
  78. package/src/cli/ports.ts +6 -2
  79. package/src/cli/resolve-dev-sql-env.test.ts +48 -0
  80. package/src/cli/resolve-dev-sql-env.ts +42 -0
  81. package/src/cli/stack.ts +7 -4
  82. package/src/cli/vault-cmd.ts +63 -0
  83. package/src/client/types.ts +7 -1
  84. package/src/compiler/extract.test.ts +40 -0
  85. package/src/compiler/extract.ts +123 -1
  86. package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
  87. package/src/compiler/fixtures/skyport.expected.json +1 -1
  88. package/src/compiler/response.ts +12 -0
  89. package/src/config/define-config.test.ts +4 -6
  90. package/src/config/index.ts +2 -15
  91. package/src/console/server/app.ts +2 -0
  92. package/src/console/server/vault.ts +21 -6
  93. package/src/docker/compose.ts +112 -16
  94. package/src/docker/derive.ts +7 -1
  95. package/src/docker/docker.test.ts +103 -0
  96. package/src/docker/index.ts +11 -1
  97. package/src/docker/recipes/index.ts +3 -2
  98. package/src/docker/recipes/openbao.ts +47 -0
  99. package/src/docker/recipes/redis.ts +5 -1
  100. package/src/docker/recipes/rustfs.ts +2 -3
  101. package/src/docker/stack-id.test.ts +43 -8
  102. package/src/docker/stack-id.ts +99 -20
  103. package/src/docker/stack.ts +36 -4
  104. package/src/docker/types.ts +3 -0
  105. package/src/docs-origin.ts +4 -4
  106. package/src/drivers/drizzle-dialect.test.ts +20 -0
  107. package/src/drivers/drizzle-dialect.ts +37 -0
  108. package/src/drivers/index.ts +1 -2
  109. package/src/drivers/memory.ts +278 -39
  110. package/src/drivers/s3.ts +10 -1
  111. package/src/drivers/vault-driver-removal.test.ts +55 -0
  112. package/src/drivers/vault-openbao.test.ts +97 -0
  113. package/src/drivers/vault-openbao.ts +102 -35
  114. package/src/drivers/vault-types.ts +3 -10
  115. package/src/elements/store/declare.ts +4 -1
  116. package/src/elements/store/resource-list-docs.fixture.ts +56 -0
  117. package/src/elements/store/resource-list-docs.test.ts +79 -0
  118. package/src/elements/store/resource.test.ts +253 -0
  119. package/src/elements/store/resource.ts +786 -0
  120. package/src/elements/store/sql-condition.test.ts +132 -0
  121. package/src/elements/store/sql-condition.ts +284 -46
  122. package/src/elements/store/sql-session.test.ts +86 -1
  123. package/src/elements/store/sql-session.ts +187 -27
  124. package/src/elements/store/table.ts +34 -4
  125. package/src/elements/store.ts +16 -0
  126. package/src/elements/vault/runtime.ts +1 -1
  127. package/src/elements/vault.test.ts +1 -28
  128. package/src/elements/vault.ts +1 -1
  129. package/src/kernel/app.ts +59 -25
  130. package/src/kernel/boot-bind/channel.test.ts +60 -0
  131. package/src/kernel/boot-bind/channel.ts +64 -2
  132. package/src/kernel/boot-bind/store.test.ts +10 -1
  133. package/src/kernel/boot-bind/store.ts +49 -2
  134. package/src/kernel/boot.test.ts +0 -1
  135. package/src/kernel/boot.ts +1 -1
  136. package/src/kernel/edge.test.ts +68 -0
  137. package/src/kernel/errors.registry.test.ts +1 -1
  138. package/src/kernel/flow.ts +8 -0
  139. package/src/kernel/fx.test.ts +23 -3
  140. package/src/kernel/fx.ts +115 -18
  141. package/src/kernel/hooks.test.ts +33 -0
  142. package/src/kernel/hooks.ts +22 -0
  143. package/src/kernel/index.ts +7 -0
  144. package/src/kernel/on.ts +44 -3
  145. package/src/kernel/plugin.ts +33 -3
  146. package/src/kernel/registry-isolation.test.ts +74 -0
  147. package/src/kernel/registry.ts +22 -1
  148. package/src/kernel/triggers.ts +59 -0
  149. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  150. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  151. package/src/manifest/index.ts +1 -1
  152. package/src/manifest/types.ts +1 -1
  153. package/src/manifest/validate.ts +2 -2
  154. package/src/plugins/compression.test.ts +127 -0
  155. package/src/plugins/compression.ts +94 -0
  156. package/src/plugins/config-source.test.ts +204 -0
  157. package/src/plugins/config-source.ts +209 -0
  158. package/src/plugins/cors.test.ts +172 -0
  159. package/src/plugins/cors.ts +153 -0
  160. package/src/plugins/csrf.test.ts +102 -0
  161. package/src/plugins/csrf.ts +86 -0
  162. package/src/plugins/headers.ts +54 -0
  163. package/src/plugins/index.ts +26 -0
  164. package/src/plugins/ip-allowlist.test.ts +139 -0
  165. package/src/plugins/ip-allowlist.ts +129 -0
  166. package/src/plugins/maintenance-mode.test.ts +91 -0
  167. package/src/plugins/maintenance-mode.ts +85 -0
  168. package/src/plugins/security-headers.test.ts +243 -0
  169. package/src/plugins/security-headers.ts +255 -0
  170. package/src/release/measure.ts +1 -2
  171. package/src/test/create-test-app.ts +14 -2
  172. package/docs/spec/console.md +0 -762
  173. package/docs/spec/example.md +0 -1374
  174. package/docs/spec/four-applications.md +0 -1376
  175. package/docs/spec/unified-theory.md +0 -498
  176. package/src/cli/doc-drift.test.ts +0 -147
  177. package/src/cli/doc-drift.ts +0 -401
  178. package/src/cli/doctor-diff-examples.ts +0 -90
  179. package/src/drivers/vault-sops.ts +0 -246
  180. /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
@@ -150,19 +150,34 @@ export interface PipelineResult {
150
150
  readonly response: Response | undefined;
151
151
  }
152
152
 
153
+ /** Serialize a pipeline outcome into an HTTP response (app-layer encoder). */
154
+ export type PipelineEncoder = (result: {
155
+ readonly response?: Response | undefined;
156
+ readonly failure?: FlowFailure | undefined;
157
+ readonly output?: unknown;
158
+ readonly error?: unknown;
159
+ }) => Response;
160
+
153
161
  /**
154
162
  * Run the seven-stage pipeline around `handler`.
155
163
  *
164
+ * When `encode` is given (HTTP triggers), the outcome is serialized into
165
+ * `ctx.response` **before** `onResponse`, so the last stage sees — and may
166
+ * replace — the final response, as {@link InvocationContext.response}
167
+ * documents. Without `encode` (non-HTTP triggers) behavior is unchanged.
168
+ *
156
169
  * @param ctx - Invocation context
157
170
  * @param fx - Fx door
158
171
  * @param hooks - Merged hooks
159
172
  * @param handler - Flow body (the `handler` stage)
173
+ * @param encode - HTTP response encoder (e.g. `encodeExecuteResult`)
160
174
  */
161
175
  export async function runPipeline(
162
176
  ctx: InvocationContext,
163
177
  fx: Fx,
164
178
  hooks: HookMap,
165
179
  handler: () => unknown | Promise<unknown>,
180
+ encode?: PipelineEncoder,
166
181
  ): Promise<PipelineResult> {
167
182
  let shortCircuit: "response" | "error" | undefined;
168
183
 
@@ -215,6 +230,13 @@ export async function runPipeline(
215
230
  await runStage("onError", hooks, ctx, fx);
216
231
  }
217
232
 
233
+ // Serialize before the last stage so `onResponse` sees the final response
234
+ // (short-circuit wins; encoder input mirrors the app layer exactly).
235
+ if (encode !== undefined && ctx.response === undefined) {
236
+ const failure = isFlowFailure(ctx.error) ? ctx.error : undefined;
237
+ ctx.response = encode({ failure, output: ctx.result, error: ctx.error });
238
+ }
239
+
218
240
  await runStage("onResponse", hooks, ctx, fx);
219
241
 
220
242
  const failure = isFlowFailure(ctx.error) ? ctx.error : undefined;
@@ -103,6 +103,8 @@ export {
103
103
  export {
104
104
  createFx,
105
105
  createFxContext,
106
+ isJsonResult,
107
+ jsonResultBrand,
106
108
  resolveName,
107
109
  resolveStoreRef,
108
110
  type CreateFxOptions,
@@ -113,12 +115,14 @@ export {
113
115
  type FxCallHandler,
114
116
  type FxClock,
115
117
  type FxContext,
118
+ type FxJson,
116
119
  type FxLog,
117
120
  type FxOperator,
118
121
  type FxSearchOptions,
119
122
  type FxSendOptions,
120
123
  type FxStoreHandle,
121
124
  type FxTenant,
125
+ type JsonResult,
122
126
  type NamedRef,
123
127
  } from "./fx.ts";
124
128
 
@@ -236,6 +240,7 @@ export {
236
240
  every,
237
241
  http,
238
242
  internal,
243
+ isResourceMount,
239
244
  isSignalTriggerSource,
240
245
  normalizeTrigger,
241
246
  table,
@@ -246,6 +251,8 @@ export {
246
251
  type HttpMethod,
247
252
  type HttpTrigger,
248
253
  type InternalTrigger,
254
+ type ResourceFlowBag,
255
+ type ResourceMount,
249
256
  type SignalAsTrigger,
250
257
  type SignalSource,
251
258
  type TableHandle,
package/src/kernel/on.ts CHANGED
@@ -8,8 +8,11 @@
8
8
 
9
9
  import { isFlow, type AnyFlowDef, type FlowDef, type FlowErrorMap } from "./flow.ts";
10
10
  import {
11
+ isResourceMount,
11
12
  normalizeTrigger,
12
13
  type BoundTriggerOf,
14
+ type ResourceFlowBag,
15
+ type ResourceMount,
13
16
  type SignalSource,
14
17
  type Trigger,
15
18
  } from "./triggers.ts";
@@ -39,17 +42,55 @@ export function on<
39
42
  >(
40
43
  trigger: T,
41
44
  flowDef: FlowDef<I, O, E, D, Trigger | undefined>,
42
- ): FlowDef<I, O, E, D, BoundTriggerOf<T>> {
45
+ ): FlowDef<I, O, E, D, BoundTriggerOf<T>>;
46
+ /**
47
+ * Mount a CRUD resource (`http.resource(path, ops)`): registers the five
48
+ * verb bindings and returns the ops bag (unit keys `list` · `create` ·
49
+ * `get` · `update` · `remove`) for `.adopt({ notes })`.
50
+ *
51
+ * @param mount - Branded {@link ResourceMount}
52
+ */
53
+ export function on(mount: ResourceMount): ResourceFlowBag;
54
+ export function on(
55
+ triggerOrMount: Trigger | SignalSource | ResourceMount,
56
+ flowDef?: FlowDef<any, any, any, any, Trigger | undefined>,
57
+ ): unknown {
58
+ if (isResourceMount(triggerOrMount)) {
59
+ if (flowDef !== undefined) {
60
+ throw new TypeError("on(http.resource(...)) takes no second argument");
61
+ }
62
+ const byIdVerb: Partial<Record<string, "get" | "update" | "remove">> = {
63
+ GET: "get",
64
+ PATCH: "update",
65
+ DELETE: "remove",
66
+ };
67
+ const baseVerb: Partial<Record<string, "list" | "create">> = {
68
+ GET: "list",
69
+ POST: "create",
70
+ };
71
+ const ops: Record<string, unknown> = {};
72
+ for (const { trigger, flow } of triggerOrMount.mounts) {
73
+ const key = trigger.path.endsWith("/:id")
74
+ ? byIdVerb[trigger.method]
75
+ : baseVerb[trigger.method];
76
+ if (key === undefined || !isFlow(flow)) {
77
+ throw new TypeError("on(http.resource(...)) expects the five CRUD FlowDefs");
78
+ }
79
+ on(trigger, flow as FlowDef<any, any, any, any, Trigger | undefined>);
80
+ ops[key] = flow;
81
+ }
82
+ return ops as unknown as ResourceFlowBag;
83
+ }
43
84
  if (!isFlow(flowDef)) {
44
85
  throw new TypeError("on() expected a flow() definition as the second argument");
45
86
  }
46
- const normalized = normalizeTrigger(trigger);
87
+ const normalized = normalizeTrigger(triggerOrMount as Trigger | SignalSource);
47
88
  const list = flowDef.triggers as Trigger[];
48
89
  list.push(normalized);
49
90
  // Stamp runtime carrier for the first bound trigger (type follows BoundTriggerOf).
50
91
  (flowDef as { $trigger: Trigger }).$trigger = normalized;
51
92
  bindings.push({ trigger: normalized, flow: flowDef as AnyFlowDef });
52
- return flowDef as unknown as FlowDef<I, O, E, D, BoundTriggerOf<T>>;
93
+ return flowDef;
53
94
  }
54
95
 
55
96
  /**
@@ -6,9 +6,9 @@
6
6
  * and records exactly what was requested (capability capture). We never
7
7
  * statically analyse plugin source to guess intent.
8
8
  *
9
- * Contributions: flows · hooks · context decorations · elements · drivers ·
10
- * image recipes · DB schema · typed errors · client extensions · CLI
11
- * commands · Console panels.
9
+ * Contributions: flows · hooks · edge handlers (unmatched requests) ·
10
+ * context decorations · elements · drivers · image recipes · DB schema ·
11
+ * typed errors · client extensions · CLI commands · Console panels.
12
12
  *
13
13
  * @see docs/spec/unified-theory.md §14
14
14
  * @see docs/spec/console.md §9.15
@@ -87,6 +87,17 @@ export interface ImageRecipeContribution {
87
87
  readonly recipe: string;
88
88
  }
89
89
 
90
+ /**
91
+ * Edge handler — runs for HTTP requests that match **no** flow (the prime
92
+ * example: a CORS preflight `OPTIONS` for a path bound to another method).
93
+ * Handlers run in install order; the first to return a {@link Response}
94
+ * answers, `undefined` passes to the next handler, then the plain 404.
95
+ */
96
+ export type EdgeHandler = (
97
+ request: Request,
98
+ info: { readonly method: string; readonly path: string },
99
+ ) => undefined | Response | Promise<undefined | Response>;
100
+
90
101
  /** Table / DB schema contribution. */
91
102
  export interface TableContribution {
92
103
  /** Table name — conflict namespace shared across plugins. */
@@ -135,6 +146,12 @@ export interface PluginApi {
135
146
  * @param fn - Hook function
136
147
  */
137
148
  hook(stage: HookStage, fn: HookFn): PluginApi;
149
+ /**
150
+ * Register an edge handler (intercept — requests that match no flow).
151
+ *
152
+ * @param fn - Edge handler
153
+ */
154
+ edge(fn: EdgeHandler): PluginApi;
138
155
  /**
139
156
  * Decorate the invocation context (declare — once at boot).
140
157
  *
@@ -242,6 +259,7 @@ export interface PluginCapabilities {
242
259
  export interface PluginRegistration {
243
260
  readonly capabilities: PluginCapabilities;
244
261
  readonly hooks: Partial<Record<HookStage, HookFn[]>>;
262
+ readonly edges: readonly EdgeHandler[];
245
263
  readonly decorations: Readonly<Record<string, unknown>>;
246
264
  readonly elements: readonly PluginElement[];
247
265
  readonly tables: readonly TableContribution[];
@@ -285,6 +303,12 @@ export interface PluginDef<D extends Record<string, unknown> = {}> {
285
303
  * @param fn - Hook function
286
304
  */
287
305
  hook(stage: HookStage, fn: HookFn): PluginDef<D>;
306
+ /**
307
+ * Queue an edge handler contribution (requests that match no flow).
308
+ *
309
+ * @param fn - Edge handler
310
+ */
311
+ edge(fn: EdgeHandler): PluginDef<D>;
288
312
  /**
289
313
  * Queue a context decoration. Accumulates types for `.plug()`.
290
314
  *
@@ -398,6 +422,12 @@ export function plugin(name: string, options: PluginOptions): PluginDef {
398
422
  });
399
423
  return def;
400
424
  },
425
+ edge(fn) {
426
+ steps.push((api) => {
427
+ api.edge(fn);
428
+ });
429
+ return def;
430
+ },
401
431
  decorate(key, value) {
402
432
  steps.push((api) => {
403
433
  api.decorate(key, value);
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Registry isolation — two `oke()` apps in one process must not share routes.
3
+ *
4
+ * This is the multi-app / future-`mount()` guarantee: constructing one app
5
+ * can never pull another app's flows through the process-global `on()`
6
+ * registry. Before the fix, `oke()` adopted the registry snapshot and left
7
+ * it intact, so App B below silently served App A's `/a` route (leakage).
8
+ * The default `registry: "consume"` mode drains the registry at construction.
9
+ */
10
+
11
+ import { afterEach, describe, expect, test } from "bun:test";
12
+ import { oke } from "./app.ts";
13
+ import { flow, resetFlowSeq } from "./flow.ts";
14
+ import { listBindings, on, resetBindings, type Binding } from "./on.ts";
15
+ import { http } from "./triggers.ts";
16
+
17
+ afterEach(() => {
18
+ resetBindings();
19
+ resetFlowSeq();
20
+ });
21
+
22
+ describe("registry isolation", () => {
23
+ test("consume (default): App B does not serve App A routes", async () => {
24
+ on(http.get("/a"), flow({ name: "app-a.ping", do: () => "a" }));
25
+ const appA = oke({ name: "app-a" });
26
+
27
+ // No resetBindings() between the apps — the pre-fix leak scenario.
28
+ on(http.get("/b"), flow({ name: "app-b.ping", do: () => "b" }));
29
+ const appB = oke({ name: "app-b" });
30
+
31
+ // Pre-fix this was 200: App B adopted App A's leftover binding.
32
+ expect((await appB.fetch(new Request("http://localhost/a"))).status).toBe(404);
33
+
34
+ const own = await appB.fetch(new Request("http://localhost/b"));
35
+ expect(own.status).toBe(200);
36
+ expect(await own.json()).toEqual({ data: "b", error: null });
37
+
38
+ // App A still serves its own route after App B was constructed.
39
+ const stillA = await appA.fetch(new Request("http://localhost/a"));
40
+ expect(stillA.status).toBe(200);
41
+ expect(await stillA.json()).toEqual({ data: "a", error: null });
42
+
43
+ // Consume drained the registry — nothing left for a third app.
44
+ expect(listBindings()).toHaveLength(0);
45
+ });
46
+
47
+ test("keep: today's behavior — registry survives construction", async () => {
48
+ on(http.get("/a"), flow({ name: "kept.ping", do: () => "a" }));
49
+ oke({ name: "app-a", registry: "keep" });
50
+ expect(listBindings()).toHaveLength(1);
51
+
52
+ const appB = oke({ name: "app-b", registry: "keep" });
53
+ expect((await appB.fetch(new Request("http://localhost/a"))).status).toBe(200);
54
+ });
55
+
56
+ test("ignore: only options.bindings are served", async () => {
57
+ on(http.get("/stray"), flow({ name: "stray.ping", do: () => "stray" }));
58
+
59
+ const onlyB: Binding = {
60
+ trigger: http.get("/b"),
61
+ flow: flow({ name: "only-b.ping", do: () => "b" }),
62
+ };
63
+ const app = oke({ name: "app-b", registry: "ignore", bindings: [onlyB] });
64
+
65
+ expect((await app.fetch(new Request("http://localhost/stray"))).status).toBe(404);
66
+
67
+ const res = await app.fetch(new Request("http://localhost/b"));
68
+ expect(res.status).toBe(200);
69
+ expect(await res.json()).toEqual({ data: "b", error: null });
70
+
71
+ // Ignore never touches the registry.
72
+ expect(listBindings()).toHaveLength(1);
73
+ });
74
+ });
@@ -20,6 +20,7 @@ import type {
20
20
  ClientExtensionContribution,
21
21
  ConsolePanelContribution,
22
22
  DriverContribution,
23
+ EdgeHandler,
23
24
  ImageRecipeContribution,
24
25
  PluginApi,
25
26
  PluginCapabilities,
@@ -62,6 +63,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
62
63
  snapshot(): PluginRegistration;
63
64
  } {
64
65
  const hooks: Partial<Record<HookStage, HookFn[]>> = {};
66
+ const edges: EdgeHandler[] = [];
65
67
  const decorations: Record<string, unknown> = {};
66
68
  const elements: PluginElement[] = [];
67
69
  const tables: TableContribution[] = [];
@@ -81,7 +83,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
81
83
  if (!declares.includes(cap)) declares.push(cap);
82
84
  }
83
85
 
84
- function pushIntercept(stage: HookStage): void {
86
+ function pushIntercept(stage: HookStage | "edge"): void {
85
87
  if (!intercepts.includes(stage)) intercepts.push(stage);
86
88
  }
87
89
 
@@ -93,6 +95,11 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
93
95
  pushIntercept(stage);
94
96
  return api;
95
97
  },
98
+ edge(fn) {
99
+ edges.push(fn);
100
+ pushIntercept("edge");
101
+ return api;
102
+ },
96
103
  decorate(key, value) {
97
104
  decorations[key] = value;
98
105
  pushDeclare(`decorate:${key}`);
@@ -168,6 +175,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
168
175
  needs: needs.slice(),
169
176
  },
170
177
  hooks: { ...hooks },
178
+ edges: edges.slice(),
171
179
  decorations: { ...decorations },
172
180
  elements: elements.slice(),
173
181
  tables: tables.slice(),
@@ -231,6 +239,12 @@ export interface PluginRegistry {
231
239
  * @param flowName - Flow name (for `flow` kind)
232
240
  */
233
241
  hooksAt(scopeKind: PluginScope["kind"], unit: string | undefined, flowName: string): HookMap;
242
+ /**
243
+ * Edge handlers in install order. They answer requests that match no
244
+ * flow, so there is no flow/unit scope to filter by — every installed
245
+ * handler runs.
246
+ */
247
+ edgeHandlers(): readonly EdgeHandler[];
234
248
  /**
235
249
  * Merged decorations visible to a flow at the given unit.
236
250
  *
@@ -380,6 +394,13 @@ export function createPluginRegistry(): PluginRegistry {
380
394
  }
381
395
  return out;
382
396
  },
397
+ edgeHandlers() {
398
+ const out: EdgeHandler[] = [];
399
+ for (const entry of installed) {
400
+ out.push(...entry.registration.edges);
401
+ }
402
+ return out;
403
+ },
383
404
  tableContributions() {
384
405
  const seenPlugins = new Set<string>();
385
406
  const out: TableContribution[] = [];
@@ -100,6 +100,59 @@ function createHttpTrigger<M extends HttpMethod, P extends string>(
100
100
  return trigger;
101
101
  }
102
102
 
103
+ /** Flow shape accepted by {@link http.resource} (duck-typed — any FlowDef). */
104
+ export type ResourceFlow = { readonly name: string };
105
+
106
+ /** The five CRUD ops {@link http.resource} mounts. */
107
+ export interface ResourceFlowBag {
108
+ readonly list: unknown;
109
+ readonly create: unknown;
110
+ readonly get: unknown;
111
+ readonly update: unknown;
112
+ readonly remove: unknown;
113
+ }
114
+
115
+ /**
116
+ * A mounted resource — the single argument to the `on(http.resource(…))`
117
+ * overload. Branded so `on` can tell it apart from a plain trigger.
118
+ */
119
+ export interface ResourceMount {
120
+ readonly [resourceMountBrand]: true;
121
+ readonly mounts: ReadonlyArray<{ readonly trigger: HttpTrigger; readonly flow: unknown }>;
122
+ }
123
+ /** Brand for {@link ResourceMount}. */
124
+ export const resourceMountBrand: unique symbol = Symbol.for("oke.resource.mount");
125
+
126
+ /**
127
+ * Mount a CRUD resource at `path`: `list`/`create` on the base, `get` /
128
+ * `update` / `remove` on `/:id`. Bind via `on(http.resource(…))`.
129
+ *
130
+ * @param path - Base path (`/notes`)
131
+ * @param ops - The five FlowDefs (usually `resource.all()`)
132
+ */
133
+ function httpResource<P extends string>(path: P, ops: ResourceFlowBag): ResourceMount {
134
+ const id = `${path}/:id`;
135
+ return {
136
+ [resourceMountBrand]: true,
137
+ mounts: [
138
+ { trigger: http.get(path), flow: ops.list },
139
+ { trigger: http.post(path), flow: ops.create },
140
+ { trigger: http.get(id), flow: ops.get },
141
+ { trigger: http.patch(id), flow: ops.update },
142
+ { trigger: http.delete(id), flow: ops.remove },
143
+ ],
144
+ };
145
+ }
146
+
147
+ /** True when `value` is a {@link ResourceMount}. */
148
+ export function isResourceMount(value: unknown): value is ResourceMount {
149
+ return (
150
+ typeof value === "object" &&
151
+ value !== null &&
152
+ (value as ResourceMount)[resourceMountBrand] === true
153
+ );
154
+ }
155
+
103
156
  /**
104
157
  * Shape of the {@link http} trigger namespace. Each method keeps `P` as a
105
158
  * generic type parameter so callers (and the client) retain literal path
@@ -134,6 +187,11 @@ export interface HttpTriggerNamespace {
134
187
  * @param path - Route path
135
188
  */
136
189
  head<P extends string>(path: P): HttpTrigger<"HEAD", P>;
190
+ /**
191
+ * Mount a CRUD resource (list/create on `path`, get/update/remove on
192
+ * `path/:id`) for the `on(http.resource(…))` overload.
193
+ */
194
+ resource<P extends string>(path: P, ops: ResourceFlowBag): ResourceMount;
137
195
  }
138
196
 
139
197
  /**
@@ -182,6 +240,7 @@ export const http: HttpTriggerNamespace = {
182
240
  head<P extends string>(path: P): HttpTrigger<"HEAD", P> {
183
241
  return createHttpTrigger("HEAD", path);
184
242
  },
243
+ resource: httpResource,
185
244
  };
186
245
 
187
246
  /**
@@ -46,6 +46,6 @@
46
46
  }
47
47
  },
48
48
  "journeys": { "book-a-flight": { "slo": { "availability": "99.5%" }, "composes": "99.6%" } },
49
- "drivers": { "prod": ["postgres", "redis", "s3", "smtp", "sops", "anthropic", "pgvector"] },
49
+ "drivers": { "prod": ["postgres", "redis", "s3", "smtp", "openbao", "anthropic", "pgvector"] },
50
50
  "tenancy": { "isolation": "row" }
51
51
  }
@@ -137,7 +137,7 @@
137
137
  }
138
138
  },
139
139
  "drivers": {
140
- "prod": ["postgres", "redis", "s3", "smtp", "sops", "anthropic", "pgvector"]
140
+ "prod": ["postgres", "redis", "s3", "smtp", "openbao", "anthropic", "pgvector"]
141
141
  },
142
142
  "tenancy": { "isolation": "row" },
143
143
  "i18n": {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Manifest — the 100-year artifact.
3
3
  *
4
- * Public contract: `spec/manifest.v1.schema.json`
4
+ * Public contract: `manifest.v1.schema.json`
5
5
  */
6
6
 
7
7
  export type * from "./types.ts";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * TypeScript surface matching `spec/manifest.v1.schema.json`.
2
+ * TypeScript surface matching `manifest.v1.schema.json`.
3
3
  *
4
4
  * Kept hand-aligned with the schema (the public, runtime-neutral contract).
5
5
  * Within major version `1.x`, only additive schema changes are allowed.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Runtime validation of Manifest documents against `spec/manifest.v1.schema.json`.
2
+ * Runtime validation of Manifest documents against `manifest.v1.schema.json`.
3
3
  */
4
4
 
5
5
  import Ajv2020, { type AnySchema, type ErrorObject } from "ajv/dist/2020.js";
@@ -19,7 +19,7 @@ export type ManifestValidationResult =
19
19
  | { ok: true; manifest: Manifest }
20
20
  | { ok: false; issues: ManifestValidationIssue[] };
21
21
 
22
- const SCHEMA_URL = new URL("../../spec/manifest.v1.schema.json", import.meta.url);
22
+ const SCHEMA_URL = new URL("../../manifest.v1.schema.json", import.meta.url);
23
23
 
24
24
  let validator: ReturnType<Ajv2020["compile"]> | undefined;
25
25
  let schemaDocument: unknown;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `compression` plugin — gzip negotiation, thresholds, and skip rules
3
+ * through the real pipeline.
4
+ */
5
+
6
+ import { beforeEach, describe, expect, test } from "bun:test";
7
+ import { oke } from "../kernel/app.ts";
8
+ import { flow, resetFlowSeq } from "../kernel/flow.ts";
9
+ import { on, resetBindings } from "../kernel/on.ts";
10
+ import { http } from "../kernel/triggers.ts";
11
+ import { acceptsGzip, compression } from "./compression.ts";
12
+
13
+ beforeEach(() => {
14
+ resetBindings();
15
+ resetFlowSeq();
16
+ });
17
+
18
+ /** Big JSON payload above the default 1 KiB threshold. */
19
+ function bigPayload() {
20
+ return { items: Array.from({ length: 100 }, (_, i) => ({ id: i, name: `item-${i}` })) };
21
+ }
22
+
23
+ describe("acceptsGzip", () => {
24
+ test("parses plain, starred, and q-valued tokens", () => {
25
+ expect(acceptsGzip(null)).toBe(false);
26
+ expect(acceptsGzip("")).toBe(false);
27
+ expect(acceptsGzip("gzip")).toBe(true);
28
+ expect(acceptsGzip("br, gzip")).toBe(true);
29
+ expect(acceptsGzip("gzip;q=0")).toBe(false);
30
+ expect(acceptsGzip("gzip;q=0.0")).toBe(false);
31
+ expect(acceptsGzip("gzip;q=0.5")).toBe(true);
32
+ expect(acceptsGzip("*")).toBe(true);
33
+ expect(acceptsGzip("*;q=0")).toBe(false);
34
+ expect(acceptsGzip("br")).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("compression plugin", () => {
39
+ test("gzips a large JSON body when the client accepts gzip", async () => {
40
+ on(http.get("/big"), flow({ name: "big.get", do: bigPayload }));
41
+ const app = oke({ name: "zip" }).plug(compression());
42
+
43
+ const res = await app.fetch(
44
+ new Request("http://localhost/big", { headers: { "accept-encoding": "gzip" } }),
45
+ );
46
+
47
+ expect(res.status).toBe(200);
48
+ expect(res.headers.get("content-encoding")).toBe("gzip");
49
+ expect(res.headers.get("vary")).toContain("accept-encoding");
50
+ expect(res.headers.get("content-length")).toBeNull();
51
+
52
+ const roundTrip = JSON.parse(
53
+ new TextDecoder().decode(Bun.gunzipSync(await res.arrayBuffer())),
54
+ ) as {
55
+ data: { items: unknown[] };
56
+ };
57
+ expect(roundTrip.data.items).toHaveLength(100);
58
+ });
59
+
60
+ test("passes through untouched without Accept-Encoding: gzip", async () => {
61
+ on(http.get("/big"), flow({ name: "big.get", do: bigPayload }));
62
+ const app = oke({ name: "zip-plain" }).plug(compression());
63
+
64
+ const res = await app.fetch(new Request("http://localhost/big"));
65
+
66
+ expect(res.headers.get("content-encoding")).toBeNull();
67
+ const body = (await res.json()) as { data: { items: unknown[] } };
68
+ expect(body.data.items).toHaveLength(100);
69
+ });
70
+
71
+ test("skips bodies under minSize", async () => {
72
+ on(http.get("/small"), flow({ name: "small.get", do: () => ({ ok: true }) }));
73
+ const app = oke({ name: "zip-small" }).plug(compression());
74
+
75
+ const res = await app.fetch(
76
+ new Request("http://localhost/small", { headers: { "accept-encoding": "gzip" } }),
77
+ );
78
+
79
+ expect(res.headers.get("content-encoding")).toBeNull();
80
+ });
81
+
82
+ test("minSize: 0 compresses even tiny bodies", async () => {
83
+ on(http.get("/small"), flow({ name: "small.get", do: () => ({ ok: true }) }));
84
+ const app = oke({ name: "zip-zero" }).plug(compression({ minSize: 0 }));
85
+
86
+ const res = await app.fetch(
87
+ new Request("http://localhost/small", { headers: { "accept-encoding": "gzip" } }),
88
+ );
89
+
90
+ expect(res.headers.get("content-encoding")).toBe("gzip");
91
+ });
92
+
93
+ test("skips non-matching content types and no-transform responses", async () => {
94
+ on(
95
+ http.get("/img"),
96
+ flow({
97
+ name: "img.get",
98
+ do: () =>
99
+ new Response(new Uint8Array(4096), {
100
+ headers: { "content-type": "image/png" },
101
+ }),
102
+ }),
103
+ );
104
+ on(
105
+ http.get("/nt"),
106
+ flow({
107
+ name: "nt.get",
108
+ do: () =>
109
+ Response.json(
110
+ { data: bigPayload(), error: null },
111
+ { headers: { "cache-control": "no-transform" } },
112
+ ),
113
+ }),
114
+ );
115
+ const app = oke({ name: "zip-skip" }).plug(compression());
116
+
117
+ const img = await app.fetch(
118
+ new Request("http://localhost/img", { headers: { "accept-encoding": "gzip" } }),
119
+ );
120
+ expect(img.headers.get("content-encoding")).toBeNull();
121
+
122
+ const nt = await app.fetch(
123
+ new Request("http://localhost/nt", { headers: { "accept-encoding": "gzip" } }),
124
+ );
125
+ expect(nt.headers.get("content-encoding")).toBeNull();
126
+ });
127
+ });