@worca/app 1.5.0-rc.3 → 1.6.0-rc.1
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/README.md +7 -1
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +52 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +165 -9
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +244 -43
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +175 -6
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +33 -6
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +99 -50
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The key page's script (src/broker/ui-server.mjs serves it). No framework, no
|
|
2
|
+
// third-party code: everything on this page talks only to this origin.
|
|
3
|
+
const $ = (sel) => document.querySelector(sel);
|
|
4
|
+
let csrf = '';
|
|
5
|
+
let me = null;
|
|
6
|
+
|
|
7
|
+
const STATE_TEXT = { set: 'Set', missing: 'Not set', invalid: 'Rejected', keyless: 'No key needed', operator: 'Provided by the team' };
|
|
8
|
+
|
|
9
|
+
function el(tag, attrs = {}, ...kids) {
|
|
10
|
+
const n = document.createElement(tag);
|
|
11
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
12
|
+
if (v === null || v === undefined || v === false) continue;
|
|
13
|
+
if (k === 'class') n.className = v;
|
|
14
|
+
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
|
|
15
|
+
else n.setAttribute(k, v === true ? '' : v);
|
|
16
|
+
}
|
|
17
|
+
for (const k of kids.flat()) if (k !== null && k !== undefined && k !== false) n.append(k);
|
|
18
|
+
return n;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const usd = (n) => `$${(Number(n) || 0).toFixed(2)}`;
|
|
22
|
+
const when = (iso) => {
|
|
23
|
+
if (!iso) return '';
|
|
24
|
+
const d = new Date(iso);
|
|
25
|
+
const mins = Math.round((Date.now() - d.getTime()) / 60000);
|
|
26
|
+
if (mins < 1) return 'just now';
|
|
27
|
+
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`;
|
|
28
|
+
if (mins < 48 * 60) return `${Math.round(mins / 60)} hours ago`;
|
|
29
|
+
return d.toISOString().slice(0, 10);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function api(method, path, body) {
|
|
33
|
+
const res = await fetch(path, {
|
|
34
|
+
method,
|
|
35
|
+
headers: { 'content-type': 'application/json', 'x-worca-csrf': csrf },
|
|
36
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
37
|
+
credentials: 'same-origin',
|
|
38
|
+
});
|
|
39
|
+
let data = {};
|
|
40
|
+
try { data = await res.json(); } catch { /* empty */ }
|
|
41
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function showError(text) {
|
|
46
|
+
const e = $('#error');
|
|
47
|
+
e.textContent = text || '';
|
|
48
|
+
e.hidden = !text;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function slotCard(slot) {
|
|
52
|
+
const card = el('article', { class: 'slot', id: `slot-${slot.id}` });
|
|
53
|
+
const msg = el('p', { class: 'msg', hidden: true, role: 'status' });
|
|
54
|
+
const say = (text, ok) => { msg.textContent = text; msg.className = `msg ${ok ? 'ok' : 'err'}`; msg.hidden = !text; };
|
|
55
|
+
const kindText = slot.kind === 'subscription' ? ' · Claude subscription' : '';
|
|
56
|
+
const chipText = (slot.state === 'set' && slot.suffix ? `Set ••••${slot.suffix}` : STATE_TEXT[slot.state] || slot.state) + (slot.state === 'set' ? kindText : '');
|
|
57
|
+
// GitHub ("push as me"): a GitHub sign-in, or a pasted fine-grained token, both.
|
|
58
|
+
const pasteToo = slot.id === 'github' || slot.protocol === 'github';
|
|
59
|
+
|
|
60
|
+
const metaParts = [];
|
|
61
|
+
if (slot.updatedAt) metaParts.push(`added ${when(slot.updatedAt)}`);
|
|
62
|
+
if (slot.lastUsedAt) metaParts.push(`used ${when(slot.lastUsedAt)}`);
|
|
63
|
+
if (slot.state === 'invalid' && slot.verifyError) metaParts.push(`last check: ${slot.verifyError}`);
|
|
64
|
+
if (slot.state === 'missing' && slot.credential === 'per-person') metaParts.push('Runs on these models are refused until you add a key');
|
|
65
|
+
|
|
66
|
+
card.append(el('div', { class: 'slot-top' },
|
|
67
|
+
el('div', {}, el('h2', {}, slot.label), metaParts.length ? el('p', { class: 'meta' }, metaParts.join(' · ')) : null),
|
|
68
|
+
el('span', { class: `chip ${slot.state}` }, chipText)));
|
|
69
|
+
|
|
70
|
+
if (slot.credential === 'per-person' && slot.signIn === 'github-device') {
|
|
71
|
+
card.append(githubSignIn(slot, say));
|
|
72
|
+
if (slot.protocol === 'github') {
|
|
73
|
+
card.append(el('p', { class: 'meta' }, "worca's pushes and pull requests for runs you start go out as you. Nothing here is ever given to an agent."));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (slot.credential === 'per-person' && (slot.signIn !== 'github-device' || pasteToo)) {
|
|
77
|
+
const input = el('input', { type: 'password', id: `key-${slot.id}`, autocomplete: 'off', spellcheck: 'false', placeholder: slot.keyHint ? `Paste your key (${slot.keyHint})` : 'Paste your key', 'aria-label': `${slot.label}` });
|
|
78
|
+
const form = el('div', { class: 'row', hidden: slot.state === 'set' || (pasteToo && slot.state !== 'invalid') });
|
|
79
|
+
const save = el('button', { class: 'primary', type: 'button' }, 'Save');
|
|
80
|
+
save.addEventListener('click', async () => {
|
|
81
|
+
if (!input.value.trim()) { say('Paste the key first.', false); return; }
|
|
82
|
+
save.disabled = true; say('Checking the key with the provider…', true);
|
|
83
|
+
try {
|
|
84
|
+
await api('PUT', `/api/slots/${slot.id}`, { secret: input.value });
|
|
85
|
+
input.value = '';
|
|
86
|
+
await load(slot.protocol === 'github' ? 'Saved. GitHub accepted the token.' : `Saved. The provider accepted your ${slot.label}.`, slot.id);
|
|
87
|
+
} catch (err) { say(err.message, false); save.disabled = false; }
|
|
88
|
+
});
|
|
89
|
+
form.append(el('div', { class: 'grow' }, input), save);
|
|
90
|
+
if (slot.state === 'set') {
|
|
91
|
+
form.append(el('button', { type: 'button', onclick: () => { form.hidden = true; actions.hidden = false; say('', true); } }, 'Cancel'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const actions = el('div', { class: 'row', hidden: slot.state !== 'set' && slot.state !== 'invalid' });
|
|
95
|
+
const test = el('button', { type: 'button' }, 'Test');
|
|
96
|
+
test.addEventListener('click', async () => {
|
|
97
|
+
test.disabled = true; say('Testing…', true);
|
|
98
|
+
try { await api('POST', `/api/slots/${slot.id}/test`); say('The provider accepted the key.', true); }
|
|
99
|
+
catch (err) { say(err.message, false); }
|
|
100
|
+
test.disabled = false;
|
|
101
|
+
});
|
|
102
|
+
const replace = el('button', { type: 'button', onclick: () => { form.hidden = false; actions.hidden = true; input.focus(); } }, pasteToo ? 'Paste a token instead' : 'Replace');
|
|
103
|
+
const del = el('button', { type: 'button', class: 'danger' }, 'Delete');
|
|
104
|
+
const confirmRow = el('div', { class: 'row', hidden: true },
|
|
105
|
+
el('span', { class: 'grow' }, `Delete your ${slot.label}? Runs using it will pause until you add another.`),
|
|
106
|
+
el('button', { type: 'button', class: 'danger', onclick: async () => {
|
|
107
|
+
try { await api('DELETE', `/api/slots/${slot.id}`); await load(`Deleted your ${slot.label}.`, slot.id); }
|
|
108
|
+
catch (err) { say(err.message, false); }
|
|
109
|
+
} }, 'Delete key'),
|
|
110
|
+
el('button', { type: 'button', onclick: () => { confirmRow.hidden = true; actions.hidden = false; } }, 'Keep it'));
|
|
111
|
+
del.addEventListener('click', () => { confirmRow.hidden = false; actions.hidden = true; say('', true); });
|
|
112
|
+
// GitHub: the sign-in box above already has "Sign in again" and "Sign out"; here only
|
|
113
|
+
// pasting a token is offered (always shown when nothing is set yet).
|
|
114
|
+
if (pasteToo) {
|
|
115
|
+
actions.append(replace);
|
|
116
|
+
actions.hidden = false;
|
|
117
|
+
} else actions.append(test, replace, del);
|
|
118
|
+
if (slot.state === 'invalid') form.hidden = false;
|
|
119
|
+
card.append(form, actions, confirmRow);
|
|
120
|
+
|
|
121
|
+
if (slot.kind === 'subscription' && slot.state === 'set') {
|
|
122
|
+
card.append(el('p', { class: 'usage' }, "Runs on your Claude plan: no per-call cost, and your plan's usage limits apply. Only agents that run under your own user use it."));
|
|
123
|
+
} else if (!pasteToo && (slot.state === 'set' || slot.state === 'invalid')) {
|
|
124
|
+
const u = me.usage?.[slot.id] || { todayUsd: 0, monthUsd: 0 };
|
|
125
|
+
card.append(el('p', { class: 'usage' }, `Today ${usd(u.todayUsd)} · This month ${usd(u.monthUsd)}`));
|
|
126
|
+
const dIn = el('input', { type: 'number', min: '0', step: '1', id: `daily-${slot.id}`, value: slot.dailyUsd ?? '', placeholder: me.defaults?.dailyUsd != null ? String(me.defaults.dailyUsd) : 'none' });
|
|
127
|
+
const mIn = el('input', { type: 'number', min: '0', step: '1', id: `monthly-${slot.id}`, value: slot.monthlyUsd ?? '', placeholder: me.defaults?.monthlyUsd != null ? String(me.defaults.monthlyUsd) : 'none' });
|
|
128
|
+
const saveCaps = el('button', { type: 'button' }, 'Save caps');
|
|
129
|
+
saveCaps.addEventListener('click', async () => {
|
|
130
|
+
try { await api('PUT', `/api/budget/${slot.id}`, { dailyUsd: dIn.value, monthlyUsd: mIn.value }); say('Spending caps saved.', true); }
|
|
131
|
+
catch (err) { say(err.message, false); }
|
|
132
|
+
});
|
|
133
|
+
card.append(el('div', { class: 'caps' },
|
|
134
|
+
el('label', { for: `daily-${slot.id}` }, 'Daily cap $', dIn),
|
|
135
|
+
el('label', { for: `monthly-${slot.id}` }, 'Monthly cap $', mIn),
|
|
136
|
+
saveCaps));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
card.append(msg);
|
|
140
|
+
return { card, say };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Copilot: GitHub's device sign-in, run by the broker. The page only shows the code. */
|
|
144
|
+
function githubSignIn(slot, say) {
|
|
145
|
+
const box = el('div', {});
|
|
146
|
+
const signedIn = slot.state === 'set' || slot.state === 'invalid';
|
|
147
|
+
const start = el('button', { type: 'button', class: signedIn ? '' : 'primary' }, signedIn ? 'Sign in again' : 'Sign in with GitHub');
|
|
148
|
+
const codeRow = el('div', { class: 'row', hidden: true });
|
|
149
|
+
const actions = el('div', { class: 'row' }, start);
|
|
150
|
+
if (signedIn) {
|
|
151
|
+
const del = el('button', { type: 'button', class: 'danger' }, 'Sign out');
|
|
152
|
+
del.addEventListener('click', async () => {
|
|
153
|
+
try { await api('DELETE', `/api/slots/${slot.id}`); await load('Signed out of GitHub Copilot.', slot.id); }
|
|
154
|
+
catch (err) { say(err.message, false); }
|
|
155
|
+
});
|
|
156
|
+
actions.append(del);
|
|
157
|
+
}
|
|
158
|
+
let timer = null;
|
|
159
|
+
start.addEventListener('click', async () => {
|
|
160
|
+
start.disabled = true; say('', true);
|
|
161
|
+
let flow;
|
|
162
|
+
try { flow = await api('POST', `/api/slots/${slot.id}/device`); }
|
|
163
|
+
catch (err) { say(err.message, false); start.disabled = false; return; }
|
|
164
|
+
const link = el('a', { href: flow.verificationUri, target: '_blank', rel: 'noopener' }, flow.verificationUri);
|
|
165
|
+
codeRow.replaceChildren(el('span', { class: 'grow' }, 'Enter ', el('strong', { class: 'code' }, flow.userCode), ' at ', link, ', then come back here.'));
|
|
166
|
+
codeRow.hidden = false;
|
|
167
|
+
let wait = Math.max(2, flow.interval || 5) * 1000;
|
|
168
|
+
const poll = async () => {
|
|
169
|
+
try {
|
|
170
|
+
const r = await api('POST', `/api/slots/${slot.id}/device/poll`);
|
|
171
|
+
if (r.pending) { if (r.slowDown) wait += 5000; timer = setTimeout(poll, wait); return; }
|
|
172
|
+
await load('Signed in. GitHub Copilot works for your runs now.', slot.id);
|
|
173
|
+
} catch (err) { codeRow.hidden = true; start.disabled = false; say(err.message, false); }
|
|
174
|
+
};
|
|
175
|
+
timer = setTimeout(poll, wait);
|
|
176
|
+
});
|
|
177
|
+
window.addEventListener('beforeunload', () => clearTimeout(timer));
|
|
178
|
+
box.append(actions, codeRow);
|
|
179
|
+
return box;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function load(notice = '', focusSlot = null) {
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch('/api/me', { credentials: 'same-origin' });
|
|
185
|
+
if (!res.ok) throw new Error(res.status === 401 ? 'Your sign-in has expired. Reload the page to sign in again.' : `Could not load your credentials (HTTP ${res.status}).`);
|
|
186
|
+
me = await res.json();
|
|
187
|
+
} catch (err) { showError(err.message); return; }
|
|
188
|
+
showError('');
|
|
189
|
+
csrf = me.csrf;
|
|
190
|
+
$('#who').textContent = `Signed in as ${me.email}`;
|
|
191
|
+
const back = $('#back');
|
|
192
|
+
if (me.returnUrl) { back.href = me.returnUrl; back.hidden = false; }
|
|
193
|
+
const list = $('#slots');
|
|
194
|
+
list.replaceChildren();
|
|
195
|
+
for (const slot of me.slots) {
|
|
196
|
+
const { card, say } = slotCard(slot);
|
|
197
|
+
list.append(card);
|
|
198
|
+
if (notice && slot.id === focusSlot) say(notice, true);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
load();
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// src/broker/ui-server.mjs
|
|
2
|
+
// The key page (plans/credential-broker-design.html §5.6, §7.1): each person saves,
|
|
3
|
+
// tests and deletes their own credentials here, at the broker's own hostname, behind
|
|
4
|
+
// its own Access application. The broker checks the identity itself and never trusts
|
|
5
|
+
// worca for it. Keys can be written, never read back.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createAccessVerifier } from '../core/cf-access.mjs';
|
|
10
|
+
import { safeEqual, normalizeBillTo } from './tokens.mjs';
|
|
11
|
+
import { startOfUtcDay, startOfUtcMonth } from './limits.mjs';
|
|
12
|
+
import { startDeviceFlow, pollDeviceFlow, githubSecretOf } from './copilot.mjs';
|
|
13
|
+
|
|
14
|
+
const UI_DIR = fileURLToPath(new URL('./ui/', import.meta.url));
|
|
15
|
+
const STATIC = Object.freeze({
|
|
16
|
+
'/': { file: 'page.html', type: 'text/html; charset=utf-8' },
|
|
17
|
+
'/page.mjs': { file: 'page.mjs', type: 'text/javascript; charset=utf-8' },
|
|
18
|
+
'/page.css': { file: 'page.css', type: 'text/css; charset=utf-8' },
|
|
19
|
+
});
|
|
20
|
+
export const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
|
|
21
|
+
const CSRF_COOKIE = 'wb_csrf';
|
|
22
|
+
|
|
23
|
+
export function securityHeaders() {
|
|
24
|
+
return {
|
|
25
|
+
'content-security-policy': CSP,
|
|
26
|
+
'x-frame-options': 'DENY',
|
|
27
|
+
'x-content-type-options': 'nosniff',
|
|
28
|
+
'referrer-policy': 'no-referrer',
|
|
29
|
+
'cache-control': 'no-store',
|
|
30
|
+
'cross-origin-opener-policy': 'same-origin',
|
|
31
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function send(res, status, body, type = 'application/json', extra = {}) {
|
|
36
|
+
const s = typeof body === 'string' ? body : JSON.stringify(body);
|
|
37
|
+
res.writeHead(status, { ...securityHeaders(), 'content-type': type, 'content-length': Buffer.byteLength(s), ...extra });
|
|
38
|
+
res.end(s);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cookies(req) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const part of String(req.headers.cookie || '').split(';')) {
|
|
44
|
+
const i = part.indexOf('=');
|
|
45
|
+
if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJson(req, limit = 16 << 10) {
|
|
51
|
+
return new Promise((resolveP, rejectP) => {
|
|
52
|
+
const parts = []; let n = 0;
|
|
53
|
+
req.on('data', (c) => { n += c.length; if (n > limit) { rejectP(new Error('too large')); req.destroy(); return; } parts.push(c); });
|
|
54
|
+
req.on('end', () => { try { resolveP(n ? JSON.parse(Buffer.concat(parts).toString('utf8')) : {}); } catch { rejectP(new Error('bad json')); } });
|
|
55
|
+
req.on('error', rejectP);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The identity resolver for the key page: Access JWT, a trusted header, or none. */
|
|
60
|
+
export function createIdentity(config, { fetchImpl } = {}) {
|
|
61
|
+
const id = config.identity;
|
|
62
|
+
if (id?.kind === 'access') {
|
|
63
|
+
const verify = createAccessVerifier({ teamDomain: id.teamDomain, aud: id.aud, ...(fetchImpl ? { fetchImpl } : {}) });
|
|
64
|
+
return async (req) => {
|
|
65
|
+
const token = req.headers['cf-access-jwt-assertion'];
|
|
66
|
+
if (typeof token !== 'string' || !token) return null;
|
|
67
|
+
const who = await verify(token);
|
|
68
|
+
return who?.email ? normalizeBillTo(who.email) : null;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (id?.kind === 'header') {
|
|
72
|
+
return async (req) => {
|
|
73
|
+
const v = req.headers[id.header];
|
|
74
|
+
return normalizeBillTo(Array.isArray(v) ? v[0] : v);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return async () => null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {{config:object, service:object, store:object, identity?:(req)=>Promise<string|null>, now?:()=>number, log?:Function}} o
|
|
82
|
+
* @returns {(req, res) => void}
|
|
83
|
+
*/
|
|
84
|
+
export function createUiHandler({ config, service, store, identity = createIdentity(config), now = Date.now, log = () => {} }) {
|
|
85
|
+
const staticCache = new Map();
|
|
86
|
+
const readStatic = (entry) => {
|
|
87
|
+
if (!staticCache.has(entry.file)) staticCache.set(entry.file, readFileSync(UI_DIR + entry.file, 'utf8'));
|
|
88
|
+
return staticCache.get(entry.file);
|
|
89
|
+
};
|
|
90
|
+
const secureCookie = String(config.publicUrl || '').startsWith('https:') ? '; Secure' : '';
|
|
91
|
+
const deviceFlows = new Map(); // "<email>|<slot>" -> {deviceCode, expiresAt}: in memory, per sign-in attempt
|
|
92
|
+
|
|
93
|
+
function usageFor(email) {
|
|
94
|
+
const t = now();
|
|
95
|
+
const out = {};
|
|
96
|
+
for (const s of service.slots) {
|
|
97
|
+
out[s.id] = { todayUsd: store.spentSince(email, s.id, startOfUtcDay(t)), monthUsd: store.spentSince(email, s.id, startOfUtcMonth(t)) };
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A state-changing request must come from the key page itself. */
|
|
103
|
+
function csrfOk(req) {
|
|
104
|
+
const origin = String(req.headers.origin || '');
|
|
105
|
+
if (!config.publicUrl || origin !== config.publicUrl) return false;
|
|
106
|
+
const c = cookies(req)[CSRF_COOKIE];
|
|
107
|
+
const h = req.headers['x-worca-csrf'];
|
|
108
|
+
return !!c && typeof h === 'string' && safeEqual(c, h);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handle(req, res) {
|
|
112
|
+
const url = new URL(req.url || '/', 'http://keys');
|
|
113
|
+
const p = url.pathname;
|
|
114
|
+
if (req.method === 'GET' && p === '/healthz') { send(res, 200, { ok: true, version: config.version || null, mode: config.mode }); return; }
|
|
115
|
+
|
|
116
|
+
let email;
|
|
117
|
+
try { email = await identity(req); }
|
|
118
|
+
catch { send(res, 503, { error: 'cannot verify the sign-in token right now' }); return; }
|
|
119
|
+
if (!email) { send(res, 401, { error: 'unauthorized: sign in through the identity proxy' }); return; }
|
|
120
|
+
|
|
121
|
+
if (req.method === 'GET' && STATIC[p]) { send(res, 200, readStatic(STATIC[p]), STATIC[p].type); return; }
|
|
122
|
+
|
|
123
|
+
if (req.method === 'GET' && p === '/api/me') {
|
|
124
|
+
let csrf = cookies(req)[CSRF_COOKIE];
|
|
125
|
+
if (!csrf || !/^[A-Za-z0-9_-]{43}$/.test(csrf)) csrf = randomBytes(32).toString('base64url');
|
|
126
|
+
send(res, 200, {
|
|
127
|
+
email, returnUrl: config.returnUrl, csrf,
|
|
128
|
+
slots: service.slotStatus(email), usage: usageFor(email),
|
|
129
|
+
defaults: { dailyUsd: config.defaultDailyUsd, monthlyUsd: config.defaultMonthlyUsd },
|
|
130
|
+
}, 'application/json', { 'set-cookie': `${CSRF_COOKIE}=${csrf}; Path=/; HttpOnly; SameSite=Strict${secureCookie}` });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const m = /^\/api\/(slots|budget)\/([a-z][a-z0-9-]{1,31})(\/test|\/device|\/device\/poll)?$/.exec(p);
|
|
135
|
+
if (!m) { send(res, 404, { error: 'not found' }); return; }
|
|
136
|
+
if (!csrfOk(req)) { send(res, 403, { error: 'refused: this request did not come from the key page' }); return; }
|
|
137
|
+
const [, kind, slotId, sub] = m;
|
|
138
|
+
const test = sub === '/test';
|
|
139
|
+
if (!service.slotById.has(slotId)) { send(res, 404, { error: 'unknown slot' }); return; }
|
|
140
|
+
|
|
141
|
+
// GitHub device sign-in (Copilot): the broker runs the flow and keeps the resulting
|
|
142
|
+
// GitHub token sealed; neither the page nor worca ever holds it.
|
|
143
|
+
if (kind === 'slots' && (sub === '/device' || sub === '/device/poll') && req.method === 'POST') {
|
|
144
|
+
const slot = service.slotById.get(slotId);
|
|
145
|
+
if (slot.signIn !== 'github-device') { send(res, 404, { error: 'this credential has no sign-in flow' }); return; }
|
|
146
|
+
const key = `${email}|${slotId}`;
|
|
147
|
+
if (sub === '/device') {
|
|
148
|
+
try {
|
|
149
|
+
const f = await startDeviceFlow({ fetchImpl: service.fetchImpl, ...(slot.deviceBaseUrl ? { baseUrl: slot.deviceBaseUrl } : {}), ...(slot.clientId ? { clientId: slot.clientId, scope: slot.scope } : {}) });
|
|
150
|
+
deviceFlows.set(key, { deviceCode: f.deviceCode, expiresAt: now() + f.expiresIn * 1000 });
|
|
151
|
+
send(res, 200, { userCode: f.userCode, verificationUri: f.verificationUri, interval: f.interval, expiresIn: f.expiresIn });
|
|
152
|
+
} catch (err) { send(res, 502, { error: err.message }); }
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const flow = deviceFlows.get(key);
|
|
156
|
+
if (!flow || flow.expiresAt < now()) { deviceFlows.delete(key); send(res, 410, { error: 'the sign-in code expired: start again' }); return; }
|
|
157
|
+
let r;
|
|
158
|
+
try { r = await pollDeviceFlow(flow.deviceCode, { fetchImpl: service.fetchImpl, ...(slot.deviceBaseUrl ? { baseUrl: slot.deviceBaseUrl } : {}), ...(slot.clientId ? { clientId: slot.clientId } : {}) }); }
|
|
159
|
+
catch (err) { send(res, 502, { error: err.message }); return; }
|
|
160
|
+
if (r.pending) { send(res, 200, { pending: true, slowDown: !!r.slowDown }); return; }
|
|
161
|
+
deviceFlows.delete(key);
|
|
162
|
+
if (r.error) { send(res, 422, { error: `GitHub sign-in failed: ${r.error}` }); return; }
|
|
163
|
+
// A GitHub App user token comes with a refresh token: stored together, renewed by the broker.
|
|
164
|
+
const saved = await service.saveCredential(email, slotId, slot.auth === 'github-user' ? githubSecretOf(r, now()) : r.token);
|
|
165
|
+
if (!saved.ok) { send(res, saved.status || 422, { error: saved.error }); return; }
|
|
166
|
+
send(res, 200, { state: 'set', suffix: saved.suffix, kind: saved.kind });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (kind === 'slots' && req.method === 'PUT' && !test) {
|
|
171
|
+
let body;
|
|
172
|
+
try { body = await readJson(req); } catch { send(res, 400, { error: 'body must be JSON' }); return; }
|
|
173
|
+
const r = await service.saveCredential(email, slotId, body.secret);
|
|
174
|
+
if (!r.ok) { send(res, r.status || 422, { error: r.error }); return; }
|
|
175
|
+
send(res, 200, { state: 'set', suffix: r.suffix, kind: r.kind });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (kind === 'slots' && req.method === 'POST' && test) {
|
|
179
|
+
const r = await service.testCredential(email, slotId);
|
|
180
|
+
send(res, r.ok ? 200 : (r.status || 422), r.ok ? { ok: true } : { ok: false, error: r.error });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (kind === 'slots' && req.method === 'DELETE' && !test) {
|
|
184
|
+
service.deleteCredential(email, slotId);
|
|
185
|
+
send(res, 200, { state: 'missing' });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (kind === 'budget' && req.method === 'PUT' && !test) {
|
|
189
|
+
let body;
|
|
190
|
+
try { body = await readJson(req); } catch { send(res, 400, { error: 'body must be JSON' }); return; }
|
|
191
|
+
const cap = (v, dflt, name) => {
|
|
192
|
+
if (v === null || v === undefined || v === '') return { v: null };
|
|
193
|
+
const n = Number(v);
|
|
194
|
+
if (!Number.isFinite(n) || n < 0) return { err: `${name} must be a number of dollars` };
|
|
195
|
+
if (dflt != null && n > dflt) return { err: `${name} can't be above the team limit of $${dflt}` };
|
|
196
|
+
return { v: n };
|
|
197
|
+
};
|
|
198
|
+
const d = cap(body.dailyUsd, config.defaultDailyUsd, 'Daily cap');
|
|
199
|
+
const mo = cap(body.monthlyUsd, config.defaultMonthlyUsd, 'Monthly cap');
|
|
200
|
+
if (d.err || mo.err) { send(res, 422, { error: d.err || mo.err }); return; }
|
|
201
|
+
const n = store.setCaps(email, slotId, { dailyUsd: d.v, monthlyUsd: mo.v });
|
|
202
|
+
if (!n) { send(res, 404, { error: 'save a key first' }); return; }
|
|
203
|
+
service.limits.invalidate(email, slotId);
|
|
204
|
+
send(res, 200, { dailyUsd: d.v, monthlyUsd: mo.v });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
send(res, 405, { error: 'method not allowed' });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return (req, res) => {
|
|
211
|
+
handle(req, res).catch((err) => {
|
|
212
|
+
log(`key page error: ${err && err.message}`);
|
|
213
|
+
if (!res.headersSent) send(res, 500, { error: 'internal error' });
|
|
214
|
+
else res.destroy();
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/broker/usage.mjs
|
|
2
|
+
// Usage of one proxied request (plans/credential-broker-design.html §6.1). A tap
|
|
3
|
+
// sees the response body as it streams past, WITHOUT buffering it, and pulls the
|
|
4
|
+
// token counts out of Anthropic SSE / JSON and OpenAI final chunks. When the
|
|
5
|
+
// upstream reports its own USD figure (OpenRouter's `usage.cost`) that figure wins;
|
|
6
|
+
// otherwise the usage is priced from the shared list-price table. Pure.
|
|
7
|
+
import { listPriceFor } from '../core/list-prices.mjs';
|
|
8
|
+
|
|
9
|
+
const MAX_LINE = 1 << 20; // an SSE line longer than this is skipped, never buffered further
|
|
10
|
+
const MAX_JSON = 4 << 20; // a non-streamed JSON body larger than this is not parsed for usage
|
|
11
|
+
|
|
12
|
+
export function emptyUsage() {
|
|
13
|
+
return { model: null, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reportedUsd: null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function applyAnthropic(u, obj) {
|
|
17
|
+
if (!obj || typeof obj !== 'object') return;
|
|
18
|
+
const msg = obj.type === 'message_start' ? obj.message : (obj.type === 'message' ? obj : null);
|
|
19
|
+
if (msg) {
|
|
20
|
+
if (msg.model) u.model = msg.model;
|
|
21
|
+
const us = msg.usage || {};
|
|
22
|
+
if (Number.isFinite(us.input_tokens)) u.inputTokens = us.input_tokens;
|
|
23
|
+
if (Number.isFinite(us.output_tokens)) u.outputTokens = us.output_tokens;
|
|
24
|
+
if (Number.isFinite(us.cache_read_input_tokens)) u.cacheReadTokens = us.cache_read_input_tokens;
|
|
25
|
+
if (Number.isFinite(us.cache_creation_input_tokens)) u.cacheWriteTokens = us.cache_creation_input_tokens;
|
|
26
|
+
}
|
|
27
|
+
if (obj.type === 'message_delta' && obj.usage) {
|
|
28
|
+
// Cumulative counts: the last delta wins.
|
|
29
|
+
if (Number.isFinite(obj.usage.output_tokens)) u.outputTokens = obj.usage.output_tokens;
|
|
30
|
+
if (Number.isFinite(obj.usage.input_tokens)) u.inputTokens = obj.usage.input_tokens;
|
|
31
|
+
if (Number.isFinite(obj.usage.cache_read_input_tokens)) u.cacheReadTokens = obj.usage.cache_read_input_tokens;
|
|
32
|
+
if (Number.isFinite(obj.usage.cache_creation_input_tokens)) u.cacheWriteTokens = obj.usage.cache_creation_input_tokens;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function applyOpenAI(u, obj) {
|
|
37
|
+
if (!obj || typeof obj !== 'object') return;
|
|
38
|
+
if (obj.model) u.model = obj.model;
|
|
39
|
+
const us = obj.usage || (obj.response && obj.response.usage);
|
|
40
|
+
if (!us || typeof us !== 'object') return;
|
|
41
|
+
const input = us.prompt_tokens ?? us.input_tokens;
|
|
42
|
+
const output = us.completion_tokens ?? us.output_tokens;
|
|
43
|
+
if (Number.isFinite(input)) u.inputTokens = input;
|
|
44
|
+
if (Number.isFinite(output)) u.outputTokens = output;
|
|
45
|
+
const cached = us.prompt_tokens_details?.cached_tokens ?? us.input_tokens_details?.cached_tokens;
|
|
46
|
+
if (Number.isFinite(cached)) u.cacheReadTokens = cached;
|
|
47
|
+
if (Number.isFinite(us.cost)) u.reportedUsd = us.cost;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A usage tap for one response.
|
|
52
|
+
* @param {'anthropic'|'openai'} protocol
|
|
53
|
+
* @param {string|null} contentType the response's content-type
|
|
54
|
+
* @returns {{write:(chunk:Buffer|string)=>void, end:()=>ReturnType<typeof emptyUsage>}}
|
|
55
|
+
*/
|
|
56
|
+
export function createUsageTap(protocol, contentType) {
|
|
57
|
+
const u = emptyUsage();
|
|
58
|
+
const apply = protocol === 'openai' ? applyOpenAI : applyAnthropic;
|
|
59
|
+
const sse = /text\/event-stream/i.test(String(contentType || ''));
|
|
60
|
+
let buf = '';
|
|
61
|
+
let jsonParts = [];
|
|
62
|
+
let jsonLen = 0;
|
|
63
|
+
let skipping = false;
|
|
64
|
+
|
|
65
|
+
const onLine = (line) => {
|
|
66
|
+
if (!line.startsWith('data:')) return;
|
|
67
|
+
const data = line.slice(5).trim();
|
|
68
|
+
if (!data || data === '[DONE]') return;
|
|
69
|
+
try { apply(u, JSON.parse(data)); } catch { /* not JSON: ignore */ }
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
write(chunk) {
|
|
74
|
+
const s = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
75
|
+
if (!sse) {
|
|
76
|
+
if (jsonLen <= MAX_JSON) { jsonParts.push(s); jsonLen += s.length; }
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
buf += s;
|
|
80
|
+
let i;
|
|
81
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
82
|
+
const line = buf.slice(0, i).replace(/\r$/, '');
|
|
83
|
+
buf = buf.slice(i + 1);
|
|
84
|
+
if (skipping) { skipping = false; continue; }
|
|
85
|
+
onLine(line);
|
|
86
|
+
}
|
|
87
|
+
if (buf.length > MAX_LINE) { buf = ''; skipping = true; }
|
|
88
|
+
},
|
|
89
|
+
end() {
|
|
90
|
+
if (sse) { if (buf && !skipping) onLine(buf.replace(/\r$/, '')); }
|
|
91
|
+
else if (jsonLen && jsonLen <= MAX_JSON) {
|
|
92
|
+
try { apply(u, JSON.parse(jsonParts.join(''))); } catch { /* not JSON */ }
|
|
93
|
+
}
|
|
94
|
+
jsonParts = [];
|
|
95
|
+
return u;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** USD for a usage record: the upstream's own figure, else list price, else 0. */
|
|
101
|
+
export function priceUsage(u, rates = listPriceFor(u.model)) {
|
|
102
|
+
if (Number.isFinite(u.reportedUsd)) return u.reportedUsd;
|
|
103
|
+
if (!rates) return 0;
|
|
104
|
+
return ((u.inputTokens || 0) * (rates.input || 0)
|
|
105
|
+
+ (u.outputTokens || 0) * (rates.output || 0)
|
|
106
|
+
+ (u.cacheReadTokens || 0) * (rates.cacheRead || 0)
|
|
107
|
+
+ (u.cacheWriteTokens || 0) * (rates.cacheWrite || 0)) / 1e6;
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/broker/vault.mjs
|
|
2
|
+
// Credentials at rest (plans/credential-broker-design.html §6.5): AES-256-GCM with
|
|
3
|
+
// the operator's vault key, the (person, slot) pair bound in as additional data so a
|
|
4
|
+
// row copied to another person or slot fails to open. Pure apart from the key given.
|
|
5
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
/** Short fingerprint of a vault key: tells the broker a row was sealed with another key. */
|
|
8
|
+
export function keyId(key) {
|
|
9
|
+
return createHash('sha256').update(key).digest('hex').slice(0, 8);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function aadFor(billTo, slot) {
|
|
13
|
+
return Buffer.from(`${billTo}\n${slot}`, 'utf8');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @returns {{ciphertext: Buffer, iv: Buffer, tag: Buffer, keyId: string}} */
|
|
17
|
+
export function seal(key, plaintext, { billTo, slot }) {
|
|
18
|
+
const iv = randomBytes(12);
|
|
19
|
+
const c = createCipheriv('aes-256-gcm', key, iv);
|
|
20
|
+
c.setAAD(aadFor(billTo, slot));
|
|
21
|
+
const ciphertext = Buffer.concat([c.update(String(plaintext), 'utf8'), c.final()]);
|
|
22
|
+
return { ciphertext, iv, tag: c.getAuthTag(), keyId: keyId(key) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The plaintext, or throws (wrong key, tampered row, or row of another person/slot). */
|
|
26
|
+
export function open(key, { ciphertext, iv, tag }, { billTo, slot }) {
|
|
27
|
+
const d = createDecipheriv('aes-256-gcm', key, Buffer.from(iv));
|
|
28
|
+
d.setAAD(aadFor(billTo, slot));
|
|
29
|
+
d.setAuthTag(Buffer.from(tag));
|
|
30
|
+
return Buffer.concat([d.update(Buffer.from(ciphertext)), d.final()]).toString('utf8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The last 4 characters, shown as ••••xxxx. Never more. */
|
|
34
|
+
export function suffixOf(secret) {
|
|
35
|
+
const s = String(secret || '');
|
|
36
|
+
return s.length > 8 ? s.slice(-4) : '';
|
|
37
|
+
}
|