@vendoai/vendo 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +28 -0
  3. package/bin/vendo.mjs +4 -0
  4. package/dist/ai-sdk.d.ts +35 -0
  5. package/dist/ai-sdk.js +62 -0
  6. package/dist/auth-presets/auth-js.d.ts +14 -0
  7. package/dist/auth-presets/auth-js.js +91 -0
  8. package/dist/auth-presets/auth0.d.ts +23 -0
  9. package/dist/auth-presets/auth0.js +98 -0
  10. package/dist/auth-presets/clerk.d.ts +23 -0
  11. package/dist/auth-presets/clerk.js +65 -0
  12. package/dist/auth-presets/conformance.d.ts +42 -0
  13. package/dist/auth-presets/conformance.js +139 -0
  14. package/dist/auth-presets/identity.d.ts +56 -0
  15. package/dist/auth-presets/identity.js +152 -0
  16. package/dist/auth-presets/index.d.ts +14 -0
  17. package/dist/auth-presets/index.js +12 -0
  18. package/dist/auth-presets/jwt.d.ts +16 -0
  19. package/dist/auth-presets/jwt.js +54 -0
  20. package/dist/auth-presets/shared.d.ts +35 -0
  21. package/dist/auth-presets/shared.js +1 -0
  22. package/dist/auth-presets/supabase.d.ts +42 -0
  23. package/dist/auth-presets/supabase.js +229 -0
  24. package/dist/byo-approvals.d.ts +37 -0
  25. package/dist/byo-approvals.js +189 -0
  26. package/dist/capability-misses.d.ts +38 -0
  27. package/dist/capability-misses.js +172 -0
  28. package/dist/catalog.d.ts +19 -0
  29. package/dist/catalog.js +152 -0
  30. package/dist/cli/cloud/args.d.ts +3 -0
  31. package/dist/cli/cloud/args.js +32 -0
  32. package/dist/cli/cloud/auth.d.ts +17 -0
  33. package/dist/cli/cloud/auth.js +123 -0
  34. package/dist/cli/cloud/client.d.ts +28 -0
  35. package/dist/cli/cloud/client.js +133 -0
  36. package/dist/cli/cloud/command.d.ts +22 -0
  37. package/dist/cli/cloud/command.js +73 -0
  38. package/dist/cli/cloud/deploy.d.ts +26 -0
  39. package/dist/cli/cloud/deploy.js +187 -0
  40. package/dist/cli/cloud/device-login.d.ts +32 -0
  41. package/dist/cli/cloud/device-login.js +259 -0
  42. package/dist/cli/cloud/index.d.ts +5 -0
  43. package/dist/cli/cloud/index.js +59 -0
  44. package/dist/cli/cloud/keys.d.ts +2 -0
  45. package/dist/cli/cloud/keys.js +31 -0
  46. package/dist/cli/cloud/members.d.ts +3 -0
  47. package/dist/cli/cloud/members.js +25 -0
  48. package/dist/cli/cloud/output.d.ts +4 -0
  49. package/dist/cli/cloud/output.js +12 -0
  50. package/dist/cli/cloud/pending-claim.d.ts +29 -0
  51. package/dist/cli/cloud/pending-claim.js +38 -0
  52. package/dist/cli/cloud/read.d.ts +3 -0
  53. package/dist/cli/cloud/read.js +12 -0
  54. package/dist/cli/cloud/services.d.ts +2 -0
  55. package/dist/cli/cloud/services.js +9 -0
  56. package/dist/cli/cloud/session.d.ts +13 -0
  57. package/dist/cli/cloud/session.js +41 -0
  58. package/dist/cli/cloud-init.d.ts +60 -0
  59. package/dist/cli/cloud-init.js +149 -0
  60. package/dist/cli/dep-versions.d.ts +26 -0
  61. package/dist/cli/dep-versions.js +77 -0
  62. package/dist/cli/doctor-codes.d.ts +58 -0
  63. package/dist/cli/doctor-codes.js +60 -0
  64. package/dist/cli/doctor-live.d.ts +67 -0
  65. package/dist/cli/doctor-live.js +169 -0
  66. package/dist/cli/doctor.d.ts +52 -0
  67. package/dist/cli/doctor.js +467 -0
  68. package/dist/cli/eject.d.ts +29 -0
  69. package/dist/cli/eject.js +141 -0
  70. package/dist/cli/extract/apply.d.ts +26 -0
  71. package/dist/cli/extract/apply.js +123 -0
  72. package/dist/cli/extract/claude-cli-harness.d.ts +18 -0
  73. package/dist/cli/extract/claude-cli-harness.js +132 -0
  74. package/dist/cli/extract/claude-harness.d.ts +14 -0
  75. package/dist/cli/extract/claude-harness.js +127 -0
  76. package/dist/cli/extract/codex-cli-harness.d.ts +31 -0
  77. package/dist/cli/extract/codex-cli-harness.js +154 -0
  78. package/dist/cli/extract/delegate.d.ts +21 -0
  79. package/dist/cli/extract/delegate.js +77 -0
  80. package/dist/cli/extract/extraction.d.ts +65 -0
  81. package/dist/cli/extract/extraction.js +300 -0
  82. package/dist/cli/extract/gateway-fuel.d.ts +69 -0
  83. package/dist/cli/extract/gateway-fuel.js +69 -0
  84. package/dist/cli/extract/harness.d.ts +114 -0
  85. package/dist/cli/extract/harness.js +84 -0
  86. package/dist/cli/extract/index.d.ts +9 -0
  87. package/dist/cli/extract/index.js +9 -0
  88. package/dist/cli/extract/npx-engine-harness.d.ts +73 -0
  89. package/dist/cli/extract/npx-engine-harness.js +225 -0
  90. package/dist/cli/extract/stages.d.ts +149 -0
  91. package/dist/cli/extract/stages.js +398 -0
  92. package/dist/cli/framework.d.ts +8 -0
  93. package/dist/cli/framework.js +36 -0
  94. package/dist/cli/init-auth.d.ts +67 -0
  95. package/dist/cli/init-auth.js +142 -0
  96. package/dist/cli/init-scaffolds.d.ts +28 -0
  97. package/dist/cli/init-scaffolds.js +235 -0
  98. package/dist/cli/init.d.ts +115 -0
  99. package/dist/cli/init.js +1008 -0
  100. package/dist/cli/mcp/index.d.ts +7 -0
  101. package/dist/cli/mcp/index.js +59 -0
  102. package/dist/cli/mcp/registry.d.ts +9 -0
  103. package/dist/cli/mcp/registry.js +117 -0
  104. package/dist/cli/mcp/server-json.d.ts +12 -0
  105. package/dist/cli/mcp/server-json.js +65 -0
  106. package/dist/cli/mcp/server.schema.json +574 -0
  107. package/dist/cli/mcp/verify-domain.d.ts +11 -0
  108. package/dist/cli/mcp/verify-domain.js +48 -0
  109. package/dist/cli/playground/app/embed-entry.d.ts +20 -0
  110. package/dist/cli/playground/app/embed-entry.js +46 -0
  111. package/dist/cli/playground/app/fake-client.d.ts +3 -0
  112. package/dist/cli/playground/app/fake-client.js +215 -0
  113. package/dist/cli/playground/app/fixtures.d.ts +47 -0
  114. package/dist/cli/playground/app/fixtures.js +472 -0
  115. package/dist/cli/playground/app/main.d.ts +1 -0
  116. package/dist/cli/playground/app/main.js +108 -0
  117. package/dist/cli/playground/app/scenario-mount.d.ts +13 -0
  118. package/dist/cli/playground/app/scenario-mount.js +48 -0
  119. package/dist/cli/playground/app/scenarios.d.ts +25 -0
  120. package/dist/cli/playground/app/scenarios.js +152 -0
  121. package/dist/cli/playground/app/theme-editor.d.ts +13 -0
  122. package/dist/cli/playground/app/theme-editor.js +149 -0
  123. package/dist/cli/playground/app/theme-state.d.ts +29 -0
  124. package/dist/cli/playground/app/theme-state.js +151 -0
  125. package/dist/cli/playground/bundle.gen.d.ts +2 -0
  126. package/dist/cli/playground/bundle.gen.js +2 -0
  127. package/dist/cli/playground/embed-bundle.gen.d.ts +2 -0
  128. package/dist/cli/playground/embed-bundle.gen.js +2 -0
  129. package/dist/cli/playground.d.ts +33 -0
  130. package/dist/cli/playground.js +120 -0
  131. package/dist/cli/pretty.d.ts +48 -0
  132. package/dist/cli/pretty.js +330 -0
  133. package/dist/cli/refine.d.ts +43 -0
  134. package/dist/cli/refine.js +181 -0
  135. package/dist/cli/semantics.d.ts +31 -0
  136. package/dist/cli/semantics.js +122 -0
  137. package/dist/cli/shared.d.ts +68 -0
  138. package/dist/cli/shared.js +184 -0
  139. package/dist/cli/sync.d.ts +36 -0
  140. package/dist/cli/sync.js +202 -0
  141. package/dist/cli/theme/color.d.ts +15 -0
  142. package/dist/cli/theme/color.js +188 -0
  143. package/dist/cli/theme/css-vars.d.ts +13 -0
  144. package/dist/cli/theme/css-vars.js +40 -0
  145. package/dist/cli/theme/entry-candidates.d.ts +5 -0
  146. package/dist/cli/theme/entry-candidates.js +22 -0
  147. package/dist/cli/theme/extract-theme.d.ts +184 -0
  148. package/dist/cli/theme/extract-theme.js +358 -0
  149. package/dist/cli/theme/walk.d.ts +2 -0
  150. package/dist/cli/theme/walk.js +24 -0
  151. package/dist/cli.d.ts +2 -0
  152. package/dist/cli.js +333 -0
  153. package/dist/cloud-apps.d.ts +17 -0
  154. package/dist/cloud-apps.js +46 -0
  155. package/dist/cloud-console.d.ts +32 -0
  156. package/dist/cloud-console.js +72 -0
  157. package/dist/cloud-tools.d.ts +23 -0
  158. package/dist/cloud-tools.js +196 -0
  159. package/dist/connections.d.ts +71 -0
  160. package/dist/connections.js +192 -0
  161. package/dist/deployment-identity.d.ts +12 -0
  162. package/dist/deployment-identity.js +69 -0
  163. package/dist/dev-creds/model.d.ts +65 -0
  164. package/dist/dev-creds/model.js +159 -0
  165. package/dist/dev-creds/resolve.d.ts +36 -0
  166. package/dist/dev-creds/resolve.js +57 -0
  167. package/dist/hosted-store.d.ts +52 -0
  168. package/dist/hosted-store.js +322 -0
  169. package/dist/index.d.ts +13 -0
  170. package/dist/index.js +1 -0
  171. package/dist/mastra.d.ts +46 -0
  172. package/dist/mastra.js +88 -0
  173. package/dist/react.d.ts +10 -0
  174. package/dist/react.js +32 -0
  175. package/dist/refine.d.ts +244 -0
  176. package/dist/refine.js +599 -0
  177. package/dist/remixable.d.ts +18 -0
  178. package/dist/remixable.js +42 -0
  179. package/dist/runtime-capture.d.ts +18 -0
  180. package/dist/runtime-capture.js +111 -0
  181. package/dist/sandbox-wire.d.ts +101 -0
  182. package/dist/sandbox-wire.js +101 -0
  183. package/dist/sandbox.d.ts +38 -0
  184. package/dist/sandbox.js +389 -0
  185. package/dist/server.d.ts +216 -0
  186. package/dist/server.js +1320 -0
  187. package/dist/sync-impact.d.ts +14 -0
  188. package/dist/sync-impact.js +75 -0
  189. package/dist/turn-liveness.d.ts +33 -0
  190. package/dist/turn-liveness.js +105 -0
  191. package/dist/wire/approvals.d.ts +10 -0
  192. package/dist/wire/approvals.js +59 -0
  193. package/dist/wire/apps.d.ts +5 -0
  194. package/dist/wire/apps.js +155 -0
  195. package/dist/wire/automations.d.ts +5 -0
  196. package/dist/wire/automations.js +57 -0
  197. package/dist/wire/box.d.ts +3 -0
  198. package/dist/wire/box.js +268 -0
  199. package/dist/wire/connections.d.ts +5 -0
  200. package/dist/wire/connections.js +47 -0
  201. package/dist/wire/context.d.ts +22 -0
  202. package/dist/wire/context.js +190 -0
  203. package/dist/wire/doctor.d.ts +19 -0
  204. package/dist/wire/doctor.js +116 -0
  205. package/dist/wire/misc.d.ts +18 -0
  206. package/dist/wire/misc.js +227 -0
  207. package/dist/wire/shared.d.ts +158 -0
  208. package/dist/wire/shared.js +134 -0
  209. package/dist/wire/threads.d.ts +3 -0
  210. package/dist/wire/threads.js +68 -0
  211. package/package.json +151 -0
  212. package/skills/vendo-setup/SKILL.md +128 -0
@@ -0,0 +1,268 @@
1
+ import { VendoError } from "@vendoai/core";
2
+ import { json, prefixRoute, route, string } from "./shared.js";
3
+ /** execution-v2 skin contract (Lane C) — the two wire surfaces on the boundary
4
+ of the box:
5
+ 1. the fn PROXY (`POST /apps/:appId/fn/:name`): the authenticated end-user
6
+ route that wakes the app's machine through the apps runtime's box door
7
+ and forwards ONE request to the box's `POST /fn/<name>`, relaying
8
+ status/body. The tree side of the contract.
9
+ 2. the CALLBACK surface (`/box/...`): plain HTTP, authenticated by the
10
+ per-app bearer minted at provision (createAppTokens), curl-able from any
11
+ language inside the box — durable rows over the app-scoped store, and
12
+ host tools through the SAME guard-bound registry chat uses (approvals
13
+ and audit intact; a pending approval relays as its pending outcome,
14
+ never bypasses). The box side of the contract. The box never holds host
15
+ credentials — this surface is its single authority path. */
16
+ /** The fn-name half of core's 01 §8 grammar (mirrored in manifest.ts). */
17
+ const FN_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
18
+ /** Wire-side ceiling on one fn round-trip. The box is a wake-in-a-second
19
+ sandbox answering a function call, not a batch job; a slower answer should
20
+ time out loudly at the tree side rather than hold the host request open. */
21
+ const FN_TIMEOUT_MS = 30_000;
22
+ const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
23
+ const COLLECTION_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
24
+ /** Matches the app-data record ceiling (06-apps §6). */
25
+ const ROW_MAX_BYTES = 256 * 1024;
26
+ const decoder = new TextDecoder();
27
+ const TIMED_OUT = Symbol("vendo-box-timeout");
28
+ async function withTimeout(work, ms) {
29
+ let timer;
30
+ try {
31
+ return await Promise.race([
32
+ work,
33
+ new Promise((resolve) => {
34
+ timer = setTimeout(() => resolve(TIMED_OUT), ms);
35
+ timer.unref?.();
36
+ }),
37
+ ]);
38
+ }
39
+ finally {
40
+ clearTimeout(timer);
41
+ }
42
+ }
43
+ export const fnProxyRoutes = [
44
+ route("POST", "/apps/:appId/fn/:name", async ({ request, params, deps, context }) => {
45
+ const appId = string(params["appId"], "app id");
46
+ const name = params["name"] ?? "";
47
+ if (!FN_NAME_PATTERN.test(name)) {
48
+ throw new VendoError("validation", "fn name must match [A-Za-z_][A-Za-z0-9_-]{0,63}");
49
+ }
50
+ const ctx = await context("app");
51
+ // Principal scoping BEFORE any machine work: the same owner-scoped get
52
+ // every /apps route rides; a non-owner sees the app's absence, not a box.
53
+ if (await deps.apps.get(appId, ctx) === null) {
54
+ throw new VendoError("not-found", `app not found: ${appId}`);
55
+ }
56
+ const body = new Uint8Array(await request.arrayBuffer());
57
+ const contentType = request.headers.get("content-type");
58
+ // Forward ONLY the payload: no cookies, no authorization, no host headers
59
+ // cross the skin. The box's authority is its own app token, nothing more.
60
+ const work = deps.apps.box.request(appId, {
61
+ method: "POST",
62
+ path: `/fn/${name}`,
63
+ ...(contentType === null ? {} : { headers: { "content-type": contentType } }),
64
+ ...(body.byteLength === 0 ? {} : { body }),
65
+ }, ctx);
66
+ const answer = await withTimeout(work, FN_TIMEOUT_MS);
67
+ if (answer === TIMED_OUT) {
68
+ // The box keeps working; only this wire request gives up. Swallow the
69
+ // eventual settle so a late failure never surfaces as unhandled.
70
+ work.catch(() => undefined);
71
+ return json({ error: { code: "timeout", message: `fn ${name} did not answer within ${FN_TIMEOUT_MS}ms` } }, 504);
72
+ }
73
+ // Relay status/body; of the box's headers only content-type crosses back
74
+ // (no set-cookie or friends smuggled onto the host origin).
75
+ const relayType = Object.entries(answer.headers)
76
+ .find(([header]) => header.toLowerCase() === "content-type")?.[1];
77
+ return new Response(answer.body.byteLength === 0 ? null : answer.body, {
78
+ status: answer.status,
79
+ ...(relayType === undefined ? {} : { headers: { "content-type": relayType } }),
80
+ });
81
+ }),
82
+ ];
83
+ function bearerToken(request) {
84
+ const match = /^Bearer\s+(.+)$/i.exec(request.headers.get("authorization") ?? "");
85
+ return match?.[1] ?? null;
86
+ }
87
+ function requireJson(request) {
88
+ const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
89
+ if (contentType !== "application/json") {
90
+ throw new VendoError("validation", "content-type must be application/json");
91
+ }
92
+ }
93
+ async function readBoundedJson(request, maxBytes) {
94
+ const bytes = new Uint8Array(await request.arrayBuffer());
95
+ if (bytes.byteLength > maxBytes) {
96
+ throw new VendoError("validation", "request body exceeds size limit");
97
+ }
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(decoder.decode(bytes));
101
+ }
102
+ catch {
103
+ throw new VendoError("validation", "request body must be valid JSON");
104
+ }
105
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
106
+ throw new VendoError("validation", "request body must be an object");
107
+ }
108
+ return parsed;
109
+ }
110
+ /** The list query the rows surface accepts (mirrors the v1 proxy dialect). */
111
+ function rowsQuery(url) {
112
+ const refs = {};
113
+ let limit;
114
+ let cursor;
115
+ for (const [key, value] of url.searchParams) {
116
+ if (key.startsWith("refs.")) {
117
+ const ref = key.slice("refs.".length);
118
+ if (ref === "" || value === "") {
119
+ throw new VendoError("validation", "ref filters require non-empty keys and values");
120
+ }
121
+ refs[ref] = value;
122
+ continue;
123
+ }
124
+ if (key === "limit") {
125
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
126
+ throw new VendoError("validation", "limit must be a positive integer");
127
+ }
128
+ limit = Number(value);
129
+ continue;
130
+ }
131
+ if (key === "cursor") {
132
+ if (value === "")
133
+ throw new VendoError("validation", "cursor must be a non-empty string");
134
+ cursor = value;
135
+ continue;
136
+ }
137
+ throw new VendoError("validation", `unknown list query parameter: ${key}`);
138
+ }
139
+ return {
140
+ ...(Object.keys(refs).length === 0 ? {} : { refs }),
141
+ ...(limit === undefined ? {} : { limit }),
142
+ ...(cursor === undefined ? {} : { cursor }),
143
+ };
144
+ }
145
+ async function handleRows(wire, appId) {
146
+ const { request, url, segments, deps } = wire;
147
+ const collection = segments[2];
148
+ if (collection === undefined || !COLLECTION_PATTERN.test(collection)) {
149
+ throw new VendoError("validation", "rows collection must match [A-Za-z0-9_-]{1,64}");
150
+ }
151
+ // The app-scoped store namespace: the box: infix keeps box rows apart from
152
+ // the host-declared v1 storage collections sharing the app:<id> prefix.
153
+ const records = deps.store.records(`app:${appId}:box:${collection}`);
154
+ // Lane E redaction guard — nothing a box writes or reads through this door
155
+ // may carry a known secret value into a store row or a response body.
156
+ const scrub = (value) => deps.apps.box.redact(appId, value);
157
+ if (segments.length === 3) {
158
+ if (request.method !== "GET")
159
+ return undefined;
160
+ return json(await scrub(await records.list(rowsQuery(url))));
161
+ }
162
+ if (segments.length !== 4)
163
+ return undefined;
164
+ const id = segments[3];
165
+ if (id.length === 0 || id.length > 256) {
166
+ throw new VendoError("validation", "row id must be 1-256 characters");
167
+ }
168
+ if (request.method === "GET") {
169
+ const record = await records.get(id);
170
+ if (record === null)
171
+ throw new VendoError("not-found", `row not found: ${id}`);
172
+ return json(await scrub(record));
173
+ }
174
+ if (request.method === "DELETE") {
175
+ await records.delete(id);
176
+ return json({ status: "ok" });
177
+ }
178
+ if (request.method === "PUT") {
179
+ requireJson(request);
180
+ const body = await readBoundedJson(request, ROW_MAX_BYTES);
181
+ const unexpected = Object.keys(body).find((key) => key !== "data" && key !== "refs");
182
+ if (unexpected !== undefined) {
183
+ throw new VendoError("validation", `unexpected row property: ${unexpected}`);
184
+ }
185
+ if (!Object.prototype.hasOwnProperty.call(body, "data")) {
186
+ throw new VendoError("validation", "row body must contain data");
187
+ }
188
+ if (body["refs"] !== undefined) {
189
+ const refs = body["refs"];
190
+ if (typeof refs !== "object" || refs === null || Array.isArray(refs)
191
+ || Object.values(refs).some((value) => typeof value !== "string" || value === "")) {
192
+ throw new VendoError("validation", "row refs must be an object of non-empty strings");
193
+ }
194
+ }
195
+ // Scrub BEFORE persisting: a secret value must never land in a store row,
196
+ // even when the box itself sent it.
197
+ return json(await records.put(await scrub({
198
+ id,
199
+ data: body["data"],
200
+ ...(body["refs"] === undefined ? {} : { refs: body["refs"] }),
201
+ })));
202
+ }
203
+ return undefined;
204
+ }
205
+ async function handleTools(wire, ctx) {
206
+ const { request, segments, deps } = wire;
207
+ if (request.method !== "POST" || segments.length !== 3)
208
+ return undefined;
209
+ const name = segments[2];
210
+ if (!TOOL_NAME_PATTERN.test(name)) {
211
+ throw new VendoError("validation", "tool name must match [a-zA-Z0-9_-]{1,64}");
212
+ }
213
+ requireJson(request);
214
+ const body = await readBoundedJson(request, ROW_MAX_BYTES);
215
+ const args = body["args"];
216
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
217
+ throw new VendoError("validation", "tool body must be an object containing an args object");
218
+ }
219
+ // The SAME guard-bound registry every venue executes through: policy,
220
+ // grants, approvals, breakers, and audit all see this call. An ask-policy
221
+ // tool comes back { status: "pending-approval" } — relayed, never bypassed.
222
+ const outcome = await deps.tools.execute({
223
+ id: `call_${globalThis.crypto.randomUUID()}`,
224
+ tool: name,
225
+ args: args,
226
+ }, ctx);
227
+ // Lane E redaction guard — a tool outcome relayed into a response is a
228
+ // host-side artifact; scrub known secret values before it crosses back.
229
+ return json(await deps.apps.box.redact(ctx.appId ?? "", outcome));
230
+ }
231
+ export const boxRoutes = [
232
+ prefixRoute("*", "/box/", async (wire) => {
233
+ const { request, segments, deps } = wire;
234
+ // The bearer IS the identity: the per-app token minted at provision, its
235
+ // hash row the authority (createAppTokens). No cookie, no host principal —
236
+ // this surface is called from inside the box, in any language, via curl.
237
+ const presented = bearerToken(request);
238
+ const identity = presented === null ? null : await deps.appTokens.verify(presented);
239
+ if (identity === null) {
240
+ return json({ error: { code: "blocked", message: "invalid app token" } }, 401);
241
+ }
242
+ // The box acts AS the app's owner, away, in the app venue — exactly the
243
+ // authority a tree action carries when the owner isn't looking at it.
244
+ const ctx = {
245
+ principal: { kind: "user", subject: identity.subject },
246
+ venue: "app",
247
+ presence: "away",
248
+ sessionId: `box_${identity.appId}`,
249
+ appId: identity.appId,
250
+ };
251
+ // A deleted (or re-owned) app retires its token even before revocation.
252
+ if (await deps.apps.get(identity.appId, ctx) === null) {
253
+ return json({ error: { code: "blocked", message: "app token no longer valid" } }, 401);
254
+ }
255
+ const area = segments[1];
256
+ if (area === "rows") {
257
+ const handled = await handleRows(wire, identity.appId);
258
+ if (handled !== undefined)
259
+ return handled;
260
+ }
261
+ if (area === "tools") {
262
+ const handled = await handleTools(wire, ctx);
263
+ if (handled !== undefined)
264
+ return handled;
265
+ }
266
+ throw new VendoError("not-found", "unknown box route");
267
+ }),
268
+ ];
@@ -0,0 +1,5 @@
1
+ import { type RouteEntry } from "./shared.js";
2
+ /** 04-actions §3 (block-actions design §B) — per-principal connected
3
+ accounts. Subject scoping happens HERE: the wire passes exactly the
4
+ resolved principal; no caller-supplied subject exists on this surface. */
5
+ export declare const connectionRoutes: RouteEntry[];
@@ -0,0 +1,47 @@
1
+ import { VendoError } from "@vendoai/core";
2
+ import { json, requestJson, route, string } from "./shared.js";
3
+ /** 04-actions §3 (block-actions design §B) — per-principal connected
4
+ accounts. Subject scoping happens HERE: the wire passes exactly the
5
+ resolved principal; no caller-supplied subject exists on this surface. */
6
+ export const connectionRoutes = [
7
+ route("GET", "/connections", async ({ deps, context }) => {
8
+ const ctx = await context("chat");
9
+ return json({ connections: await deps.connections.list(ctx.principal) });
10
+ }),
11
+ route("POST", "/connections/initiate", async ({ request, deps, context }) => {
12
+ const body = await requestJson(request);
13
+ const ctx = await context("chat");
14
+ return json(await deps.connections.initiate(ctx.principal, {
15
+ toolkit: string(body["toolkit"], "toolkit"),
16
+ ...(body["connector"] === undefined ? {} : { connector: string(body["connector"], "connector") }),
17
+ ...(body["callbackUrl"] === undefined ? {} : { callbackUrl: string(body["callbackUrl"], "callbackUrl") }),
18
+ }));
19
+ }),
20
+ // The connect dock's auto catalog. Host-level rows, but the principal still
21
+ // resolves first so this surface authenticates exactly like its siblings.
22
+ // Must precede /connections/:id, which would otherwise swallow "catalog".
23
+ route("GET", "/connections/catalog", async ({ deps, context }) => {
24
+ await context("chat");
25
+ // Optional-capability guard: a pre-catalog custom adapter (plain JS, or
26
+ // compiled before the interface grew) advertises nothing rather than 500s.
27
+ return json({ available: await deps.connections.catalog?.() ?? [] });
28
+ }),
29
+ // Grouped like the old if-chain arm: ANY method resolves context first, and
30
+ // an unhandled method falls through to the table's not-found.
31
+ route("*", "/connections/:id", async ({ request, url, deps, context, params }) => {
32
+ const connectionId = string(params["id"], "connection id");
33
+ const connector = url.searchParams.get("connector") ?? "composio";
34
+ const ctx = await context("chat");
35
+ if (request.method === "GET") {
36
+ const connection = await deps.connections.status(ctx.principal, connector, connectionId);
37
+ if (connection === null)
38
+ throw new VendoError("not-found", `connection not found: ${connectionId}`);
39
+ return json(connection);
40
+ }
41
+ if (request.method === "DELETE") {
42
+ await deps.connections.disconnect(ctx.principal, connector, connectionId);
43
+ return json({});
44
+ }
45
+ return undefined;
46
+ }),
47
+ ];
@@ -0,0 +1,22 @@
1
+ import { type RunContext } from "@vendoai/core";
2
+ import { type WireDeps } from "./shared.js";
3
+ /** Append the minted Set-Cookie to the response. Stream/SSE responses carry
4
+ immutable headers, so re-wrap via `new Response(body, response)` (copies
5
+ status/statusText/headers into a fresh mutable Headers) before appending. */
6
+ export declare function withAnonCookie(response: Response, setCookie: string | undefined): Response;
7
+ /** Per-request anonymous-session state. The wire handler closure is shared
8
+ across requests, so this MUST be minted per-invocation — a shared one would
9
+ leak one visitor's session to the next. INVARIANT: one request resolves to
10
+ at most ONE anonymous id — `id` caches the first resolution so a route that
11
+ resolves context twice on a cookie-less request can never mint a second id
12
+ (which would silently split one request across two subjects and overwrite
13
+ the Set-Cookie). */
14
+ export interface AnonSession {
15
+ id?: string;
16
+ setCookie?: string;
17
+ }
18
+ /** The one context-resolution pass every route shares (kill-list B4): resolve
19
+ the host principal (or mint/read the per-client anonymous session), enforce
20
+ the resolver invariants, run the anonymous→signed-in merge, and touch the
21
+ ephemeral session row. Returned resolver is called per route with a venue. */
22
+ export declare function createContextResolver(deps: WireDeps, anon: AnonSession): (req: Request, venue: RunContext["venue"]) => Promise<RunContext>;
@@ -0,0 +1,190 @@
1
+ import { VendoError, isReservedSubject, principalSchema, } from "@vendoai/core";
2
+ import { BASE_PATH, hex } from "./shared.js";
3
+ /** The anonymous-session machinery + the one shared per-request context
4
+ resolution pass (kill-list B4): opaque anon cookie mint/read/clear, the
5
+ anonymous→signed-in merge, and RunContext assembly. Every wire area
6
+ resolves context through createContextResolver below. */
7
+ function requestHeaders(request) {
8
+ return Object.fromEntries(request.headers.entries());
9
+ }
10
+ function randomId() {
11
+ const raw = new Uint8Array(16); // 128-bit session id
12
+ globalThis.crypto.getRandomValues(raw);
13
+ return hex(raw);
14
+ }
15
+ function ephemeralPrincipal(subject) {
16
+ return { kind: "user", subject, ephemeral: true };
17
+ }
18
+ /** 00 overview ("no host principal resolver → an ephemeral session-scoped
19
+ principal"), 01-core §2, 02-store §4. When `principal(req)` returns null the
20
+ visitor is anonymous, and each CLIENT gets its OWN ephemeral principal —
21
+ carried by an opaque httpOnly cookie (a random 128-bit session id) so two
22
+ anonymous visitors never share threads, grants, approvals, or apps. The
23
+ cookie is just a pointer: the session's `vendo_sessions` row and its
24
+ ordinary disk rows are the authority (02-store §4, kill-list B3), so it
25
+ carries no signature — an invented id names its own empty session. */
26
+ const ANON_COOKIE = "vendo_anon_session";
27
+ /** Secure requests use the `__Host-` prefix against session fixation (cookie
28
+ tossing): a sibling subdomain could otherwise plant an attacker's own
29
+ session cookie via `Domain=` and read everything the victim's anonymous
30
+ session then accrues — browsers refuse `__Host-*` cookies that set Domain or
31
+ arrive from another host. `__Host-` REQUIRES Secure + Path=/ + no Domain. */
32
+ const ANON_COOKIE_SECURE = `__Host-${ANON_COOKIE}`;
33
+ function anonCookieName(secure) {
34
+ return secure ? ANON_COOKIE_SECURE : ANON_COOKIE;
35
+ }
36
+ /** Whether a request counts as secure for cookie purposes: its own URL is
37
+ https, OR the operator-set VENDO_BASE_URL (the TRUSTED origin channel —
38
+ never x-forwarded-*) is https — i.e. TLS terminates at a proxy and the
39
+ request reaches this process as http. */
40
+ function secureRequest(url, trustedBaseIsHttps) {
41
+ return url.protocol === "https:" || trustedBaseIsHttps;
42
+ }
43
+ function readCookie(header, name) {
44
+ if (header === null)
45
+ return null;
46
+ for (const part of header.split(";")) {
47
+ const eq = part.indexOf("=");
48
+ if (eq === -1)
49
+ continue;
50
+ if (part.slice(0, eq).trim() === name)
51
+ return part.slice(eq + 1).trim();
52
+ }
53
+ return null;
54
+ }
55
+ /** The shape of the opaque pointer we mint: 128-bit lowercase hex (randomId). */
56
+ const ANON_ID_PATTERN = /^[0-9a-f]{32}$/;
57
+ /** Read the anonymous-session pointer from the Cookie header; return the id
58
+ when it is a well-formed 128-bit hex pointer, else null (absent or
59
+ malformed → the caller mints a fresh session). There is nothing to verify
60
+ beyond shape: the session's `vendo_sessions` row is the authority, so an
61
+ invented id merely names its own EMPTY session — guessing a live one is a
62
+ 2^128 search (kill-list B3; ids survive restarts and cross instances with
63
+ the disk rows). Looks up the name matching the CURRENT request's secure
64
+ determination — a client switching protocols just gets a fresh ephemeral
65
+ session. */
66
+ function readAnonCookie(cookieHeader, secure) {
67
+ const raw = readCookie(cookieHeader, anonCookieName(secure));
68
+ return raw !== null && ANON_ID_PATTERN.test(raw) ? raw : null;
69
+ }
70
+ /** The Set-Cookie for a freshly minted anonymous session. Secure requests get
71
+ the fixation-proof `__Host-` form (Secure + Path=/, per the prefix rules);
72
+ insecure (localhost http dev) keeps the plain name scoped to the wire base. */
73
+ function buildAnonCookie(id, secure) {
74
+ return secure
75
+ ? `${ANON_COOKIE_SECURE}=${id}; Path=/; HttpOnly; SameSite=Lax; Secure`
76
+ : `${ANON_COOKIE}=${id}; Path=${BASE_PATH}; HttpOnly; SameSite=Lax`;
77
+ }
78
+ /** The Set-Cookie that CLEARS the anonymous session (block-actions design §C:
79
+ the first authenticated request carrying a valid anon cookie merges the
80
+ session's data and retires the cookie). Same attributes as buildAnonCookie
81
+ so the browser matches the stored cookie; Max-Age=0 expires it. */
82
+ function clearedAnonCookie(secure) {
83
+ return secure
84
+ ? `${ANON_COOKIE_SECURE}=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0`
85
+ : `${ANON_COOKIE}=; Path=${BASE_PATH}; HttpOnly; SameSite=Lax; Max-Age=0`;
86
+ }
87
+ /** Append the minted Set-Cookie to the response. Stream/SSE responses carry
88
+ immutable headers, so re-wrap via `new Response(body, response)` (copies
89
+ status/statusText/headers into a fresh mutable Headers) before appending. */
90
+ export function withAnonCookie(response, setCookie) {
91
+ if (setCookie === undefined)
92
+ return response;
93
+ const rewrapped = new Response(response.body, response);
94
+ rewrapped.headers.append("set-cookie", setCookie);
95
+ return rewrapped;
96
+ }
97
+ /** The one context-resolution pass every route shares (kill-list B4): resolve
98
+ the host principal (or mint/read the per-client anonymous session), enforce
99
+ the resolver invariants, run the anonymous→signed-in merge, and touch the
100
+ ephemeral session row. Returned resolver is called per route with a venue. */
101
+ export function createContextResolver(deps, anon) {
102
+ return async (req, venue) => {
103
+ const resolved = await deps.principal(req);
104
+ let principal;
105
+ // Host-resolved principals keep the process-wide fallback sessionId; only
106
+ // anonymous requests fall back to their per-client cookie id (below).
107
+ let sessionId = req.headers.get("x-vendo-session-id") ?? deps.sessionId;
108
+ if (resolved === null) {
109
+ const secure = secureRequest(new URL(req.url), deps.trustedBaseIsHttps);
110
+ let id = anon.id ?? readAnonCookie(req.headers.get("cookie"), secure);
111
+ if (id === null) {
112
+ id = randomId();
113
+ anon.setCookie = buildAnonCookie(id, secure);
114
+ }
115
+ anon.id = id;
116
+ principal = ephemeralPrincipal(`anonymous_${id}`);
117
+ // 05-guard §2: session/task grants bind to ctx.sessionId. Anonymous
118
+ // sessions bind per CLIENT (the cookie id), not per PROCESS, so one
119
+ // visitor's session grant never authorizes another's calls. The explicit
120
+ // x-vendo-session-id header still wins when the client sets it.
121
+ if (req.headers.get("x-vendo-session-id") === null)
122
+ sessionId = `anon_${id}`;
123
+ }
124
+ else {
125
+ const parsed = principalSchema.safeParse(resolved);
126
+ if (!parsed.success) {
127
+ throw new VendoError("validation", "principal resolver returned an invalid principal");
128
+ }
129
+ // Block-actions design §C: host resolvers mint USER principals only —
130
+ // org context is derived from membership, never resolved — and the
131
+ // `vendo:` namespace is reserved for runtime-minted subjects (webhook
132
+ // trigger principals, org subjects). Both rejections are LOUD: a
133
+ // resolver colliding with the reserved namespace could otherwise act
134
+ // as an org or a webhook principal.
135
+ if (parsed.data.kind !== "user") {
136
+ throw new VendoError("validation", "principal resolver must mint kind:\"user\" principals; org context is derived from org membership");
137
+ }
138
+ if (isReservedSubject(parsed.data.subject)) {
139
+ throw new VendoError("validation", "principal resolver produced a reserved subject (the vendo: namespace is runtime-minted only)");
140
+ }
141
+ principal = parsed.data;
142
+ // Anonymous→signed-in auto-merge (block-actions design §C): the FIRST
143
+ // authenticated request still carrying a valid anonymous-session
144
+ // cookie adopts that session's threads/apps/state into the signed-in
145
+ // subject (grants, approvals, and connected accounts deliberately do
146
+ // NOT transfer — consent doesn't change identities), then retires the
147
+ // cookie. Idempotent: a replay finds nothing to merge and just clears
148
+ // the cookie again. A merge failure must never take down the request:
149
+ // the cookie stays, and the next authenticated request retries.
150
+ if (principal.ephemeral !== true) {
151
+ const secure = secureRequest(new URL(req.url), deps.trustedBaseIsHttps);
152
+ const anonId = readAnonCookie(req.headers.get("cookie"), secure);
153
+ if (anonId !== null) {
154
+ try {
155
+ const merged = await deps.sessionStore.adopt(`anonymous_${anonId}`, principal.subject);
156
+ anon.setCookie = clearedAnonCookie(secure);
157
+ if (merged !== null) {
158
+ await deps.guard.report({
159
+ id: `aud_${globalThis.crypto.randomUUID()}`,
160
+ at: new Date().toISOString(),
161
+ kind: "principal",
162
+ principal,
163
+ venue,
164
+ presence: "present",
165
+ detail: { event: "anon-merge", from: `anonymous_${anonId}`, ...merged },
166
+ });
167
+ }
168
+ }
169
+ catch (error) {
170
+ console.warn(`[vendo] anonymous-session merge failed; will retry next request: ${error instanceof Error ? error.message : String(error)}`);
171
+ }
172
+ }
173
+ }
174
+ }
175
+ // 02-store §4 (kill-list B3): anonymous rows are ordinary disk rows;
176
+ // registering the subject (registration == touch) is what makes the
177
+ // session sweepable and keeps it alive while the visitor is active. One
178
+ // touch covers both anonymous and host-resolved ephemeral principals.
179
+ if (principal.ephemeral === true) {
180
+ await deps.sessionStore.register(principal.subject, deps.sessions.now());
181
+ }
182
+ return {
183
+ principal,
184
+ venue,
185
+ presence: "present",
186
+ sessionId,
187
+ requestHeaders: requestHeaders(req),
188
+ };
189
+ };
190
+ }
@@ -0,0 +1,19 @@
1
+ import type { ExtractedTool } from "@vendoai/actions";
2
+ import { type Principal } from "@vendoai/core";
3
+ import { type RouteEntry } from "./shared.js";
4
+ export declare const DOCTOR_ACT_AS_PRINCIPAL: Principal;
5
+ export declare const DOCTOR_ACT_AS_APP_ID: "app_vendo_doctor";
6
+ export declare const doctorPresentTool: ExtractedTool;
7
+ export declare const doctorActAsTool: ExtractedTool;
8
+ /** Doctor targets a running dev server. The prefix gate keeps its synthetic
9
+ mint/echo routes out of production entirely (and falls through in
10
+ development); the echo halves expose no credential material — booleans
11
+ only.
12
+
13
+ `/doctor/base-url` (09-vendo §2 install-dx wave 1.1) is listed BEFORE that
14
+ gate on purpose: unlike the synthetic credential round-trips, it reports a
15
+ static composition fact (is VENDO_BASE_URL set?) that reveals no secret
16
+ material, and it is exactly the environment — production — the gate
17
+ otherwise silences. Placing it first lets it win the route-table scan in
18
+ every environment, gate included. */
19
+ export declare const doctorRoutes: RouteEntry[];