@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
package/README.md
CHANGED
|
@@ -234,7 +234,7 @@ See [`docs/team-policy.md`](docs/team-policy.md).
|
|
|
234
234
|
into that model's agent spawns. Share a model catalog as a plugin, with
|
|
235
235
|
secrets required at install time.
|
|
236
236
|
- **No first-party account needed** — run and chat titles are written by the
|
|
237
|
-
model the run or chat itself uses (Settings ›
|
|
237
|
+
model the run or chat itself uses (Settings › Models › Title generation picks
|
|
238
238
|
a fixed one instead), endpoint-routed models carry Claude Code's internal
|
|
239
239
|
haiku/sonnet/opus/fable tier keys so nothing falls back to the Anthropic API,
|
|
240
240
|
and *Hide built-in models* (Settings › Models) drops the built-ins from every
|
|
@@ -352,6 +352,11 @@ worca --project /path/to/your/project --prompt "Add a /search endpoint" --workfl
|
|
|
352
352
|
# pause with Ctrl+C, continue later (survives restarts)
|
|
353
353
|
worca resume <pipelineId>
|
|
354
354
|
|
|
355
|
+
# see every run from the terminal — no browser, no Worca server needed
|
|
356
|
+
worca runs
|
|
357
|
+
worca runs --status paused
|
|
358
|
+
worca runs <pipelineId> # one run in detail (any unique prefix; --json for machines)
|
|
359
|
+
|
|
355
360
|
# run it later: once, from this terminal, or on a repeat (needs `worca ui` up, or --wait)
|
|
356
361
|
worca --project /path/to/your/project --prompt "Upgrade dependencies" --at "tomorrow 02:00"
|
|
357
362
|
worca --project /path/to/your/project --prompt "Upgrade dependencies" --at 02:00 --wait --yes
|
|
@@ -394,7 +399,8 @@ interactive run paused and you can resume it); `1` a hard error, a stop, or an
|
|
|
394
399
|
interactive pause an error forced; `2` a usage error; `3` a `--yes` run that
|
|
395
400
|
parked itself — auth, quota, a usage or cost limit, exhausted retries, or a
|
|
396
401
|
step error — with nobody attached to resume it. Nothing is discarded on a
|
|
397
|
-
pause: `worca resume <pipelineId>` picks the run up where it stopped
|
|
402
|
+
pause: `worca resume <pipelineId>` picks the run up where it stopped — on the
|
|
403
|
+
model it was started with (`--model`, or the one picked in the UI) — and the
|
|
398
404
|
cause is printed with the pause block on stdout.
|
|
399
405
|
|
|
400
406
|
### `/worca` skill (inside Claude Code)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# docker/compose.broker.yml — the credential broker overlay (docs/credential-broker.md).
|
|
2
|
+
#
|
|
3
|
+
# docker compose -f compose.yml -f compose.broker.yml up -d
|
|
4
|
+
# (add -f compose.egress.yml as well to lock worca's network down)
|
|
5
|
+
#
|
|
6
|
+
# Model keys move out of the worca container into a second container from the same
|
|
7
|
+
# image. worca holds no key: each claude spawn gets a short-lived token from the
|
|
8
|
+
# broker, and the broker adds the real key on the way to the provider. Agents can't
|
|
9
|
+
# read a key because there is none where they run.
|
|
10
|
+
#
|
|
11
|
+
# In .env (never ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN: compose.yml would hand
|
|
12
|
+
# those to worca, and worca refuses to start while a key is within agents' reach):
|
|
13
|
+
#
|
|
14
|
+
# WORCA_BROKER_SECRET=<32+ random chars> # `docker compose run --rm broker worca broker secrets`
|
|
15
|
+
# WORCA_BROKER_KEY_ANTHROPIC=sk-ant-… # single mode: the one key, held by the broker only
|
|
16
|
+
#
|
|
17
|
+
# Multi mode (a team sharing one instance, each person with their own keys) also needs
|
|
18
|
+
# WORCA_BROKER_MODE=multi, WORCA_BROKER_VAULT_KEY, WORCA_BROKER_PUBLIC_URL and an identity
|
|
19
|
+
# check for the key page; see docs/credential-broker.md.
|
|
20
|
+
|
|
21
|
+
services:
|
|
22
|
+
worca:
|
|
23
|
+
depends_on:
|
|
24
|
+
- broker
|
|
25
|
+
environment:
|
|
26
|
+
WORCA_BROKER_URL: http://broker:8080
|
|
27
|
+
WORCA_BROKER_SECRET: ${WORCA_BROKER_SECRET:?set WORCA_BROKER_SECRET in .env}
|
|
28
|
+
WORCA_BROKER_SYSTEM_BILL_TO: ${WORCA_BROKER_SYSTEM_BILL_TO:-}
|
|
29
|
+
# Optional: pushes and pull requests as the person who acted (prefer | required),
|
|
30
|
+
# and what to do with MCP servers that carry a literal secret (block | warn | off).
|
|
31
|
+
WORCA_GH_AS_PERSON: ${WORCA_GH_AS_PERSON:-}
|
|
32
|
+
WORCA_MCP_SECRETS: ${WORCA_MCP_SECRETS:-}
|
|
33
|
+
# The key lives in the broker now; these stay empty in worca.
|
|
34
|
+
ANTHROPIC_API_KEY: ""
|
|
35
|
+
CLAUDE_CODE_OAUTH_TOKEN: ""
|
|
36
|
+
|
|
37
|
+
broker:
|
|
38
|
+
image: ghcr.io/sinishadjukic/worca:${WORCA_TAG:-latest}
|
|
39
|
+
init: true
|
|
40
|
+
command: ["worca", "broker"]
|
|
41
|
+
environment:
|
|
42
|
+
WORCA_BROKER_MODE: ${WORCA_BROKER_MODE:-single}
|
|
43
|
+
WORCA_BROKER_SECRET: ${WORCA_BROKER_SECRET:?set WORCA_BROKER_SECRET in .env}
|
|
44
|
+
WORCA_BROKER_VAULT_KEY: ${WORCA_BROKER_VAULT_KEY:-}
|
|
45
|
+
WORCA_BROKER_KEY_ANTHROPIC: ${WORCA_BROKER_KEY_ANTHROPIC:-}
|
|
46
|
+
WORCA_BROKER_LOCAL_URL: ${WORCA_BROKER_LOCAL_URL:-}
|
|
47
|
+
WORCA_BROKER_PUBLIC_URL: ${WORCA_BROKER_PUBLIC_URL:-}
|
|
48
|
+
WORCA_BROKER_RETURN_URL: ${WORCA_BROKER_RETURN_URL:-}
|
|
49
|
+
WORCA_CF_ACCESS_TEAM_DOMAIN: ${WORCA_BROKER_CF_ACCESS_TEAM_DOMAIN:-}
|
|
50
|
+
WORCA_CF_ACCESS_AUD: ${WORCA_BROKER_CF_ACCESS_AUD:-}
|
|
51
|
+
WORCA_IDENTITY_HEADER: ${WORCA_BROKER_IDENTITY_HEADER:-}
|
|
52
|
+
WORCA_BROKER_DEFAULT_DAILY_USD: ${WORCA_BROKER_DEFAULT_DAILY_USD:-}
|
|
53
|
+
WORCA_BROKER_DEFAULT_MONTHLY_USD: ${WORCA_BROKER_DEFAULT_MONTHLY_USD:-}
|
|
54
|
+
# "Push as me": the GitHub App or OAuth App people sign in with on the key page.
|
|
55
|
+
WORCA_BROKER_GITHUB_CLIENT_ID: ${WORCA_BROKER_GITHUB_CLIENT_ID:-}
|
|
56
|
+
WORCA_BROKER_GITHUB_CLIENT_SECRET: ${WORCA_BROKER_GITHUB_CLIENT_SECRET:-}
|
|
57
|
+
WORCA_BROKER_GITHUB_SCOPES: ${WORCA_BROKER_GITHUB_SCOPES:-repo}
|
|
58
|
+
# The image's /worca is owned by the worca user, so a named volume there is too.
|
|
59
|
+
WORCA_BROKER_DATA_DIR: /worca/broker
|
|
60
|
+
volumes:
|
|
61
|
+
- broker-data:/worca
|
|
62
|
+
healthcheck:
|
|
63
|
+
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"]
|
|
64
|
+
interval: 30s
|
|
65
|
+
timeout: 5s
|
|
66
|
+
start_period: 10s
|
|
67
|
+
retries: 3
|
|
68
|
+
# No published ports: worca reaches the broker on the compose network. In multi mode
|
|
69
|
+
# the key page (8081) is published through cloudflared or a reverse proxy, never 8080.
|
|
70
|
+
security_opt:
|
|
71
|
+
- no-new-privileges:true
|
|
72
|
+
cap_drop:
|
|
73
|
+
- ALL
|
|
74
|
+
pids_limit: 128
|
|
75
|
+
mem_limit: 256m
|
|
76
|
+
restart: unless-stopped
|
|
77
|
+
|
|
78
|
+
volumes:
|
|
79
|
+
broker-data:
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# docker/compose.isolation.yml — agents under their own users, for a worca a TEAM shares
|
|
2
|
+
# (docs/credential-broker.md). Use it with the broker overlay:
|
|
3
|
+
#
|
|
4
|
+
# docker compose -f compose.yml -f compose.broker.yml -f compose.isolation.yml up -d
|
|
5
|
+
#
|
|
6
|
+
# What it changes, and why:
|
|
7
|
+
# - One volume (/data) holds everything, like the single-volume hosts (Railway): the
|
|
8
|
+
# entrypoint starts as root ONLY to prepare it, then drops to the `worca` user.
|
|
9
|
+
# - Agents run as `worca-agent`, and each signed-in person's agents as their own user
|
|
10
|
+
# from the pool (worca-agent-01..16): they can't read worca's settings, database or
|
|
11
|
+
# environment, nor each other's processes (and the broker token in them).
|
|
12
|
+
# - That switch is a `sudo` to those users only (never root), which needs setuid to
|
|
13
|
+
# work: `no-new-privileges` is lifted and exactly the capabilities it needs are kept.
|
|
14
|
+
#
|
|
15
|
+
# Needs Docker Compose 2.24 or newer (the !reset tag). Your repositories live on the
|
|
16
|
+
# volume (/data/projects): clone them from the UI, or copy them in.
|
|
17
|
+
|
|
18
|
+
services:
|
|
19
|
+
worca:
|
|
20
|
+
user: "0:0"
|
|
21
|
+
environment:
|
|
22
|
+
WORCA_DATA_DIR: /data
|
|
23
|
+
volumes:
|
|
24
|
+
- worca-data:/data
|
|
25
|
+
security_opt: !reset []
|
|
26
|
+
cap_drop:
|
|
27
|
+
- ALL
|
|
28
|
+
cap_add:
|
|
29
|
+
- CHOWN # the entrypoint takes the fresh volume
|
|
30
|
+
- DAC_OVERRIDE
|
|
31
|
+
- FOWNER
|
|
32
|
+
- FSETID # the shared folders' setgid bit (without it the kernel silently drops it,
|
|
33
|
+
# new files get worca's own group, and agent users can't read them)
|
|
34
|
+
- SETUID # dropping to worca, and sudo to the agent users
|
|
35
|
+
- SETGID
|
|
36
|
+
- AUDIT_WRITE # sudo's session record
|
|
37
|
+
- KILL # stopping an agent's process group
|
|
38
|
+
|
|
39
|
+
volumes:
|
|
40
|
+
worca-data:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0-rc.2",
|
|
4
4
|
"description": "Worca — deterministic multi-agent pipeline that drives Claude Code (headless) through Plan -> Refine -> Implement -> Review, with a CLI, an installable /worca skill, and a web UI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sinisha Djukic",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"@highlightjs/cdn-assets": "11.12.0",
|
|
66
66
|
"dompurify": "3.4.14",
|
|
67
67
|
"express": "^4.19.2",
|
|
68
|
+
"htmlparser2": "12.0.0",
|
|
68
69
|
"marked": "18.0.10",
|
|
69
70
|
"ws": "^8.18.0"
|
|
70
71
|
},
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// src/broker/config.mjs
|
|
2
|
+
// The credential broker's configuration (plans/credential-broker-design.html §6.2).
|
|
3
|
+
// Pure: reads only the `env` it is given (plus the files its *_FILE variables name),
|
|
4
|
+
// and returns every problem as a list instead of throwing, so the entry point can
|
|
5
|
+
// print them all and exit 78 once.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { normalizeTeamDomain } from '../core/cf-access.mjs';
|
|
8
|
+
|
|
9
|
+
export const MODES = Object.freeze(['single', 'multi']);
|
|
10
|
+
const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,64}$/;
|
|
11
|
+
const DAY_MS = 86_400_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A secret from `NAME_FILE` (preferred: never in the process env) or `NAME`.
|
|
15
|
+
* Trailing whitespace is dropped (files usually end in a newline).
|
|
16
|
+
* @returns {{value: string|null, error: string|null}}
|
|
17
|
+
*/
|
|
18
|
+
export function readSecret(env, name, readFile = readFileSync) {
|
|
19
|
+
const file = String(env[`${name}_FILE`] || '').trim();
|
|
20
|
+
if (file) {
|
|
21
|
+
try {
|
|
22
|
+
const v = String(readFile(file, 'utf8')).trim();
|
|
23
|
+
return v ? { value: v, error: null } : { value: null, error: `${name}_FILE (${file}) is empty` };
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return { value: null, error: `${name}_FILE (${file}) cannot be read: ${err.code || err.message}` };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const v = typeof env[name] === 'string' ? env[name].trim() : '';
|
|
29
|
+
return { value: v || null, error: null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** "48h" | "90m" | "3600s" | "3600000" (ms) -> ms, or null. */
|
|
33
|
+
export function parseDuration(v) {
|
|
34
|
+
const s = String(v ?? '').trim();
|
|
35
|
+
if (!s) return null;
|
|
36
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(s);
|
|
37
|
+
if (!m) return null;
|
|
38
|
+
const n = Number(m[1]);
|
|
39
|
+
const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: DAY_MS }[m[2] || 'ms'];
|
|
40
|
+
return Math.round(n * mult);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parsePort(v, fallback) {
|
|
44
|
+
if (v === undefined || v === '') return fallback;
|
|
45
|
+
const n = Number(v);
|
|
46
|
+
return Number.isInteger(n) && n >= 0 && n < 65536 ? n : NaN; // 0 = an ephemeral port (tests)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseUsd(v) {
|
|
50
|
+
if (v === undefined || v === null || String(v).trim() === '') return null;
|
|
51
|
+
const n = Number(v);
|
|
52
|
+
return Number.isFinite(n) && n >= 0 ? n : NaN;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isLoopbackHost(h) {
|
|
56
|
+
const host = String(h || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
57
|
+
return host === 'localhost' || host === '::1' || /^127\./.test(host);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A 32-byte AES key from base64 (or base64url), or null. */
|
|
61
|
+
export function decodeVaultKey(v) {
|
|
62
|
+
if (!v) return null;
|
|
63
|
+
let buf;
|
|
64
|
+
try { buf = Buffer.from(String(v).trim(), /[-_]/.test(v) ? 'base64url' : 'base64'); } catch { return null; }
|
|
65
|
+
return buf.length === 32 ? buf : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Slot ids whose single-mode key is set: WORCA_BROKER_KEY_<SLOT>[_FILE]. */
|
|
69
|
+
function singleKeys(env, readFile, errors) {
|
|
70
|
+
const out = {};
|
|
71
|
+
const names = new Set();
|
|
72
|
+
for (const k of Object.keys(env)) {
|
|
73
|
+
const m = /^WORCA_BROKER_KEY_([A-Z0-9_]+?)(_FILE)?$/.exec(k);
|
|
74
|
+
if (m) names.add(m[1]);
|
|
75
|
+
}
|
|
76
|
+
for (const n of names) {
|
|
77
|
+
const { value, error } = readSecret(env, `WORCA_BROKER_KEY_${n}`, readFile);
|
|
78
|
+
if (error) errors.push(error);
|
|
79
|
+
if (value) out[n.toLowerCase().replace(/_/g, '-')] = value;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {Record<string,string|undefined>} env
|
|
86
|
+
* @returns {{config: object, errors: string[]}}
|
|
87
|
+
*/
|
|
88
|
+
export function readBrokerConfig(env = process.env, { readFile = readFileSync } = {}) {
|
|
89
|
+
const errors = [];
|
|
90
|
+
const mode = String(env.WORCA_BROKER_MODE || '').trim().toLowerCase();
|
|
91
|
+
if (!MODES.includes(mode)) errors.push(`WORCA_BROKER_MODE must be one of ${MODES.join(', ')} (got ${JSON.stringify(env.WORCA_BROKER_MODE ?? '')})`);
|
|
92
|
+
|
|
93
|
+
const secret = readSecret(env, 'WORCA_BROKER_SECRET', readFile);
|
|
94
|
+
if (secret.error) errors.push(secret.error);
|
|
95
|
+
else if (!secret.value) errors.push('WORCA_BROKER_SECRET (or WORCA_BROKER_SECRET_FILE) is required: the secret worca uses to ask for spawn tokens');
|
|
96
|
+
else if (secret.value.length < 32) errors.push('WORCA_BROKER_SECRET is too short: use at least 32 characters (`worca broker secrets` prints one)');
|
|
97
|
+
|
|
98
|
+
const vk = readSecret(env, 'WORCA_BROKER_VAULT_KEY', readFile);
|
|
99
|
+
if (vk.error) errors.push(vk.error);
|
|
100
|
+
const vaultKey = decodeVaultKey(vk.value);
|
|
101
|
+
if (vk.value && !vaultKey) errors.push('WORCA_BROKER_VAULT_KEY must be 32 bytes, base64-encoded (`worca broker secrets` prints one)');
|
|
102
|
+
if (mode === 'multi' && !vk.value) errors.push('WORCA_BROKER_VAULT_KEY is required in multi mode: it encrypts people\'s keys at rest');
|
|
103
|
+
const vko = readSecret(env, 'WORCA_BROKER_VAULT_KEY_OLD', readFile);
|
|
104
|
+
if (vko.error) errors.push(vko.error);
|
|
105
|
+
const vaultKeyOld = decodeVaultKey(vko.value);
|
|
106
|
+
if (vko.value && !vaultKeyOld) errors.push('WORCA_BROKER_VAULT_KEY_OLD must be 32 bytes, base64-encoded');
|
|
107
|
+
|
|
108
|
+
const host = String(env.WORCA_BROKER_HOST || '0.0.0.0').trim();
|
|
109
|
+
const port = parsePort(env.WORCA_BROKER_PORT, 8080);
|
|
110
|
+
const uiPort = parsePort(env.WORCA_BROKER_UI_PORT, 8081);
|
|
111
|
+
if (Number.isNaN(port)) errors.push('WORCA_BROKER_PORT must be a port number');
|
|
112
|
+
if (Number.isNaN(uiPort)) errors.push('WORCA_BROKER_UI_PORT must be a port number');
|
|
113
|
+
if (port === uiPort && port !== 0) errors.push('WORCA_BROKER_PORT and WORCA_BROKER_UI_PORT must differ: the key page port is the only one a public route may point at');
|
|
114
|
+
|
|
115
|
+
let publicUrl = null;
|
|
116
|
+
const pu = String(env.WORCA_BROKER_PUBLIC_URL || '').trim();
|
|
117
|
+
if (pu) {
|
|
118
|
+
try {
|
|
119
|
+
const u = new URL(pu);
|
|
120
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && isLoopbackHost(u.hostname))) {
|
|
121
|
+
errors.push('WORCA_BROKER_PUBLIC_URL must be https (http only for localhost)');
|
|
122
|
+
} else if (u.pathname !== '/' || u.search || u.hash || u.username || u.password) {
|
|
123
|
+
errors.push('WORCA_BROKER_PUBLIC_URL must be an origin only, e.g. https://worca-01-keys.example.com');
|
|
124
|
+
} else publicUrl = u.origin;
|
|
125
|
+
} catch { errors.push('WORCA_BROKER_PUBLIC_URL is not a URL'); }
|
|
126
|
+
}
|
|
127
|
+
let returnUrl = null;
|
|
128
|
+
const ru = String(env.WORCA_BROKER_RETURN_URL || '').trim();
|
|
129
|
+
if (ru) {
|
|
130
|
+
try { const u = new URL(ru); if (u.protocol === 'https:' || u.protocol === 'http:') returnUrl = u.href; else throw new Error(); }
|
|
131
|
+
catch { errors.push('WORCA_BROKER_RETURN_URL must be an http(s) URL'); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const teamDomain = normalizeTeamDomain(env.WORCA_CF_ACCESS_TEAM_DOMAIN);
|
|
135
|
+
const aud = String(env.WORCA_CF_ACCESS_AUD || '').trim();
|
|
136
|
+
const identityHeader = String(env.WORCA_IDENTITY_HEADER || '').trim();
|
|
137
|
+
let identity = null;
|
|
138
|
+
if (teamDomain || aud) {
|
|
139
|
+
if (!teamDomain || !aud) errors.push('set both WORCA_CF_ACCESS_TEAM_DOMAIN and WORCA_CF_ACCESS_AUD (the AUD of the key page\'s own Access application)');
|
|
140
|
+
else identity = { kind: 'access', teamDomain, aud };
|
|
141
|
+
} else if (identityHeader) {
|
|
142
|
+
if (!HEADER_NAME_RE.test(identityHeader)) errors.push('WORCA_IDENTITY_HEADER is not a valid header name');
|
|
143
|
+
else identity = { kind: 'header', header: identityHeader.toLowerCase() };
|
|
144
|
+
}
|
|
145
|
+
if (mode === 'multi') {
|
|
146
|
+
if (!publicUrl) errors.push('WORCA_BROKER_PUBLIC_URL is required in multi mode: the address of the key page');
|
|
147
|
+
if (!identity) errors.push('multi mode needs an identity check for the key page: WORCA_CF_ACCESS_TEAM_DOMAIN + WORCA_CF_ACCESS_AUD, or WORCA_IDENTITY_HEADER');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const defaultDailyUsd = parseUsd(env.WORCA_BROKER_DEFAULT_DAILY_USD);
|
|
151
|
+
const defaultMonthlyUsd = parseUsd(env.WORCA_BROKER_DEFAULT_MONTHLY_USD);
|
|
152
|
+
if (Number.isNaN(defaultDailyUsd)) errors.push('WORCA_BROKER_DEFAULT_DAILY_USD must be a number >= 0');
|
|
153
|
+
if (Number.isNaN(defaultMonthlyUsd)) errors.push('WORCA_BROKER_DEFAULT_MONTHLY_USD must be a number >= 0');
|
|
154
|
+
|
|
155
|
+
let tokenMaxTtlMs = 48 * 3_600_000;
|
|
156
|
+
if (env.WORCA_BROKER_TOKEN_MAX_TTL) {
|
|
157
|
+
const t = parseDuration(env.WORCA_BROKER_TOKEN_MAX_TTL);
|
|
158
|
+
if (!t || t < 60_000) errors.push('WORCA_BROKER_TOKEN_MAX_TTL must be a duration of at least 1m, e.g. 48h');
|
|
159
|
+
else tokenMaxTtlMs = t;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// "Push as me" (optional): each person signs in with GitHub on the key page through the
|
|
163
|
+
// operator's GitHub App or OAuth App; worca's pushes and PRs then go out as them.
|
|
164
|
+
const githubClientId = String(env.WORCA_BROKER_GITHUB_CLIENT_ID || '').trim() || null;
|
|
165
|
+
const ghSecret = readSecret(env, 'WORCA_BROKER_GITHUB_CLIENT_SECRET', readFile);
|
|
166
|
+
if (ghSecret.error) errors.push(ghSecret.error);
|
|
167
|
+
if (ghSecret.value && !githubClientId) errors.push('WORCA_BROKER_GITHUB_CLIENT_SECRET needs WORCA_BROKER_GITHUB_CLIENT_ID');
|
|
168
|
+
if (githubClientId && !/^[A-Za-z0-9._-]{8,64}$/.test(githubClientId)) errors.push('WORCA_BROKER_GITHUB_CLIENT_ID does not look like a GitHub client ID');
|
|
169
|
+
|
|
170
|
+
const keys = singleKeys(env, readFile, errors);
|
|
171
|
+
const dataDir = String(env.WORCA_BROKER_DATA_DIR || '').trim() || null;
|
|
172
|
+
|
|
173
|
+
const config = Object.freeze({
|
|
174
|
+
mode: MODES.includes(mode) ? mode : null,
|
|
175
|
+
secret: secret.value,
|
|
176
|
+
vaultKey,
|
|
177
|
+
vaultKeyOld,
|
|
178
|
+
dataDir,
|
|
179
|
+
host,
|
|
180
|
+
port,
|
|
181
|
+
uiPort,
|
|
182
|
+
uiEnabled: mode === 'multi',
|
|
183
|
+
publicUrl,
|
|
184
|
+
returnUrl,
|
|
185
|
+
identity,
|
|
186
|
+
slotsFile: String(env.WORCA_BROKER_SLOTS_FILE || '').trim() || null,
|
|
187
|
+
localUrl: String(env.WORCA_BROKER_LOCAL_URL || '').trim() || null,
|
|
188
|
+
github: githubClientId ? Object.freeze({
|
|
189
|
+
clientId: githubClientId,
|
|
190
|
+
clientSecret: ghSecret.value || null,
|
|
191
|
+
scope: String(env.WORCA_BROKER_GITHUB_SCOPES ?? 'repo').trim(),
|
|
192
|
+
}) : null,
|
|
193
|
+
singleKeys: Object.freeze(keys),
|
|
194
|
+
allowTeamKeys: /^(1|true|yes|on)$/i.test(String(env.WORCA_BROKER_ALLOW_TEAM_KEYS || '')),
|
|
195
|
+
defaultDailyUsd: Number.isNaN(defaultDailyUsd) ? null : defaultDailyUsd,
|
|
196
|
+
defaultMonthlyUsd: Number.isNaN(defaultMonthlyUsd) ? null : defaultMonthlyUsd,
|
|
197
|
+
tokenMaxTtlMs,
|
|
198
|
+
});
|
|
199
|
+
return { config, errors };
|
|
200
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// src/broker/copilot.mjs
|
|
2
|
+
// GitHub Copilot inside the credential broker (plans/credential-broker-design.html §5.3).
|
|
3
|
+
// A person signs in on the key page with GitHub's device flow; the broker keeps their
|
|
4
|
+
// GitHub token sealed in the vault and, per request, exchanges it for Copilot's
|
|
5
|
+
// short-lived API token (cached until shortly before it expires). worca never sees
|
|
6
|
+
// either token. The editor headers and endpoints are the ones worca's own Copilot
|
|
7
|
+
// provider uses (src/core/bridge/providers/copilot.mjs), imported from there.
|
|
8
|
+
import { githubHeaders, GITHUB_API, GITHUB_BASE, GITHUB_CLIENT_ID, GITHUB_SCOPES } from '../core/bridge/providers/copilot.mjs';
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_EXCHANGE_URL = `${GITHUB_API}/copilot_internal/v2/token`;
|
|
11
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
/** A Copilot API host must be GitHub's (the exchange response names it; never trust anything else). */
|
|
14
|
+
export function isCopilotHost(origin) {
|
|
15
|
+
try {
|
|
16
|
+
const u = new URL(origin);
|
|
17
|
+
return u.protocol === 'https:' && (u.hostname === 'githubcopilot.com' || u.hostname.endsWith('.githubcopilot.com'));
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The exchange, cached per GitHub token.
|
|
23
|
+
* @param {{fetchImpl?:typeof fetch, exchangeUrl?:string, now?:()=>number, defaultHost?:string, allowHost?:(o:string)=>boolean}} o
|
|
24
|
+
*/
|
|
25
|
+
export function createCopilotExchange({ fetchImpl = globalThis.fetch, exchangeUrl = DEFAULT_EXCHANGE_URL, now = Date.now, defaultHost = 'https://api.githubcopilot.com', allowHost = isCopilotHost } = {}) {
|
|
26
|
+
const cache = new Map(); // github token -> {token, host, expiresAt}
|
|
27
|
+
return {
|
|
28
|
+
/** {token, host} or throws with .status (401/403 = the GitHub sign-in no longer works). */
|
|
29
|
+
async token(githubToken, { force = false } = {}) {
|
|
30
|
+
const hit = cache.get(githubToken);
|
|
31
|
+
if (!force && hit && hit.expiresAt - REFRESH_MARGIN_MS > now()) return hit;
|
|
32
|
+
const res = await fetchImpl(exchangeUrl, { headers: githubHeaders(githubToken) });
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
cache.delete(githubToken);
|
|
35
|
+
const err = new Error(res.status === 401 || res.status === 403
|
|
36
|
+
? `GitHub refused the sign-in (${res.status}): sign in to Copilot again`
|
|
37
|
+
: `the Copilot token exchange failed (HTTP ${res.status})`);
|
|
38
|
+
err.status = res.status;
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
const j = await res.json();
|
|
42
|
+
if (!j || typeof j.token !== 'string') throw new Error('the Copilot token exchange returned no token');
|
|
43
|
+
let host = defaultHost;
|
|
44
|
+
const api = j.endpoints && typeof j.endpoints.api === 'string' ? j.endpoints.api.replace(/\/+$/, '') : null;
|
|
45
|
+
if (api && allowHost(api)) host = new URL(api).origin;
|
|
46
|
+
const expiresAt = Number.isFinite(j.expires_at) ? j.expires_at * 1000 : now() + 25 * 60_000;
|
|
47
|
+
const v = { token: j.token, host, expiresAt };
|
|
48
|
+
cache.set(githubToken, v);
|
|
49
|
+
return v;
|
|
50
|
+
},
|
|
51
|
+
invalidate(githubToken) { cache.delete(githubToken); },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* GitHub's device flow, step 1: a code for the person to enter at github.com/login/device.
|
|
57
|
+
* `clientId`/`scope`: Copilot's own app by default; "push as me" passes the operator's
|
|
58
|
+
* GitHub App or OAuth App (WORCA_BROKER_GITHUB_CLIENT_ID).
|
|
59
|
+
*/
|
|
60
|
+
export async function startDeviceFlow({ fetchImpl = globalThis.fetch, baseUrl = GITHUB_BASE, clientId = GITHUB_CLIENT_ID, scope = GITHUB_SCOPES } = {}) {
|
|
61
|
+
const res = await fetchImpl(`${baseUrl}/login/device/code`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ client_id: clientId, ...(scope ? { scope } : {}) }),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) throw new Error(`GitHub device sign-in failed (HTTP ${res.status})`);
|
|
67
|
+
const j = await res.json();
|
|
68
|
+
if (!j.device_code || !j.user_code) throw new Error('GitHub returned no device code');
|
|
69
|
+
return { deviceCode: j.device_code, userCode: j.user_code, verificationUri: j.verification_uri || 'https://github.com/login/device', interval: Number(j.interval) || 5, expiresIn: Number(j.expires_in) || 900 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Step 2, polled: {token, refreshToken?, expiresIn?} once the person approved (a GitHub App
|
|
74
|
+
* with expiring user tokens also returns a refresh token), {pending:true} before, {error}.
|
|
75
|
+
*/
|
|
76
|
+
export async function pollDeviceFlow(deviceCode, { fetchImpl = globalThis.fetch, baseUrl = GITHUB_BASE, clientId = GITHUB_CLIENT_ID } = {}) {
|
|
77
|
+
const res = await fetchImpl(`${baseUrl}/login/oauth/access_token`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ client_id: clientId, device_code: deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }),
|
|
81
|
+
});
|
|
82
|
+
const j = await res.json().catch(() => ({}));
|
|
83
|
+
if (j.access_token) {
|
|
84
|
+
return {
|
|
85
|
+
token: j.access_token,
|
|
86
|
+
...(j.refresh_token ? { refreshToken: j.refresh_token } : {}),
|
|
87
|
+
...(Number.isFinite(j.expires_in) ? { expiresIn: j.expires_in } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (j.error === 'authorization_pending' || j.error === 'slow_down') return { pending: true, slowDown: j.error === 'slow_down' };
|
|
91
|
+
return { error: j.error_description || j.error || `HTTP ${res.status}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── "Push as me": a person's GitHub user token (credential broker, docs/credential-broker.md)
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A stored GitHub credential: a plain token (a pasted fine-grained token, an OAuth App
|
|
98
|
+
* token that doesn't expire) or `{"t":…,"r":…,"e":…}` for a GitHub App user token with
|
|
99
|
+
* its refresh token and expiry (ms). Pure.
|
|
100
|
+
*/
|
|
101
|
+
export function parseGithubSecret(secret) {
|
|
102
|
+
const s = String(secret || '');
|
|
103
|
+
if (s.startsWith('{')) {
|
|
104
|
+
try { const j = JSON.parse(s); if (typeof j.t === 'string') return { token: j.t, refreshToken: j.r || null, expiresAt: Number(j.e) || null }; } catch { /* plain */ }
|
|
105
|
+
}
|
|
106
|
+
return { token: s, refreshToken: null, expiresAt: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function githubSecretOf({ token, refreshToken = null, expiresIn = null }, now = Date.now()) {
|
|
110
|
+
return refreshToken ? JSON.stringify({ t: token, r: refreshToken, e: expiresIn ? now + expiresIn * 1000 : null }) : token;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Refresh a GitHub App user token. Returns the new stored secret, or throws. */
|
|
114
|
+
export async function refreshGithubToken({ refreshToken, clientId, clientSecret, fetchImpl = globalThis.fetch, baseUrl = GITHUB_BASE, now = Date.now() }) {
|
|
115
|
+
if (!clientSecret) throw new Error('the GitHub sign-in expired and the broker has no client secret to renew it: sign in again');
|
|
116
|
+
const res = await fetchImpl(`${baseUrl}/login/oauth/access_token`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
119
|
+
body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, grant_type: 'refresh_token', refresh_token: refreshToken }),
|
|
120
|
+
});
|
|
121
|
+
const j = await res.json().catch(() => ({}));
|
|
122
|
+
if (!j.access_token) throw new Error(`GitHub would not renew the sign-in (${j.error || `HTTP ${res.status}`}): sign in again`);
|
|
123
|
+
return githubSecretOf({ token: j.access_token, refreshToken: j.refresh_token || refreshToken, expiresIn: j.expires_in }, now);
|
|
124
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/broker/limits.mjs
|
|
2
|
+
// Spend and load limits (plans/credential-broker-design.html §6.4 step 4): the
|
|
3
|
+
// worst a rogue agent can do is burn credits, and these cap how many. In memory:
|
|
4
|
+
// concurrency per token and per person+slot, requests per minute per person+slot.
|
|
5
|
+
// Budgets read the usage table (cached briefly) against the person's own cap, the
|
|
6
|
+
// operator default, and the token's own budget.
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
const SPEND_CACHE_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
export function startOfUtcDay(now) { const d = new Date(now); d.setUTCHours(0, 0, 0, 0); return d.getTime(); }
|
|
12
|
+
export function startOfUtcMonth(now) { const d = new Date(now); d.setUTCDate(1); d.setUTCHours(0, 0, 0, 0); return d.getTime(); }
|
|
13
|
+
|
|
14
|
+
export function createLimits({ store, config, now = Date.now }) {
|
|
15
|
+
const active = new Map(); // key -> count
|
|
16
|
+
const windows = new Map(); // person|slot -> [timestamps]
|
|
17
|
+
const spendCache = new Map(); // person|slot|period -> {at, usd}
|
|
18
|
+
|
|
19
|
+
const inc = (k) => active.set(k, (active.get(k) || 0) + 1);
|
|
20
|
+
const dec = (k) => { const n = (active.get(k) || 1) - 1; if (n <= 0) active.delete(k); else active.set(k, n); };
|
|
21
|
+
|
|
22
|
+
function spent(billTo, slot, period) {
|
|
23
|
+
const key = `${billTo}|${slot}|${period}`;
|
|
24
|
+
const t = now();
|
|
25
|
+
const hit = spendCache.get(key);
|
|
26
|
+
if (hit && t - hit.at < SPEND_CACHE_MS) return hit.usd;
|
|
27
|
+
const since = period === 'day' ? startOfUtcDay(t) : startOfUtcMonth(t);
|
|
28
|
+
const usd = store.spentSince(billTo, slot, since);
|
|
29
|
+
spendCache.set(key, { at: t, usd });
|
|
30
|
+
return usd;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
/**
|
|
35
|
+
* Admit one request, or say why not. On admission the caller MUST call release().
|
|
36
|
+
* @returns {{release: () => void} | {status:number, kind:'rate'|'budget', message:string, retryAfter?:number}}
|
|
37
|
+
*/
|
|
38
|
+
acquire({ tokenRow, slot, credentialRow = null }) {
|
|
39
|
+
const person = tokenRow.bill_to;
|
|
40
|
+
const lim = slot.limits || {};
|
|
41
|
+
const perToken = Number(lim.concurrencyPerToken) || 8;
|
|
42
|
+
const perPerson = Number(lim.concurrencyPerPerson) || 16;
|
|
43
|
+
const rpm = Number(lim.rpmPerPerson) || 240;
|
|
44
|
+
|
|
45
|
+
// Budgets first: a spent budget is not cleared by waiting.
|
|
46
|
+
if (tokenRow.budget_usd != null && tokenRow.spent_usd >= tokenRow.budget_usd) {
|
|
47
|
+
return { status: 403, kind: 'budget', message: `quota reached: this spawn's budget of $${fmt(tokenRow.budget_usd)} is spent` };
|
|
48
|
+
}
|
|
49
|
+
const daily = credentialRow?.daily_usd ?? config.defaultDailyUsd;
|
|
50
|
+
if (daily != null && spent(person, slot.id, 'day') >= daily) {
|
|
51
|
+
return { status: 403, kind: 'budget', message: `quota reached: daily budget of $${fmt(daily)} for ${person} on ${slot.id}` };
|
|
52
|
+
}
|
|
53
|
+
const monthly = credentialRow?.monthly_usd ?? config.defaultMonthlyUsd;
|
|
54
|
+
if (monthly != null && spent(person, slot.id, 'month') >= monthly) {
|
|
55
|
+
return { status: 403, kind: 'budget', message: `quota reached: monthly budget of $${fmt(monthly)} for ${person} on ${slot.id}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const tk = `t:${tokenRow.hash}`;
|
|
59
|
+
const pk = `p:${person}|${slot.id}`;
|
|
60
|
+
if ((active.get(tk) || 0) >= perToken || (active.get(pk) || 0) >= perPerson) {
|
|
61
|
+
return { status: 429, kind: 'rate', message: 'too many concurrent requests, retry after 2 seconds', retryAfter: 2 };
|
|
62
|
+
}
|
|
63
|
+
const t = now();
|
|
64
|
+
const w = (windows.get(pk) || []).filter((x) => t - x < 60_000);
|
|
65
|
+
if (w.length >= rpm) {
|
|
66
|
+
const wait = Math.max(1, Math.ceil((60_000 - (t - w[0])) / 1000));
|
|
67
|
+
windows.set(pk, w);
|
|
68
|
+
return { status: 429, kind: 'rate', message: `too many requests per minute, retry after ${wait} seconds`, retryAfter: wait };
|
|
69
|
+
}
|
|
70
|
+
w.push(t);
|
|
71
|
+
windows.set(pk, w);
|
|
72
|
+
inc(tk); inc(pk);
|
|
73
|
+
let released = false;
|
|
74
|
+
return { release() { if (released) return; released = true; dec(tk); dec(pk); } };
|
|
75
|
+
},
|
|
76
|
+
/** Forget cached spend for a person (after a usage row lands, budgets see it within 10 s anyway). */
|
|
77
|
+
invalidate(billTo, slot) {
|
|
78
|
+
for (const p of ['day', 'month']) spendCache.delete(`${billTo}|${slot}|${p}`);
|
|
79
|
+
},
|
|
80
|
+
_active: active,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fmt(n) {
|
|
85
|
+
return Number(n).toFixed(2).replace(/\.00$/, '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { DAY_MS };
|