@worca/app 1.5.0 → 1.6.0-rc.2
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 +8 -2
- 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 +56 -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 +202 -10
- 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 +271 -54
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +216 -9
- 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 +303 -244
- 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 +110 -54
- 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,180 @@
|
|
|
1
|
+
// src/broker/slots.mjs
|
|
2
|
+
// Credential slots (plans/credential-broker-design.html §5.3, §6.3): one kind of
|
|
3
|
+
// credential with ONE pinned destination. A request names a slot, never a host, so
|
|
4
|
+
// nothing on worca's side (the catalog, an Ask proposal, an agent's env) can send a
|
|
5
|
+
// key anywhere else (guarantee K2). Pure.
|
|
6
|
+
|
|
7
|
+
export const PROTOCOLS = Object.freeze(['anthropic', 'openai', 'github']);
|
|
8
|
+
export const AUTH_STYLES = Object.freeze(['x-api-key', 'bearer', 'copilot', 'github-user', 'none']);
|
|
9
|
+
const HEADER_NAME_RE = /^[a-z0-9-]{1,64}$/;
|
|
10
|
+
/** Request headers Copilot's gateway needs beyond the common allowlist (the editor identity). */
|
|
11
|
+
const COPILOT_HEADERS = Object.freeze([
|
|
12
|
+
'copilot-integration-id', 'editor-version', 'editor-plugin-version', 'openai-intent',
|
|
13
|
+
'x-github-api-version', 'x-request-id', 'x-initiator', 'copilot-vision-request',
|
|
14
|
+
]);
|
|
15
|
+
export const CREDENTIAL_KINDS = Object.freeze(['per-person', 'operator', 'none']);
|
|
16
|
+
const SLOT_ID_RE = /^[a-z][a-z0-9-]{1,31}$/;
|
|
17
|
+
|
|
18
|
+
const ANTHROPIC_PATHS = Object.freeze([
|
|
19
|
+
['POST', '/v1/messages'],
|
|
20
|
+
['POST', '/v1/messages/count_tokens'],
|
|
21
|
+
['GET', '/v1/models'],
|
|
22
|
+
]);
|
|
23
|
+
const OPENAI_PATHS = Object.freeze([
|
|
24
|
+
['POST', '/v1/chat/completions'],
|
|
25
|
+
['POST', '/v1/responses'],
|
|
26
|
+
['GET', '/v1/models'],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The built-in slots. `local` exists only when the operator names its upstream; `github`
|
|
31
|
+
* ("push as me") only when the operator configured a GitHub App or OAuth App for it.
|
|
32
|
+
*/
|
|
33
|
+
export function builtinSlots({ localUrl = null, github = null } = {}) {
|
|
34
|
+
const slots = [
|
|
35
|
+
{
|
|
36
|
+
// An API key (sk-ant-api…) or a Claude subscription token from `claude setup-token`
|
|
37
|
+
// (sk-ant-oat…): the broker tells them apart and sends each the way it must be sent.
|
|
38
|
+
id: 'anthropic', label: 'Anthropic API key or Claude subscription', protocol: 'anthropic',
|
|
39
|
+
upstream: 'https://api.anthropic.com', auth: 'x-api-key', credential: 'per-person',
|
|
40
|
+
paths: ANTHROPIC_PATHS, verify: { method: 'GET', path: '/v1/models' },
|
|
41
|
+
keyHint: 'sk-ant-api… or, from `claude setup-token`, sk-ant-oat…',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'openai', label: 'OpenAI API key', protocol: 'openai',
|
|
45
|
+
upstream: 'https://api.openai.com', auth: 'bearer', credential: 'per-person',
|
|
46
|
+
paths: OPENAI_PATHS, verify: { method: 'GET', path: '/v1/models' },
|
|
47
|
+
keyHint: 'sk-…',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'openrouter', label: 'OpenRouter API key', protocol: 'openai',
|
|
51
|
+
upstream: 'https://openrouter.ai', auth: 'bearer', credential: 'per-person',
|
|
52
|
+
paths: Object.freeze([['POST', '/api/v1/chat/completions'], ['GET', '/api/v1/models'], ['GET', '/api/v1/key']]),
|
|
53
|
+
verify: { method: 'GET', path: '/api/v1/key' },
|
|
54
|
+
keyHint: 'sk-or-…',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
// The upstream is where the token exchange says Copilot's API lives (always a
|
|
58
|
+
// *.githubcopilot.com host, src/broker/copilot.mjs); this is only the fallback.
|
|
59
|
+
id: 'copilot', label: 'GitHub Copilot', protocol: 'openai',
|
|
60
|
+
upstream: 'https://api.githubcopilot.com', auth: 'copilot', credential: 'per-person',
|
|
61
|
+
paths: Object.freeze([['POST', '/chat/completions'], ['POST', '/responses'], ['POST', '/v1/messages'], ['GET', '/models']]),
|
|
62
|
+
headers: COPILOT_HEADERS, verify: { exchange: true }, keyHint: '', signIn: 'github-device',
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
if (github && github.clientId) {
|
|
66
|
+
// Never proxied (no paths): worca asks the broker for a short-lived token for ONE
|
|
67
|
+
// git or gh call of the acting person (/internal/github-token), and makes the call.
|
|
68
|
+
slots.push({
|
|
69
|
+
id: 'github', label: 'GitHub (push as me)', protocol: 'github',
|
|
70
|
+
upstream: 'https://api.github.com', auth: 'github-user', credential: 'per-person',
|
|
71
|
+
paths: Object.freeze([]), verify: { github: true }, keyHint: 'or paste a fine-grained token (github_pat_…)',
|
|
72
|
+
signIn: 'github-device', clientId: github.clientId, scope: github.scope,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (localUrl) {
|
|
76
|
+
slots.push({
|
|
77
|
+
id: 'local', label: 'Local models', protocol: 'anthropic',
|
|
78
|
+
upstream: localUrl, auth: 'none', credential: 'none',
|
|
79
|
+
paths: ANTHROPIC_PATHS, verify: null, keyHint: '',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return slots;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isPrivateHost(host) {
|
|
86
|
+
const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
87
|
+
if (h === 'localhost' || h === '::1' || h === 'host.docker.internal' || h.endsWith('.internal') || h.endsWith('.local')) return true;
|
|
88
|
+
if (/^(fc|fd)[0-9a-f]{2}:/i.test(h)) return true;
|
|
89
|
+
const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(h);
|
|
90
|
+
if (!m) return !h.includes('.'); // a bare service name on a private network ("ollama")
|
|
91
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
92
|
+
return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** An upstream must be an origin: https anywhere, http only on a private network. */
|
|
96
|
+
export function upstreamIssue(v) {
|
|
97
|
+
let u;
|
|
98
|
+
try { u = new URL(String(v || '').trim()); } catch { return 'upstream is not a URL'; }
|
|
99
|
+
if (u.username || u.password) return 'upstream must not carry credentials';
|
|
100
|
+
if (u.pathname !== '/' || u.search || u.hash) return 'upstream must be an origin only (scheme://host[:port])';
|
|
101
|
+
if (u.protocol === 'https:') return null;
|
|
102
|
+
if (u.protocol === 'http:' && isPrivateHost(u.hostname)) return null;
|
|
103
|
+
return 'upstream must be https (http only for a private or loopback host)';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normPaths(paths, where) {
|
|
107
|
+
if (!Array.isArray(paths) || !paths.length) throw new Error(`${where}: paths must be a non-empty array of [METHOD, "/path"]`);
|
|
108
|
+
return Object.freeze(paths.map((p) => {
|
|
109
|
+
const [method, path] = Array.isArray(p) ? p : [p?.method, p?.path];
|
|
110
|
+
const m = String(method || '').toUpperCase();
|
|
111
|
+
if (!['GET', 'POST'].includes(m)) throw new Error(`${where}: method must be GET or POST`);
|
|
112
|
+
if (typeof path !== 'string' || !path.startsWith('/') || pathIssue(path)) throw new Error(`${where}: bad path ${JSON.stringify(path)}`);
|
|
113
|
+
return Object.freeze([m, path]);
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Merge the operator's slots file over the built-ins: an entry with a built-in id
|
|
119
|
+
* overrides it field by field, a new id adds a slot. Throws with a message naming
|
|
120
|
+
* the entry.
|
|
121
|
+
*/
|
|
122
|
+
export function mergeSlots(builtins, extra) {
|
|
123
|
+
const byId = new Map(builtins.map((s) => [s.id, { ...s }]));
|
|
124
|
+
if (extra !== undefined && extra !== null) {
|
|
125
|
+
if (!Array.isArray(extra)) throw new Error('the slots file must hold a JSON array');
|
|
126
|
+
for (const [i, e] of extra.entries()) {
|
|
127
|
+
const where = `slots[${i}]`;
|
|
128
|
+
if (!e || typeof e !== 'object') throw new Error(`${where} must be an object`);
|
|
129
|
+
if (!SLOT_ID_RE.test(String(e.id || ''))) throw new Error(`${where}: id must match ${SLOT_ID_RE}`);
|
|
130
|
+
const base = byId.get(e.id) || {};
|
|
131
|
+
const s = { ...base, ...e };
|
|
132
|
+
if (!PROTOCOLS.includes(s.protocol)) throw new Error(`${where}: protocol must be one of ${PROTOCOLS.join(', ')}`);
|
|
133
|
+
if (!AUTH_STYLES.includes(s.auth)) throw new Error(`${where}: auth must be one of ${AUTH_STYLES.join(', ')}`);
|
|
134
|
+
if (!CREDENTIAL_KINDS.includes(s.credential)) throw new Error(`${where}: credential must be one of ${CREDENTIAL_KINDS.join(', ')}`);
|
|
135
|
+
const issue = upstreamIssue(s.upstream);
|
|
136
|
+
if (issue) throw new Error(`${where}: ${issue}`);
|
|
137
|
+
s.upstream = new URL(s.upstream).origin;
|
|
138
|
+
// A GitHub ("push as me") slot is never proxied: it keeps no paths.
|
|
139
|
+
s.paths = s.auth === 'github-user' ? Object.freeze([]) : normPaths(s.paths || (s.protocol === 'openai' ? OPENAI_PATHS : ANTHROPIC_PATHS), where);
|
|
140
|
+
s.label = typeof s.label === 'string' && s.label.trim() ? s.label.trim() : s.id;
|
|
141
|
+
if (s.verify && (typeof s.verify !== 'object' || (!s.verify.path && !s.verify.exchange && !s.verify.github))) throw new Error(`${where}: verify must be {method, path}`);
|
|
142
|
+
if (s.headers !== undefined) {
|
|
143
|
+
if (!Array.isArray(s.headers) || !s.headers.every((x) => HEADER_NAME_RE.test(String(x).toLowerCase()))) throw new Error(`${where}: headers must be a list of header names`);
|
|
144
|
+
if (s.headers.some((x) => /^(authorization|x-api-key|cookie|host|proxy-.*)$/i.test(x))) throw new Error(`${where}: headers may not include credential or routing headers`);
|
|
145
|
+
s.headers = Object.freeze(s.headers.map((x) => String(x).toLowerCase()));
|
|
146
|
+
}
|
|
147
|
+
for (const k of ['exchangeUrl', 'deviceBaseUrl']) {
|
|
148
|
+
if ((s.auth !== 'copilot' && s.auth !== 'github-user') || s[k] === undefined) continue;
|
|
149
|
+
let origin;
|
|
150
|
+
try { origin = new URL(s[k]).origin; } catch { throw new Error(`${where}: ${k} is not a URL`); }
|
|
151
|
+
const issue = upstreamIssue(origin);
|
|
152
|
+
if (issue) throw new Error(`${where}: ${k}: ${issue}`);
|
|
153
|
+
}
|
|
154
|
+
byId.set(s.id, s);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return [...byId.values()].map((s) => Object.freeze(s));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Why a request path is unsafe to forward, or null. Rejects anything a
|
|
162
|
+
* normalising upstream could read as a different path: dot segments, empty
|
|
163
|
+
* segments, encoded slashes/backslashes/dots, backslashes and control chars.
|
|
164
|
+
*/
|
|
165
|
+
export function pathIssue(path) {
|
|
166
|
+
const p = String(path);
|
|
167
|
+
if (!p.startsWith('/')) return 'path must start with /';
|
|
168
|
+
if (/[\u0000-\u001f\u007f\\]/.test(p)) return 'path has control characters or backslashes';
|
|
169
|
+
if (/%(2f|5c|2e|00)/i.test(p)) return 'path has encoded separators';
|
|
170
|
+
if (p.includes('//')) return 'path has empty segments';
|
|
171
|
+
if (p.split('/').some((seg) => seg === '.' || seg === '..')) return 'path has dot segments';
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Whether `method path` (path WITHOUT the query) is on the slot's allowlist. */
|
|
176
|
+
export function isAllowedPath(slot, method, path) {
|
|
177
|
+
if (pathIssue(path)) return false;
|
|
178
|
+
const m = String(method || '').toUpperCase();
|
|
179
|
+
return slot.paths.some(([pm, pp]) => pm === m && pp === path);
|
|
180
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// src/broker/store.mjs
|
|
2
|
+
// The broker's own SQLite database (plans/credential-broker-design.html §6.5):
|
|
3
|
+
// sealed credentials, spawn tokens (hashes only) and usage rows. node:sqlite,
|
|
4
|
+
// synchronous, loaded lazily like src/core/db.mjs. A path of ':memory:' keeps
|
|
5
|
+
// everything in memory (single mode without a data dir, and tests).
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { mkdirSync } from 'node:fs';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const _require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
const SCHEMA = `
|
|
13
|
+
CREATE TABLE IF NOT EXISTS credentials (
|
|
14
|
+
bill_to TEXT NOT NULL, slot TEXT NOT NULL,
|
|
15
|
+
ciphertext BLOB NOT NULL, iv BLOB NOT NULL, tag BLOB NOT NULL,
|
|
16
|
+
key_id TEXT NOT NULL, suffix TEXT NOT NULL,
|
|
17
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
18
|
+
last_used_at TEXT, verified_at TEXT, verify_error TEXT,
|
|
19
|
+
daily_usd REAL, monthly_usd REAL,
|
|
20
|
+
PRIMARY KEY (bill_to, slot));
|
|
21
|
+
CREATE TABLE IF NOT EXISTS tokens (
|
|
22
|
+
hash TEXT PRIMARY KEY,
|
|
23
|
+
spawn_id TEXT NOT NULL UNIQUE, run_id TEXT, thread_id TEXT, kind TEXT NOT NULL,
|
|
24
|
+
bill_to TEXT NOT NULL, slots TEXT NOT NULL, issuer TEXT NOT NULL,
|
|
25
|
+
created_at TEXT NOT NULL, expires_at TEXT NOT NULL, revoked_at TEXT,
|
|
26
|
+
budget_usd REAL, spent_usd REAL NOT NULL DEFAULT 0);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS usage (
|
|
28
|
+
id INTEGER PRIMARY KEY, at TEXT NOT NULL, bill_to TEXT NOT NULL, slot TEXT NOT NULL,
|
|
29
|
+
spawn_id TEXT, run_id TEXT, model TEXT, status INTEGER,
|
|
30
|
+
input_tokens INTEGER, output_tokens INTEGER, cache_read_tokens INTEGER, cache_write_tokens INTEGER,
|
|
31
|
+
usd REAL, ms INTEGER);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS usage_bill_to_at ON usage (bill_to, at);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS usage_run ON usage (run_id);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS tokens_issuer ON tokens (issuer);
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
38
|
+
|
|
39
|
+
export function openStore(path = ':memory:') {
|
|
40
|
+
const { DatabaseSync } = _require('node:sqlite');
|
|
41
|
+
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
const db = new DatabaseSync(path);
|
|
43
|
+
db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
|
|
44
|
+
db.exec(SCHEMA);
|
|
45
|
+
// Columns added after the first release, on existing databases too:
|
|
46
|
+
// credentials.kind 'api-key' | 'subscription' | 'github' (shown without decrypting)
|
|
47
|
+
// tokens.isolated the spawn runs under its person's own agent user (a subscription
|
|
48
|
+
// is only ever used by such a spawn in multi mode)
|
|
49
|
+
// usage.plan 'subscription' for calls on a Claude plan (no per-call price)
|
|
50
|
+
const cols = (t) => new Set(db.prepare(`PRAGMA table_info(${t})`).all().map((r) => r.name));
|
|
51
|
+
if (!cols('credentials').has('kind')) db.exec('ALTER TABLE credentials ADD COLUMN kind TEXT');
|
|
52
|
+
if (!cols('tokens').has('isolated')) db.exec('ALTER TABLE tokens ADD COLUMN isolated INTEGER NOT NULL DEFAULT 0');
|
|
53
|
+
if (!cols('usage').has('plan')) db.exec('ALTER TABLE usage ADD COLUMN plan TEXT');
|
|
54
|
+
const q = (sql) => db.prepare(sql);
|
|
55
|
+
|
|
56
|
+
const s = {
|
|
57
|
+
db,
|
|
58
|
+
close() { try { db.close(); } catch { /* already closed */ } },
|
|
59
|
+
|
|
60
|
+
// ── credentials ──
|
|
61
|
+
putCredential({ billTo, slot, sealed, suffix, kind = null, verifiedAt = null, now = Date.now() }) {
|
|
62
|
+
q(`INSERT INTO credentials (bill_to, slot, ciphertext, iv, tag, key_id, suffix, kind, created_at, updated_at, verified_at, verify_error)
|
|
63
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
64
|
+
ON CONFLICT (bill_to, slot) DO UPDATE SET ciphertext = excluded.ciphertext, iv = excluded.iv, tag = excluded.tag,
|
|
65
|
+
key_id = excluded.key_id, suffix = excluded.suffix, kind = excluded.kind, updated_at = excluded.updated_at,
|
|
66
|
+
verified_at = excluded.verified_at, verify_error = NULL`)
|
|
67
|
+
.run(billTo, slot, sealed.ciphertext, sealed.iv, sealed.tag, sealed.keyId, suffix, kind, iso(now), iso(now), verifiedAt ? iso(verifiedAt) : null);
|
|
68
|
+
},
|
|
69
|
+
getCredential(billTo, slot) {
|
|
70
|
+
return q('SELECT * FROM credentials WHERE bill_to = ? AND slot = ?').get(billTo, slot) || null;
|
|
71
|
+
},
|
|
72
|
+
listCredentials(billTo) {
|
|
73
|
+
return q('SELECT bill_to, slot, suffix, kind, key_id, created_at, updated_at, last_used_at, verified_at, verify_error, daily_usd, monthly_usd FROM credentials WHERE bill_to = ? ORDER BY slot').all(billTo);
|
|
74
|
+
},
|
|
75
|
+
allCredentials() {
|
|
76
|
+
return q('SELECT * FROM credentials').all();
|
|
77
|
+
},
|
|
78
|
+
resealCredential(billTo, slot, sealed) {
|
|
79
|
+
q('UPDATE credentials SET ciphertext = ?, iv = ?, tag = ?, key_id = ? WHERE bill_to = ? AND slot = ?')
|
|
80
|
+
.run(sealed.ciphertext, sealed.iv, sealed.tag, sealed.keyId, billTo, slot);
|
|
81
|
+
},
|
|
82
|
+
deleteCredential(billTo, slot) {
|
|
83
|
+
return q('DELETE FROM credentials WHERE bill_to = ? AND slot = ?').run(billTo, slot).changes;
|
|
84
|
+
},
|
|
85
|
+
markUsed(billTo, slot, now = Date.now()) {
|
|
86
|
+
q('UPDATE credentials SET last_used_at = ? WHERE bill_to = ? AND slot = ?').run(iso(now), billTo, slot);
|
|
87
|
+
},
|
|
88
|
+
setVerify(billTo, slot, { ok, error = null, now = Date.now() }) {
|
|
89
|
+
q('UPDATE credentials SET verified_at = ?, verify_error = ? WHERE bill_to = ? AND slot = ?')
|
|
90
|
+
.run(iso(now), ok ? null : String(error || 'rejected').slice(0, 300), billTo, slot);
|
|
91
|
+
},
|
|
92
|
+
setCaps(billTo, slot, { dailyUsd = null, monthlyUsd = null }) {
|
|
93
|
+
return q('UPDATE credentials SET daily_usd = ?, monthly_usd = ? WHERE bill_to = ? AND slot = ?')
|
|
94
|
+
.run(dailyUsd, monthlyUsd, billTo, slot).changes;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// ── tokens ──
|
|
98
|
+
insertToken(row) {
|
|
99
|
+
q(`INSERT INTO tokens (hash, spawn_id, run_id, thread_id, kind, bill_to, slots, issuer, created_at, expires_at, budget_usd, isolated)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
101
|
+
.run(row.hash, row.spawnId, row.runId ?? null, row.threadId ?? null, row.kind, row.billTo,
|
|
102
|
+
JSON.stringify(row.slots), row.issuer, iso(row.createdAt), iso(row.expiresAt), row.budgetUsd ?? null, row.isolated ? 1 : 0);
|
|
103
|
+
},
|
|
104
|
+
tokenByHash(hash) {
|
|
105
|
+
return q('SELECT * FROM tokens WHERE hash = ?').get(hash) || null;
|
|
106
|
+
},
|
|
107
|
+
tokenBySpawn(spawnId) {
|
|
108
|
+
return q('SELECT * FROM tokens WHERE spawn_id = ?').get(spawnId) || null;
|
|
109
|
+
},
|
|
110
|
+
revokeSpawn(spawnId, now = Date.now()) {
|
|
111
|
+
return q('UPDATE tokens SET revoked_at = ? WHERE spawn_id = ? AND revoked_at IS NULL').run(iso(now), spawnId).changes;
|
|
112
|
+
},
|
|
113
|
+
revokeWhere({ issuer, runId, billTo, exceptIssuer }, now = Date.now()) {
|
|
114
|
+
const where = []; const args = [];
|
|
115
|
+
if (issuer) { where.push('issuer = ?'); args.push(issuer); }
|
|
116
|
+
if (exceptIssuer) { where.push('issuer <> ?'); args.push(exceptIssuer); }
|
|
117
|
+
if (runId) { where.push('run_id = ?'); args.push(runId); }
|
|
118
|
+
if (billTo) { where.push('bill_to = ?'); args.push(billTo); }
|
|
119
|
+
if (!where.length) return 0;
|
|
120
|
+
return q(`UPDATE tokens SET revoked_at = ? WHERE revoked_at IS NULL AND ${where.join(' AND ')}`).run(iso(now), ...args).changes;
|
|
121
|
+
},
|
|
122
|
+
addSpend(hash, usd) {
|
|
123
|
+
if (!(usd > 0)) return;
|
|
124
|
+
q('UPDATE tokens SET spent_usd = spent_usd + ? WHERE hash = ?').run(usd, hash);
|
|
125
|
+
},
|
|
126
|
+
deleteExpiredTokens(now = Date.now()) {
|
|
127
|
+
// Keep revoked/expired rows for a day so a late request still gets "expired or revoked".
|
|
128
|
+
return q('DELETE FROM tokens WHERE expires_at < ?').run(iso(now - 86_400_000)).changes;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
// ── usage ──
|
|
132
|
+
insertUsage(u) {
|
|
133
|
+
q(`INSERT INTO usage (at, bill_to, slot, spawn_id, run_id, model, status, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, usd, ms, plan)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
135
|
+
.run(iso(u.at ?? Date.now()), u.billTo, u.slot, u.spawnId ?? null, u.runId ?? null, u.model ?? null, u.status ?? null,
|
|
136
|
+
u.inputTokens ?? 0, u.outputTokens ?? 0, u.cacheReadTokens ?? 0, u.cacheWriteTokens ?? 0, u.usd ?? 0, u.ms ?? null, u.plan ?? null);
|
|
137
|
+
},
|
|
138
|
+
spentSince(billTo, slot, sinceMs) {
|
|
139
|
+
const r = q('SELECT COALESCE(SUM(usd), 0) AS usd FROM usage WHERE bill_to = ? AND slot = ? AND at >= ?').get(billTo, slot, iso(sinceMs));
|
|
140
|
+
return Number(r?.usd || 0);
|
|
141
|
+
},
|
|
142
|
+
queryUsage({ since = null, billTo = null, runId = null, spawnId = null, limit = 1000 } = {}) {
|
|
143
|
+
const where = []; const args = [];
|
|
144
|
+
if (since) { where.push('at >= ?'); args.push(since); }
|
|
145
|
+
if (billTo) { where.push('bill_to = ?'); args.push(billTo); }
|
|
146
|
+
if (runId) { where.push('run_id = ?'); args.push(runId); }
|
|
147
|
+
if (spawnId) { where.push('spawn_id = ?'); args.push(spawnId); }
|
|
148
|
+
const lim = Math.max(1, Math.min(10_000, Number(limit) || 1000));
|
|
149
|
+
return q(`SELECT at, bill_to AS billTo, slot, spawn_id AS spawnId, run_id AS runId, model, status,
|
|
150
|
+
input_tokens AS inputTokens, output_tokens AS outputTokens, cache_read_tokens AS cacheReadTokens,
|
|
151
|
+
cache_write_tokens AS cacheWriteTokens, usd, ms
|
|
152
|
+
FROM usage ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ${lim}`).all(...args);
|
|
153
|
+
},
|
|
154
|
+
/** Spend and volume per person and slot in [since, until): the Stats "By person" card. */
|
|
155
|
+
summarizeUsage({ since = null, until = null } = {}) {
|
|
156
|
+
const where = []; const args = [];
|
|
157
|
+
if (since) { where.push('at >= ?'); args.push(since); }
|
|
158
|
+
if (until) { where.push('at < ?'); args.push(until); }
|
|
159
|
+
return q(`SELECT bill_to AS billTo, slot, COALESCE(plan, 'api') AS plan, COUNT(*) AS requests, COALESCE(SUM(usd), 0) AS usd,
|
|
160
|
+
COALESCE(SUM(input_tokens), 0) AS inputTokens, COALESCE(SUM(output_tokens), 0) AS outputTokens,
|
|
161
|
+
COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens, COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
|
|
162
|
+
COUNT(DISTINCT run_id) AS runs, MAX(at) AS lastAt
|
|
163
|
+
FROM usage ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
164
|
+
GROUP BY bill_to, slot, COALESCE(plan, 'api') ORDER BY usd DESC`).all(...args);
|
|
165
|
+
},
|
|
166
|
+
deleteOldUsage(now = Date.now()) {
|
|
167
|
+
return q('DELETE FROM usage WHERE at < ?').run(iso(now - 400 * 86_400_000)).changes;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// src/broker/tokens.mjs
|
|
2
|
+
// Per-spawn tokens (plans/credential-broker-design.html §5.4). A token is
|
|
3
|
+
// `wbt_` + 43 base64url chars (32 random bytes). The broker stores only its
|
|
4
|
+
// SHA-256, so a copy of the database cannot be replayed. The `wbt_` prefix exists so
|
|
5
|
+
// worca can redact a token wherever agent output is stored or shown.
|
|
6
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
export const TOKEN_PREFIX = 'wbt_';
|
|
9
|
+
export const TOKEN_RE = /^wbt_[A-Za-z0-9_-]{43}$/;
|
|
10
|
+
export const SPAWN_KINDS = Object.freeze(['phase', 'ask', 'aux', 'test']);
|
|
11
|
+
/** Default and maximum lifetime per spawn kind, before the operator's global cap. */
|
|
12
|
+
export const KIND_TTL_MS = Object.freeze({ aux: 10 * 60_000, test: 10 * 60_000, ask: 2 * 3_600_000, phase: 24 * 3_600_000 });
|
|
13
|
+
const ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
14
|
+
const EMAIL_RE = /^[^\s@<>]{1,200}(@[^\s@<>]{1,200})?$/;
|
|
15
|
+
|
|
16
|
+
export function hashToken(token) {
|
|
17
|
+
return createHash('sha256').update(String(token)).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Constant-time string comparison (both sides hashed first so lengths match). */
|
|
21
|
+
export function safeEqual(a, b) {
|
|
22
|
+
const ha = createHash('sha256').update(String(a ?? '')).digest();
|
|
23
|
+
const hb = createHash('sha256').update(String(b ?? '')).digest();
|
|
24
|
+
return timingSafeEqual(ha, hb) && String(a ?? '').length === String(b ?? '').length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Normalise a person id: lower-cased email, or 'local'. null when unusable. */
|
|
28
|
+
export function normalizeBillTo(v) {
|
|
29
|
+
const s = typeof v === 'string' ? v.trim().toLowerCase() : '';
|
|
30
|
+
return s && EMAIL_RE.test(s) ? s : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate a mint request body. Returns {req} or {error}.
|
|
35
|
+
* @param {object} body {billTo, slots, spawnId, runId?, threadId?, kind, ttlSec?, budgetUsd?, issuer}
|
|
36
|
+
*/
|
|
37
|
+
export function parseMintRequest(body, { slotIds, maxTtlMs }) {
|
|
38
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
39
|
+
const billTo = normalizeBillTo(b.billTo);
|
|
40
|
+
if (!billTo) return { error: 'billTo must be an email or "local"' };
|
|
41
|
+
if (!Array.isArray(b.slots) || !b.slots.length || !b.slots.every((s) => slotIds.includes(s))) {
|
|
42
|
+
return { error: `slots must be a non-empty list of known slots (${slotIds.join(', ')})` };
|
|
43
|
+
}
|
|
44
|
+
if (!ID_RE.test(String(b.spawnId || ''))) return { error: 'spawnId is required ([A-Za-z0-9._:-], at most 128)' };
|
|
45
|
+
if (!ID_RE.test(String(b.issuer || ''))) return { error: 'issuer is required' };
|
|
46
|
+
for (const k of ['runId', 'threadId']) {
|
|
47
|
+
if (b[k] != null && !ID_RE.test(String(b[k]))) return { error: `${k} has unsupported characters` };
|
|
48
|
+
}
|
|
49
|
+
const kind = SPAWN_KINDS.includes(b.kind) ? b.kind : null;
|
|
50
|
+
if (!kind) return { error: `kind must be one of ${SPAWN_KINDS.join(', ')}` };
|
|
51
|
+
const kindMax = KIND_TTL_MS[kind];
|
|
52
|
+
let ttlMs = kindMax;
|
|
53
|
+
if (b.ttlSec != null) {
|
|
54
|
+
const n = Number(b.ttlSec);
|
|
55
|
+
if (!Number.isFinite(n) || n <= 0) return { error: 'ttlSec must be a positive number' };
|
|
56
|
+
ttlMs = Math.min(n * 1000, kindMax);
|
|
57
|
+
}
|
|
58
|
+
ttlMs = Math.min(ttlMs, maxTtlMs);
|
|
59
|
+
let budgetUsd = null;
|
|
60
|
+
if (b.budgetUsd != null) {
|
|
61
|
+
const n = Number(b.budgetUsd);
|
|
62
|
+
if (!Number.isFinite(n) || n < 0) return { error: 'budgetUsd must be a number >= 0' };
|
|
63
|
+
budgetUsd = n;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
req: {
|
|
67
|
+
billTo, slots: [...new Set(b.slots)], spawnId: String(b.spawnId), issuer: String(b.issuer),
|
|
68
|
+
runId: b.runId != null ? String(b.runId) : null, threadId: b.threadId != null ? String(b.threadId) : null,
|
|
69
|
+
kind, ttlMs, budgetUsd,
|
|
70
|
+
// worca says so when the spawn runs under its person's own agent user (agent-pool.mjs):
|
|
71
|
+
// only then may a personal Claude subscription be used for it in multi mode.
|
|
72
|
+
isolated: b.isolated === true,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Mint and store a token. Returns {token, expiresAt}. A reused spawnId revokes the old token first. */
|
|
78
|
+
export function mintToken(store, req, now = Date.now()) {
|
|
79
|
+
const old = store.tokenBySpawn(req.spawnId);
|
|
80
|
+
if (old) {
|
|
81
|
+
store.revokeSpawn(req.spawnId, now);
|
|
82
|
+
store.db.prepare('DELETE FROM tokens WHERE spawn_id = ?').run(req.spawnId);
|
|
83
|
+
}
|
|
84
|
+
const token = TOKEN_PREFIX + randomBytes(32).toString('base64url');
|
|
85
|
+
const expiresAt = now + req.ttlMs;
|
|
86
|
+
store.insertToken({ ...req, hash: hashToken(token), createdAt: now, expiresAt });
|
|
87
|
+
return { token, expiresAt: new Date(expiresAt).toISOString() };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Look a presented token up. Returns {row} for a live token, or {error} naming why
|
|
92
|
+
* it is refused ('missing' | 'malformed' | 'unknown' | 'revoked' | 'expired').
|
|
93
|
+
*/
|
|
94
|
+
export function resolveToken(store, token, now = Date.now()) {
|
|
95
|
+
if (!token) return { error: 'missing' };
|
|
96
|
+
if (!TOKEN_RE.test(token)) return { error: 'malformed' };
|
|
97
|
+
const row = store.tokenByHash(hashToken(token));
|
|
98
|
+
if (!row) return { error: 'unknown' };
|
|
99
|
+
if (row.revoked_at) return { error: 'revoked' };
|
|
100
|
+
if (Date.parse(row.expires_at) <= now) return { error: 'expired' };
|
|
101
|
+
let slots = [];
|
|
102
|
+
try { slots = JSON.parse(row.slots); } catch { slots = []; }
|
|
103
|
+
return { row: { ...row, slots } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The token a request carries: `x-api-key`, or `Authorization: Bearer`. */
|
|
107
|
+
export function tokenFromHeaders(headers) {
|
|
108
|
+
const k = headers['x-api-key'];
|
|
109
|
+
if (typeof k === 'string' && k.trim()) return k.trim();
|
|
110
|
+
const a = headers.authorization;
|
|
111
|
+
if (typeof a === 'string') {
|
|
112
|
+
const m = /^Bearer\s+(\S+)\s*$/i.exec(a);
|
|
113
|
+
if (m) return m[1];
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
:root{
|
|
2
|
+
color-scheme:light dark;
|
|
3
|
+
--bg:light-dark(#F1F1EF,#161614);
|
|
4
|
+
--panel:light-dark(#FFFFFF,#222220);
|
|
5
|
+
--field:light-dark(#F6F6F4,#2C2C29);
|
|
6
|
+
--ink:light-dark(#19191B,#ECECE8);
|
|
7
|
+
--ink-2:light-dark(#5C5C63,#B4B4AE);
|
|
8
|
+
--line:light-dark(#E3E3E0,#3A3A36);
|
|
9
|
+
--accent:light-dark(#2B6A8C,#86C4E3);
|
|
10
|
+
--on-accent:light-dark(#FFFFFF,#161614);
|
|
11
|
+
--ok-bg:light-dark(#E2F3DF,#1C2F1D); --ok:light-dark(#2F7A38,#8FD48F);
|
|
12
|
+
--err-bg:light-dark(#FBE3E0,#34201D); --err:light-dark(#C5483A,#F08C7E);
|
|
13
|
+
--na-bg:light-dark(#ECECEA,#30302D); --na:light-dark(#5C5C63,#B4B4AE);
|
|
14
|
+
--info-bg:light-dark(#DEEFF7,#1A2A33); --info:light-dark(#3782A8,#86C4E3);
|
|
15
|
+
}
|
|
16
|
+
*{box-sizing:border-box}
|
|
17
|
+
/* The page toggles rows with the hidden attribute; author display rules (.row) must not win. */
|
|
18
|
+
[hidden]{display:none!important}
|
|
19
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding-inline:16px}
|
|
20
|
+
.page{max-width:760px;margin:0 auto;padding-block:40px 64px}
|
|
21
|
+
.head{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap}
|
|
22
|
+
h1{font-size:24px;line-height:1.2;margin:0 0 4px}
|
|
23
|
+
.who{margin:0;color:var(--ink-2);font-size:14px}
|
|
24
|
+
.back{color:var(--accent);font-size:14px}
|
|
25
|
+
.intro{color:var(--ink-2);font-size:14px;max-width:62ch;margin:14px 0 20px}
|
|
26
|
+
.banner{padding:10px 14px;border-radius:8px;font-size:14px;margin:0 0 16px}
|
|
27
|
+
.banner.err{background:var(--err-bg);color:var(--err)}
|
|
28
|
+
.slots{display:grid;gap:18px}
|
|
29
|
+
.slot{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px}
|
|
30
|
+
.slot-top{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 16px;align-items:center}
|
|
31
|
+
.slot h2{font-size:16px;margin:0}
|
|
32
|
+
.meta{font-size:13px;color:var(--ink-2);margin:2px 0 0}
|
|
33
|
+
.chip{display:inline-block;font-size:12px;font-weight:600;padding:4px 9px;border-radius:999px;white-space:nowrap}
|
|
34
|
+
.chip.set{background:var(--ok-bg);color:var(--ok)}
|
|
35
|
+
.chip.missing{background:var(--na-bg);color:var(--na)}
|
|
36
|
+
.chip.invalid{background:var(--err-bg);color:var(--err)}
|
|
37
|
+
.chip.keyless,.chip.operator{background:var(--info-bg);color:var(--info)}
|
|
38
|
+
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:12px}
|
|
39
|
+
.row .grow{flex:1 1 240px;min-width:0}
|
|
40
|
+
input{font:inherit;color:var(--ink);background:var(--field);border:1px solid var(--line);border-radius:6px;padding:0 10px;height:34px;width:100%}
|
|
41
|
+
input:focus-visible,button:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
42
|
+
button{font:inherit;font-size:13px;font-weight:500;height:34px;padding:0 12px;border-radius:6px;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer}
|
|
43
|
+
button.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
|
44
|
+
button.danger{color:var(--err)}
|
|
45
|
+
button:disabled{opacity:.6;cursor:default}
|
|
46
|
+
.msg{font-size:13px;margin:8px 0 0}
|
|
47
|
+
.msg.ok{color:var(--ok)}
|
|
48
|
+
.msg.err{color:var(--err)}
|
|
49
|
+
.caps{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;font-size:13px;color:var(--ink-2);margin-top:12px}
|
|
50
|
+
.caps label{display:flex;gap:6px;align-items:center}
|
|
51
|
+
.caps input{width:90px}
|
|
52
|
+
.code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:15px;letter-spacing:.08em}
|
|
53
|
+
.usage{font-size:13px;color:var(--ink-2);margin:10px 0 0;font-variant-numeric:tabular-nums}
|
|
54
|
+
@media (max-width:520px){.slot-top{grid-template-columns:minmax(0,1fr)}}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Your model credentials · worca</title>
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<link rel="stylesheet" href="/page.css">
|
|
9
|
+
<script type="module" src="/page.mjs"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<main class="page">
|
|
13
|
+
<header class="head">
|
|
14
|
+
<div>
|
|
15
|
+
<h1>Your model credentials</h1>
|
|
16
|
+
<p class="who" id="who">Loading…</p>
|
|
17
|
+
</div>
|
|
18
|
+
<a class="back" id="back" href="#" hidden>Back to worca</a>
|
|
19
|
+
</header>
|
|
20
|
+
<p class="intro">Your keys are stored encrypted by the credential broker and are never shown again, to anyone. Runs, chats and code on this worca are still visible to your teammates.</p>
|
|
21
|
+
<p class="banner err" id="error" role="alert" hidden></p>
|
|
22
|
+
<section class="slots" id="slots" aria-live="polite"></section>
|
|
23
|
+
</main>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|