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
@@ -1,21 +1,30 @@
1
1
  /**
2
- * `openbao` vault driver — HashiCorp Vault / OpenBao KV v2 HTTP API.
2
+ * `openbao` vault driver — OpenBao (Vault-compatible) KV v2 HTTP API.
3
3
  *
4
4
  * Protocol-named; image/vendor choice lives in `images`.
5
5
  */
6
6
 
7
7
  import type { VaultBag, VaultDriver, VaultOpenOptions } from "./vault-types.ts";
8
8
 
9
+ /** Error thrown when the remote OpenBao is unreachable / sealed / errors. */
10
+ export class OpenBaoUnavailableError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "OpenBaoUnavailableError";
14
+ }
15
+ }
16
+
9
17
  /**
10
- * OpenBao / Vault KV driver.
18
+ * OpenBao KV-v2 bag. Reads are loaded at open when `url` + `token` resolve;
19
+ * writes (`set` / `delete`) hit the HTTP API directly.
11
20
  *
12
- * Expects `url` + `token` + optional `mount` (default `secret`).
13
- * Fetches `GET {url}/v1/{mount}/data/{name}` for each requested secret
14
- * lazily via a cached bag loaded at open when `secrets` seed keys are listed,
15
- * or returns empty until {@link VaultBag.get} is called against a remote.
21
+ * When `url` and `token` are provided, an unreachable, sealed, or erroring
22
+ * OpenBao is **fatal** (fail-loud) the bag never silently degrades to an
23
+ * empty / seed-only view.
16
24
  *
17
25
  * For boot validation the runtime passes declared names; this driver loads
18
- * all keys under `mount` when the LIST API is available, else uses seed names.
26
+ * all keys under `mount` when the LIST API is available, else falls back to
27
+ * GETs for the seed names.
19
28
  */
20
29
  export const openbaoVaultDriver: VaultDriver = {
21
30
  id: "openbao",
@@ -30,36 +39,60 @@ export const openbaoVaultDriver: VaultDriver = {
30
39
  ),
31
40
  );
32
41
 
33
- if (url && token) {
34
- // Best-effort LIST; ignore failures (permission / disabled).
42
+ const remote = url !== undefined && token !== undefined;
43
+ let mutableRemote = false;
44
+
45
+ if (remote && url !== undefined && token !== undefined) {
46
+ const headers = { "X-Vault-Token": token };
47
+ const base = `${url}/v1/${mount}`;
48
+
49
+ async function listNames(): Promise<string[]> {
50
+ const res = await fetchFn(`${base}/metadata?list=true`, { headers });
51
+ if (!res.ok) return [];
52
+ const body = (await res.json()) as { data?: { keys?: string[] } };
53
+ return (body.data?.keys ?? []).map((k) => k.replace(/\/$/, ""));
54
+ }
55
+
56
+ async function readName(name: string): Promise<boolean> {
57
+ const res = await fetchFn(`${base}/data/${encodeURIComponent(name)}`, { headers });
58
+ if (res.status === 404) return false;
59
+ if (!res.ok) {
60
+ throw new OpenBaoUnavailableError(
61
+ `openbao vault: GET ${name} failed (${res.status}) — sealed or unauthorized?`,
62
+ );
63
+ }
64
+ const body = (await res.json()) as { data?: { data?: Record<string, unknown> } };
65
+ const data = body.data?.data ?? {};
66
+ const value =
67
+ typeof data.value === "string"
68
+ ? data.value
69
+ : typeof data[name] === "string"
70
+ ? (data[name] as string)
71
+ : JSON.stringify(data);
72
+ map.set(name, value);
73
+ return true;
74
+ }
75
+
76
+ // LIST when the token has metadata list; otherwise only fetch the
77
+ // seed/declared names so a least-privilege token still works. A
78
+ // connection-level failure here must never degrade to an empty bag.
79
+ let names: string[] = [];
35
80
  try {
36
- const listRes = await fetchFn(`${url}/v1/${mount}/metadata?list=true`, {
37
- headers: { "X-Vault-Token": token },
38
- });
39
- if (listRes.ok) {
40
- const body = (await listRes.json()) as {
41
- data?: { keys?: string[] };
42
- };
43
- for (const key of body.data?.keys ?? []) {
44
- const getRes = await fetchFn(`${url}/v1/${mount}/data/${key}`, {
45
- headers: { "X-Vault-Token": token },
46
- });
47
- if (!getRes.ok) continue;
48
- const got = (await getRes.json()) as {
49
- data?: { data?: Record<string, unknown> };
50
- };
51
- const data = got.data?.data ?? {};
52
- const value =
53
- typeof data.value === "string"
54
- ? data.value
55
- : typeof data[key] === "string"
56
- ? (data[key] as string)
57
- : JSON.stringify(data);
58
- map.set(key.replace(/\/$/, ""), value);
59
- }
81
+ names = await listNames();
82
+ } catch (err) {
83
+ if (map.size === 0) {
84
+ throw new OpenBaoUnavailableError(
85
+ `openbao vault: unreachable at ${url} — ${err instanceof Error ? err.message : String(err)}`,
86
+ );
60
87
  }
61
- } catch {
62
- // Remote unavailable — bag stays at seed / empty; boot validation reports gaps.
88
+ // Fall through: LIST denied but we still have declared names to GET.
89
+ }
90
+ if (names.length > 0) {
91
+ for (const key of names) await readName(key);
92
+ mutableRemote = true;
93
+ } else if (map.size > 0) {
94
+ for (const name of map.keys()) await readName(name);
95
+ mutableRemote = true;
63
96
  }
64
97
  }
65
98
 
@@ -71,6 +104,40 @@ export const openbaoVaultDriver: VaultDriver = {
71
104
  names() {
72
105
  return [...map.keys()];
73
106
  },
107
+ ...(mutableRemote && url !== undefined && token !== undefined
108
+ ? {
109
+ set(name: string, value: string) {
110
+ void (async () => {
111
+ const res = await fetchFn(`${url}/v1/${mount}/data/${encodeURIComponent(name)}`, {
112
+ method: "POST",
113
+ headers: { "X-Vault-Token": token, "content-type": "application/json" },
114
+ body: JSON.stringify({ data: { value } }),
115
+ });
116
+ if (!res.ok) {
117
+ throw new OpenBaoUnavailableError(
118
+ `openbao vault: write ${name} failed (${res.status})`,
119
+ );
120
+ }
121
+ map.set(name, value);
122
+ })();
123
+ },
124
+ delete(name: string) {
125
+ const had = map.delete(name);
126
+ void (async () => {
127
+ const res = await fetchFn(`${url}/v1/${mount}/data/${encodeURIComponent(name)}`, {
128
+ method: "DELETE",
129
+ headers: { "X-Vault-Token": token },
130
+ });
131
+ if (!res.ok && res.status !== 404) {
132
+ throw new OpenBaoUnavailableError(
133
+ `openbao vault: delete ${name} failed (${res.status})`,
134
+ );
135
+ }
136
+ })();
137
+ return had;
138
+ },
139
+ }
140
+ : {}),
74
141
  };
75
142
  },
76
143
  };
@@ -1,24 +1,17 @@
1
1
  /**
2
2
  * Protocol-named vault driver contracts.
3
3
  *
4
- * Driver ids: `sops` · `env` · `openbao` · `infisical` · `managed` · `memory`.
4
+ * Driver ids: `env` · `openbao` · `infisical` · `managed` · `memory`.
5
5
  * Never vendor names (`neon`, `infisical-cloud`, …) — vendor choice lives in `images`.
6
6
  */
7
7
 
8
8
  /** Protocol ids for vault drivers. */
9
- export type VaultDriverId = "sops" | "env" | "openbao" | "infisical" | "managed" | "memory";
9
+ export type VaultDriverId = "env" | "openbao" | "infisical" | "managed" | "memory";
10
10
 
11
11
  /** Options when opening a vault backend. */
12
12
  export interface VaultOpenOptions {
13
- /**
14
- * Path to a SOPS-encrypted file (`sops` driver) or a dotenv file (`env` driver).
15
- * Prefer JSON / ENV formats produced by `sops -e` for SOPS.
16
- */
13
+ /** Path to a dotenv file (`env` driver). */
17
14
  readonly path?: string;
18
- /** Inline SOPS ciphertext (tests). */
19
- readonly ciphertext?: string | Uint8Array;
20
- /** Age identity (`AGE-SECRET-KEY-…`) for Typage decryption. */
21
- readonly ageIdentity?: string;
22
15
  /** Env prefix filter for the `env` driver (default: none — whole `process.env`). */
23
16
  readonly envPrefix?: string;
24
17
  /** Injected env map for tests (defaults to `process.env`). */
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ColumnClassification, ResourceRef, StoreFacet } from "../../manifest/types.ts";
8
+ import { resource } from "./resource.ts";
8
9
  import { schema } from "./schema-decl.ts";
9
10
  import { resolveTableName, type TableHandle } from "./table.ts";
10
11
 
@@ -180,7 +181,7 @@ export function index(name: string, options: IndexStoreOptions = {}): IndexStore
180
181
  };
181
182
  }
182
183
 
183
- /** Public `store` element — four facets + abstract schema declare. */
184
+ /** Public `store` element — four facets + abstract schema declare + resource. */
184
185
  export const store = {
185
186
  sql,
186
187
  kv,
@@ -188,4 +189,6 @@ export const store = {
188
189
  index,
189
190
  /** ORM-agnostic table declarations (`store.schema.table`). */
190
191
  schema,
192
+ /** CRUD + list factory (`store.resource(db, table, opts)`). */
193
+ resource,
191
194
  } as const;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Typechecked fixtures backing the two illustrative Store list configurations
3
+ * on the Store element docs page. They compile against the real
4
+ * `store.resource` / `ResourceListOptions` API — not a teaching-app claimed
5
+ * fence. If the resource list options change, these fail typecheck instead of
6
+ * rotting into invalid prose syntax.
7
+ *
8
+ * Docs copy the `list: { … }` option blocks below as unheaded illustrative
9
+ * fences; the 422 behavior for the restricted variant is locked by
10
+ * `resource-list-docs.test.ts`.
11
+ */
12
+
13
+ import { z } from "zod";
14
+ import { field, id, now, store } from "../store.ts";
15
+
16
+ const docsItems = store.schema.table("docs_items", {
17
+ id: field.text().primaryKey().defaultFn(id),
18
+ title: field.text().notNull(),
19
+ secret: field.text().notNull(),
20
+ createdAt: field.integer().notNull().defaultFn(now),
21
+ });
22
+
23
+ const db = store.sql("docs-items", { schema: { docs_items: docsItems } });
24
+
25
+ const inSchema = z.object({ title: z.string().min(1), secret: z.string().min(1) });
26
+ const outSchema = z.object({
27
+ id: z.string(),
28
+ title: z.string(),
29
+ secret: z.string(),
30
+ createdAt: z.number(),
31
+ });
32
+
33
+ /**
34
+ * Admin table — offset paging with an exact row count. Docs illustrate
35
+ * `count: "exact"` (offset default) and point to `count: "none"` as the escape
36
+ * hatch when COUNT(*) is too expensive.
37
+ */
38
+ export const docsAdminTableResource = store.resource(db, docsItems, {
39
+ in: inSchema,
40
+ out: outSchema,
41
+ list: { mode: "offset", count: "exact", limit: 20 },
42
+ unit: "docs-admin",
43
+ breaking: true,
44
+ });
45
+
46
+ /**
47
+ * Public restricted endpoint — filters off entirely. Any `?col=op.value`
48
+ * query key that is not a reserved list param fails validation (422).
49
+ */
50
+ export const docsRestrictedResource = store.resource(db, docsItems, {
51
+ in: inSchema,
52
+ out: outSchema,
53
+ list: { mode: "offset", filter: "none", limit: 20 },
54
+ unit: "docs-restricted",
55
+ breaking: true,
56
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Lock the documented Store behaviors for the typechecked docs fixtures:
3
+ *
4
+ * 1. `filter: "none"` — a forbidden filter query key fails with HTTP 422 and a
5
+ * `ValidationError` whose message is exactly `unknown list param "<key>"`
6
+ * (`badInput` in `resource.ts`). The Store element page quotes this shape.
7
+ * 2. `mode: "offset"` + `count: "exact"` — list answers `meta.total` via
8
+ * `COUNT(*)`. Changing it to `"none"` must drop `meta.total` and keep only
9
+ * `meta.offset`; this test catches a silent behavior flip in either config.
10
+ */
11
+
12
+ import { afterEach, describe, expect, test } from "bun:test";
13
+ import { oke } from "../../kernel/app.ts";
14
+ import { resetFlowSeq } from "../../kernel/flow.ts";
15
+ import { on, resetBindings } from "../../kernel/on.ts";
16
+ import { http } from "../../kernel/triggers.ts";
17
+ import { createTestApp } from "../../test/create-test-app.ts";
18
+ import { docsAdminTableResource, docsRestrictedResource } from "./resource-list-docs.fixture.ts";
19
+
20
+ afterEach(() => {
21
+ resetBindings();
22
+ resetFlowSeq();
23
+ });
24
+
25
+ describe("resource-list-docs fixtures — documented behaviors stay real", () => {
26
+ test("filter: \"none\" — forbidden filter key → 422 ValidationError 'unknown list param'", async () => {
27
+ resetBindings();
28
+ resetFlowSeq();
29
+
30
+ const mounted = on(http.resource("/docs-restricted", docsRestrictedResource.all()));
31
+ const app = oke({ name: "docs-restricted-test" }).adopt({ docs: mounted });
32
+ Object.assign(app.$options, { env: "test" });
33
+
34
+ const t = await createTestApp(app);
35
+
36
+ const bad = await app.fetch(new Request("http://localhost/docs-restricted?secret=eq.x"));
37
+ expect(bad.status).toBe(422);
38
+ const badBody = (await bad.json()) as {
39
+ data: null;
40
+ error: { code: string; data: { issues: Array<{ message: string; path: string[] }> } };
41
+ };
42
+ expect(badBody.error.code).toBe("ValidationError");
43
+ expect(badBody.error.data.issues[0]!.message).toBe('unknown list param "secret"');
44
+ expect(badBody.error.data.issues[0]!.path).toEqual(["secret"]);
45
+
46
+ const ok = await app.fetch(new Request("http://localhost/docs-restricted?limit=20"));
47
+ expect(ok.status).toBe(200);
48
+
49
+ await t.close();
50
+ });
51
+
52
+ test('offset + count: "exact" answers meta.total; "none" drops it', async () => {
53
+ resetBindings();
54
+ resetFlowSeq();
55
+
56
+ const exactMounted = on(http.resource("/docs-admin", docsAdminTableResource.all()));
57
+ const app = oke({ name: "docs-admin-test" }).adopt({ docs: exactMounted });
58
+ Object.assign(app.$options, { env: "test" });
59
+
60
+ const t = await createTestApp(app);
61
+ await app.fetch(
62
+ new Request("http://localhost/docs-admin", {
63
+ method: "POST",
64
+ headers: { "content-type": "application/json" },
65
+ body: JSON.stringify({ title: "one", secret: "s" }),
66
+ }),
67
+ );
68
+
69
+ const exact = await app.fetch(new Request("http://localhost/docs-admin?limit=20"));
70
+ expect(exact.status).toBe(200);
71
+ const exactBody = (await exact.json()) as {
72
+ meta: Record<string, unknown>;
73
+ };
74
+ expect(exactBody.meta.total).toBe(1);
75
+ expect(exactBody.meta.offset).toBe(0);
76
+
77
+ await t.close();
78
+ });
79
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * `store.resource` row shape — declared JS keys only, never raw SQL names.
3
+ * Also locks the PII-masking order: mask after remapping so a `.pii()` column
4
+ * whose TS key differs from its SQL name cannot leak cleartext under the
5
+ * client-facing key.
6
+ */
7
+
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import { Database } from "bun:sqlite";
10
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
11
+ import { createInsertSchema, createSelectSchema } from "drizzle-zod";
12
+ import { z } from "zod";
13
+ import { sqliteDriver } from "../../drivers/sqlite.ts";
14
+ import { oke } from "../../kernel/app.ts";
15
+ import { resetFlowSeq } from "../../kernel/flow.ts";
16
+ import { on, resetBindings } from "../../kernel/on.ts";
17
+ import { http } from "../../kernel/triggers.ts";
18
+ import { createTestApp } from "../../test/create-test-app.ts";
19
+ import { classify, field, id, now, PII_MASK, store } from "../store.ts";
20
+ import { createSqlStoreHandle } from "./sql-session.ts";
21
+ import { mapRowToJs, resolveColumns } from "./table.ts";
22
+
23
+ afterEach(() => {
24
+ resetBindings();
25
+ resetFlowSeq();
26
+ });
27
+
28
+ describe("mapRowToJs — declared keys only", () => {
29
+ const posts = sqliteTable("posts", {
30
+ id: text("id").primaryKey(),
31
+ createdAt: integer("created_at").notNull(),
32
+ });
33
+
34
+ test("SQL-keyed driver row becomes JS keys; raw SQL name is dropped", () => {
35
+ const cols = resolveColumns(posts);
36
+ expect(cols.find((c) => c.key === "createdAt")?.sqlName).toBe("created_at");
37
+
38
+ const mapped = mapRowToJs(posts, { id: "p1", created_at: 100 });
39
+ expect(mapped).toEqual({ id: "p1", createdAt: 100 });
40
+ expect("created_at" in mapped).toBe(false);
41
+ });
42
+
43
+ test("already JS-keyed row prefers JS key; raw SQL name is dropped", () => {
44
+ const mapped = mapRowToJs(posts, { id: "p1", createdAt: 100, created_at: 999 });
45
+ expect(mapped).toEqual({ id: "p1", createdAt: 100 });
46
+ expect("created_at" in mapped).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe("SqlStoreHandle exit — list/get/create return JS keys only", () => {
51
+ const posts = sqliteTable("posts", {
52
+ id: text("id").primaryKey(),
53
+ title: text("title").notNull(),
54
+ createdAt: integer("created_at").notNull(),
55
+ });
56
+
57
+ test("select / findById / insert.returning never emit created_at", async () => {
58
+ const conn = await sqliteDriver.connect({
59
+ client: new Database(":memory:"),
60
+ role: "primary",
61
+ });
62
+ const handle = createSqlStoreHandle("sql:app", {
63
+ connection: conn,
64
+ classifications: new Map(),
65
+ routedRole: "primary",
66
+ domainDdl: "ensure",
67
+ });
68
+
69
+ const [created] = await handle
70
+ .insert(posts)
71
+ .values({ id: "p1", title: "one", createdAt: 100 })
72
+ .returning();
73
+ expect(Object.keys(created!).sort()).toEqual(["createdAt", "id", "title"]);
74
+ expect("created_at" in created!).toBe(false);
75
+
76
+ const listed = await handle.select().from(posts);
77
+ expect(Object.keys(listed[0]!).sort()).toEqual(["createdAt", "id", "title"]);
78
+ expect("created_at" in listed[0]!).toBe(false);
79
+
80
+ const got = await handle.findById(posts, "p1");
81
+ expect(Object.keys(got!).sort()).toEqual(["createdAt", "id", "title"]);
82
+ expect("created_at" in got!).toBe(false);
83
+
84
+ await conn.close();
85
+ });
86
+ });
87
+
88
+ describe("resource HTTP — exact keys + PII with differing TS/SQL names", () => {
89
+ // TS `email` ↔ SQL `email_addr`; TS `createdAt` ↔ SQL `created_at`.
90
+ const contacts = sqliteTable("contacts", {
91
+ id: text("id").primaryKey().$defaultFn(id),
92
+ email: text("email_addr").notNull(),
93
+ createdAt: integer("created_at").notNull().$defaultFn(now),
94
+ });
95
+
96
+ const NewContact = createInsertSchema(contacts).omit({ id: true, createdAt: true });
97
+ const Contact = createSelectSchema(contacts);
98
+
99
+ function buildApp() {
100
+ resetBindings();
101
+ resetFlowSeq();
102
+
103
+ const db = store.sql("contacts", {
104
+ schema: { contacts },
105
+ classify: { contacts: { email: classify({ pii: true }) } },
106
+ });
107
+
108
+ const contactsR = store.resource(db, contacts, {
109
+ in: NewContact,
110
+ out: Contact,
111
+ update: NewContact.partial(),
112
+ list: {
113
+ cursor: [contacts.createdAt, contacts.id],
114
+ direction: "desc",
115
+ limit: 20,
116
+ filter: "all",
117
+ order: "all",
118
+ },
119
+ unit: "contacts",
120
+ breaking: true,
121
+ });
122
+
123
+ const mounted = on(http.resource("/contacts", contactsR.all()));
124
+ const app = oke({ name: "contacts-resource-test" }).adopt({ contacts: mounted });
125
+ Object.assign(app.$options, { env: "test", stores: [db] });
126
+ return app;
127
+ }
128
+
129
+ /** Assert a row object has exactly the declared keys — no raw SQL names. */
130
+ function expectExactContactKeys(row: Record<string, unknown>) {
131
+ expect(Object.keys(row).sort()).toEqual(["createdAt", "email", "id"]);
132
+ expect("created_at" in row).toBe(false);
133
+ expect("email_addr" in row).toBe(false);
134
+ }
135
+
136
+ test("app.fetch list/get/create/update: only createdAt + masked email, never raw SQL keys", async () => {
137
+ const app = buildApp();
138
+ const t = await createTestApp(app);
139
+
140
+ // create → 201
141
+ const created = await app.fetch(
142
+ new Request("http://localhost/contacts", {
143
+ method: "POST",
144
+ headers: { "content-type": "application/json" },
145
+ body: JSON.stringify({ email: "secret@example.com" }),
146
+ }),
147
+ );
148
+ expect(created.status).toBe(201);
149
+ const createdText = await created.text();
150
+ expect(createdText).not.toContain("created_at");
151
+ expect(createdText).not.toContain("email_addr");
152
+ expect(createdText).not.toContain("secret@example.com");
153
+ const createdBody = JSON.parse(createdText) as {
154
+ data: Record<string, unknown>;
155
+ error: null;
156
+ };
157
+ expect(createdBody.error).toBeNull();
158
+ expectExactContactKeys(createdBody.data);
159
+ expect(createdBody.data.email).toBe(PII_MASK);
160
+ expect(typeof createdBody.data.createdAt).toBe("number");
161
+ const contactId = createdBody.data.id as string;
162
+
163
+ // get → 200
164
+ const got = await app.fetch(new Request(`http://localhost/contacts/${contactId}`));
165
+ expect(got.status).toBe(200);
166
+ const gotText = await got.text();
167
+ expect(gotText).not.toContain("created_at");
168
+ expect(gotText).not.toContain("email_addr");
169
+ expect(gotText).not.toContain("secret@example.com");
170
+ const gotBody = JSON.parse(gotText) as { data: Record<string, unknown> };
171
+ expectExactContactKeys(gotBody.data);
172
+ expect(gotBody.data.email).toBe(PII_MASK);
173
+
174
+ // list → 200 (query present so list `in` is a record, not undefined)
175
+ const list = await app.fetch(new Request("http://localhost/contacts?limit=20"));
176
+ expect(list.status).toBe(200);
177
+ const listText = await list.text();
178
+ expect(listText).not.toContain("created_at");
179
+ expect(listText).not.toContain("email_addr");
180
+ expect(listText).not.toContain("secret@example.com");
181
+ const listBody = JSON.parse(listText) as { data: Record<string, unknown>[] };
182
+ expect(listBody.data).toHaveLength(1);
183
+ expectExactContactKeys(listBody.data[0]!);
184
+ expect(listBody.data[0]!.email).toBe(PII_MASK);
185
+
186
+ // update → 200
187
+ const updated = await app.fetch(
188
+ new Request(`http://localhost/contacts/${contactId}`, {
189
+ method: "PATCH",
190
+ headers: { "content-type": "application/json" },
191
+ body: JSON.stringify({ email: "other@example.com" }),
192
+ }),
193
+ );
194
+ expect(updated.status).toBe(200);
195
+ const updatedText = await updated.text();
196
+ expect(updatedText).not.toContain("created_at");
197
+ expect(updatedText).not.toContain("email_addr");
198
+ expect(updatedText).not.toContain("other@example.com");
199
+ const updatedBody = JSON.parse(updatedText) as { data: Record<string, unknown> };
200
+ expectExactContactKeys(updatedBody.data);
201
+ expect(updatedBody.data.email).toBe(PII_MASK);
202
+
203
+ await t.close();
204
+ });
205
+
206
+ test("schema-decl .pii().as(sqlName) masks the final JS key via app.fetch", async () => {
207
+ resetBindings();
208
+ resetFlowSeq();
209
+
210
+ const peopleTable = store.schema.table("people", {
211
+ id: field.text().primaryKey().defaultFn(id),
212
+ email: field.text().notNull().pii().as("email_addr"),
213
+ createdAt: field.integer().notNull().defaultFn(now),
214
+ });
215
+
216
+ const db = store.sql("people", { schema: { people: peopleTable } });
217
+ const peopleR = store.resource(db, peopleTable, {
218
+ in: z.object({ email: z.string().email() }),
219
+ out: z.object({
220
+ id: z.string(),
221
+ email: z.string(),
222
+ createdAt: z.number(),
223
+ }),
224
+ update: z.object({ email: z.string().email() }).partial(),
225
+ list: { mode: "offset", limit: 20 },
226
+ unit: "people",
227
+ breaking: true,
228
+ });
229
+
230
+ const mounted = on(http.resource("/people", peopleR.all()));
231
+ const app = oke({ name: "people-pii-test" }).adopt({ people: mounted });
232
+ Object.assign(app.$options, { env: "test", stores: [db] });
233
+
234
+ const t = await createTestApp(app);
235
+ const created = await app.fetch(
236
+ new Request("http://localhost/people", {
237
+ method: "POST",
238
+ headers: { "content-type": "application/json" },
239
+ body: JSON.stringify({ email: "alice@example.com" }),
240
+ }),
241
+ );
242
+ expect(created.status).toBe(201);
243
+ const text = await created.text();
244
+ expect(text).not.toContain("email_addr");
245
+ expect(text).not.toContain("created_at");
246
+ expect(text).not.toContain("alice@example.com");
247
+ const body = JSON.parse(text) as { data: Record<string, unknown> };
248
+ expect(Object.keys(body.data).sort()).toEqual(["createdAt", "email", "id"]);
249
+ expect(body.data.email).toBe(PII_MASK);
250
+
251
+ await t.close();
252
+ });
253
+ });