@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.
- package/LICENSE +202 -0
- package/README.md +28 -0
- package/bin/vendo.mjs +4 -0
- package/dist/ai-sdk.d.ts +35 -0
- package/dist/ai-sdk.js +62 -0
- package/dist/auth-presets/auth-js.d.ts +14 -0
- package/dist/auth-presets/auth-js.js +91 -0
- package/dist/auth-presets/auth0.d.ts +23 -0
- package/dist/auth-presets/auth0.js +98 -0
- package/dist/auth-presets/clerk.d.ts +23 -0
- package/dist/auth-presets/clerk.js +65 -0
- package/dist/auth-presets/conformance.d.ts +42 -0
- package/dist/auth-presets/conformance.js +139 -0
- package/dist/auth-presets/identity.d.ts +56 -0
- package/dist/auth-presets/identity.js +152 -0
- package/dist/auth-presets/index.d.ts +14 -0
- package/dist/auth-presets/index.js +12 -0
- package/dist/auth-presets/jwt.d.ts +16 -0
- package/dist/auth-presets/jwt.js +54 -0
- package/dist/auth-presets/shared.d.ts +35 -0
- package/dist/auth-presets/shared.js +1 -0
- package/dist/auth-presets/supabase.d.ts +42 -0
- package/dist/auth-presets/supabase.js +229 -0
- package/dist/byo-approvals.d.ts +37 -0
- package/dist/byo-approvals.js +189 -0
- package/dist/capability-misses.d.ts +38 -0
- package/dist/capability-misses.js +172 -0
- package/dist/catalog.d.ts +19 -0
- package/dist/catalog.js +152 -0
- package/dist/cli/cloud/args.d.ts +3 -0
- package/dist/cli/cloud/args.js +32 -0
- package/dist/cli/cloud/auth.d.ts +17 -0
- package/dist/cli/cloud/auth.js +123 -0
- package/dist/cli/cloud/client.d.ts +28 -0
- package/dist/cli/cloud/client.js +133 -0
- package/dist/cli/cloud/command.d.ts +22 -0
- package/dist/cli/cloud/command.js +73 -0
- package/dist/cli/cloud/deploy.d.ts +26 -0
- package/dist/cli/cloud/deploy.js +187 -0
- package/dist/cli/cloud/device-login.d.ts +32 -0
- package/dist/cli/cloud/device-login.js +259 -0
- package/dist/cli/cloud/index.d.ts +5 -0
- package/dist/cli/cloud/index.js +59 -0
- package/dist/cli/cloud/keys.d.ts +2 -0
- package/dist/cli/cloud/keys.js +31 -0
- package/dist/cli/cloud/members.d.ts +3 -0
- package/dist/cli/cloud/members.js +25 -0
- package/dist/cli/cloud/output.d.ts +4 -0
- package/dist/cli/cloud/output.js +12 -0
- package/dist/cli/cloud/pending-claim.d.ts +29 -0
- package/dist/cli/cloud/pending-claim.js +38 -0
- package/dist/cli/cloud/read.d.ts +3 -0
- package/dist/cli/cloud/read.js +12 -0
- package/dist/cli/cloud/services.d.ts +2 -0
- package/dist/cli/cloud/services.js +9 -0
- package/dist/cli/cloud/session.d.ts +13 -0
- package/dist/cli/cloud/session.js +41 -0
- package/dist/cli/cloud-init.d.ts +60 -0
- package/dist/cli/cloud-init.js +149 -0
- package/dist/cli/dep-versions.d.ts +26 -0
- package/dist/cli/dep-versions.js +77 -0
- package/dist/cli/doctor-codes.d.ts +58 -0
- package/dist/cli/doctor-codes.js +60 -0
- package/dist/cli/doctor-live.d.ts +67 -0
- package/dist/cli/doctor-live.js +169 -0
- package/dist/cli/doctor.d.ts +52 -0
- package/dist/cli/doctor.js +467 -0
- package/dist/cli/eject.d.ts +29 -0
- package/dist/cli/eject.js +141 -0
- package/dist/cli/extract/apply.d.ts +26 -0
- package/dist/cli/extract/apply.js +123 -0
- package/dist/cli/extract/claude-cli-harness.d.ts +18 -0
- package/dist/cli/extract/claude-cli-harness.js +132 -0
- package/dist/cli/extract/claude-harness.d.ts +14 -0
- package/dist/cli/extract/claude-harness.js +127 -0
- package/dist/cli/extract/codex-cli-harness.d.ts +31 -0
- package/dist/cli/extract/codex-cli-harness.js +154 -0
- package/dist/cli/extract/delegate.d.ts +21 -0
- package/dist/cli/extract/delegate.js +77 -0
- package/dist/cli/extract/extraction.d.ts +65 -0
- package/dist/cli/extract/extraction.js +300 -0
- package/dist/cli/extract/gateway-fuel.d.ts +69 -0
- package/dist/cli/extract/gateway-fuel.js +69 -0
- package/dist/cli/extract/harness.d.ts +114 -0
- package/dist/cli/extract/harness.js +84 -0
- package/dist/cli/extract/index.d.ts +9 -0
- package/dist/cli/extract/index.js +9 -0
- package/dist/cli/extract/npx-engine-harness.d.ts +73 -0
- package/dist/cli/extract/npx-engine-harness.js +225 -0
- package/dist/cli/extract/stages.d.ts +149 -0
- package/dist/cli/extract/stages.js +398 -0
- package/dist/cli/framework.d.ts +8 -0
- package/dist/cli/framework.js +36 -0
- package/dist/cli/init-auth.d.ts +67 -0
- package/dist/cli/init-auth.js +142 -0
- package/dist/cli/init-scaffolds.d.ts +28 -0
- package/dist/cli/init-scaffolds.js +235 -0
- package/dist/cli/init.d.ts +115 -0
- package/dist/cli/init.js +1008 -0
- package/dist/cli/mcp/index.d.ts +7 -0
- package/dist/cli/mcp/index.js +59 -0
- package/dist/cli/mcp/registry.d.ts +9 -0
- package/dist/cli/mcp/registry.js +117 -0
- package/dist/cli/mcp/server-json.d.ts +12 -0
- package/dist/cli/mcp/server-json.js +65 -0
- package/dist/cli/mcp/server.schema.json +574 -0
- package/dist/cli/mcp/verify-domain.d.ts +11 -0
- package/dist/cli/mcp/verify-domain.js +48 -0
- package/dist/cli/playground/app/embed-entry.d.ts +20 -0
- package/dist/cli/playground/app/embed-entry.js +46 -0
- package/dist/cli/playground/app/fake-client.d.ts +3 -0
- package/dist/cli/playground/app/fake-client.js +215 -0
- package/dist/cli/playground/app/fixtures.d.ts +47 -0
- package/dist/cli/playground/app/fixtures.js +472 -0
- package/dist/cli/playground/app/main.d.ts +1 -0
- package/dist/cli/playground/app/main.js +108 -0
- package/dist/cli/playground/app/scenario-mount.d.ts +13 -0
- package/dist/cli/playground/app/scenario-mount.js +48 -0
- package/dist/cli/playground/app/scenarios.d.ts +25 -0
- package/dist/cli/playground/app/scenarios.js +152 -0
- package/dist/cli/playground/app/theme-editor.d.ts +13 -0
- package/dist/cli/playground/app/theme-editor.js +149 -0
- package/dist/cli/playground/app/theme-state.d.ts +29 -0
- package/dist/cli/playground/app/theme-state.js +151 -0
- package/dist/cli/playground/bundle.gen.d.ts +2 -0
- package/dist/cli/playground/bundle.gen.js +2 -0
- package/dist/cli/playground/embed-bundle.gen.d.ts +2 -0
- package/dist/cli/playground/embed-bundle.gen.js +2 -0
- package/dist/cli/playground.d.ts +33 -0
- package/dist/cli/playground.js +120 -0
- package/dist/cli/pretty.d.ts +48 -0
- package/dist/cli/pretty.js +330 -0
- package/dist/cli/refine.d.ts +43 -0
- package/dist/cli/refine.js +181 -0
- package/dist/cli/semantics.d.ts +31 -0
- package/dist/cli/semantics.js +122 -0
- package/dist/cli/shared.d.ts +68 -0
- package/dist/cli/shared.js +184 -0
- package/dist/cli/sync.d.ts +36 -0
- package/dist/cli/sync.js +202 -0
- package/dist/cli/theme/color.d.ts +15 -0
- package/dist/cli/theme/color.js +188 -0
- package/dist/cli/theme/css-vars.d.ts +13 -0
- package/dist/cli/theme/css-vars.js +40 -0
- package/dist/cli/theme/entry-candidates.d.ts +5 -0
- package/dist/cli/theme/entry-candidates.js +22 -0
- package/dist/cli/theme/extract-theme.d.ts +184 -0
- package/dist/cli/theme/extract-theme.js +358 -0
- package/dist/cli/theme/walk.d.ts +2 -0
- package/dist/cli/theme/walk.js +24 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +333 -0
- package/dist/cloud-apps.d.ts +17 -0
- package/dist/cloud-apps.js +46 -0
- package/dist/cloud-console.d.ts +32 -0
- package/dist/cloud-console.js +72 -0
- package/dist/cloud-tools.d.ts +23 -0
- package/dist/cloud-tools.js +196 -0
- package/dist/connections.d.ts +71 -0
- package/dist/connections.js +192 -0
- package/dist/deployment-identity.d.ts +12 -0
- package/dist/deployment-identity.js +69 -0
- package/dist/dev-creds/model.d.ts +65 -0
- package/dist/dev-creds/model.js +159 -0
- package/dist/dev-creds/resolve.d.ts +36 -0
- package/dist/dev-creds/resolve.js +57 -0
- package/dist/hosted-store.d.ts +52 -0
- package/dist/hosted-store.js +322 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1 -0
- package/dist/mastra.d.ts +46 -0
- package/dist/mastra.js +88 -0
- package/dist/react.d.ts +10 -0
- package/dist/react.js +32 -0
- package/dist/refine.d.ts +244 -0
- package/dist/refine.js +599 -0
- package/dist/remixable.d.ts +18 -0
- package/dist/remixable.js +42 -0
- package/dist/runtime-capture.d.ts +18 -0
- package/dist/runtime-capture.js +111 -0
- package/dist/sandbox-wire.d.ts +101 -0
- package/dist/sandbox-wire.js +101 -0
- package/dist/sandbox.d.ts +38 -0
- package/dist/sandbox.js +389 -0
- package/dist/server.d.ts +216 -0
- package/dist/server.js +1320 -0
- package/dist/sync-impact.d.ts +14 -0
- package/dist/sync-impact.js +75 -0
- package/dist/turn-liveness.d.ts +33 -0
- package/dist/turn-liveness.js +105 -0
- package/dist/wire/approvals.d.ts +10 -0
- package/dist/wire/approvals.js +59 -0
- package/dist/wire/apps.d.ts +5 -0
- package/dist/wire/apps.js +155 -0
- package/dist/wire/automations.d.ts +5 -0
- package/dist/wire/automations.js +57 -0
- package/dist/wire/box.d.ts +3 -0
- package/dist/wire/box.js +268 -0
- package/dist/wire/connections.d.ts +5 -0
- package/dist/wire/connections.js +47 -0
- package/dist/wire/context.d.ts +22 -0
- package/dist/wire/context.js +190 -0
- package/dist/wire/doctor.d.ts +19 -0
- package/dist/wire/doctor.js +116 -0
- package/dist/wire/misc.d.ts +18 -0
- package/dist/wire/misc.js +227 -0
- package/dist/wire/shared.d.ts +158 -0
- package/dist/wire/shared.js +134 -0
- package/dist/wire/threads.d.ts +3 -0
- package/dist/wire/threads.js +68 -0
- package/package.json +151 -0
- package/skills/vendo-setup/SKILL.md +128 -0
package/dist/wire/box.js
ADDED
|
@@ -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[];
|