voroa-cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -2
  3. package/package.json +41 -4
  4. package/src/api.js +552 -0
  5. package/src/commands/db.js +99 -0
  6. package/src/commands/deploy.js +104 -0
  7. package/src/commands/domains.js +122 -0
  8. package/src/commands/env.js +143 -0
  9. package/src/commands/lifecycle.js +40 -0
  10. package/src/commands/link.js +92 -0
  11. package/src/commands/login.js +104 -0
  12. package/src/commands/logs.js +285 -0
  13. package/src/commands/services.js +72 -0
  14. package/src/commands/ssh.js +46 -0
  15. package/src/commands/status.js +36 -0
  16. package/src/commands/tokens.js +81 -0
  17. package/src/commands/workspace.js +63 -0
  18. package/src/config.js +108 -0
  19. package/src/errors.js +10 -0
  20. package/src/flags.js +32 -0
  21. package/src/index.js +190 -0
  22. package/src/interactive.js +28 -0
  23. package/src/link.js +72 -0
  24. package/src/mcp/build.js +72 -0
  25. package/src/mcp/kernel/index.js +59 -0
  26. package/src/mcp/kernel/logger.js +28 -0
  27. package/src/mcp/lib/config.js +72 -0
  28. package/src/mcp/lib/credentials.js +51 -0
  29. package/src/mcp/lib/http.js +136 -0
  30. package/src/mcp/lib/response.js +14 -0
  31. package/src/mcp/lib/reversibility.js +33 -0
  32. package/src/mcp/reversibilityMap.json +77 -0
  33. package/src/mcp/tools/_action.js +49 -0
  34. package/src/mcp/tools/_ids.js +42 -0
  35. package/src/mcp/tools/_shape.js +318 -0
  36. package/src/mcp/tools/actions.js +94 -0
  37. package/src/mcp/tools/getBuildLog.js +39 -0
  38. package/src/mcp/tools/getDeployHistory.js +42 -0
  39. package/src/mcp/tools/getRuntimeLogs.js +44 -0
  40. package/src/mcp/tools/getServiceStatus.js +41 -0
  41. package/src/mcp/tools/getUsage.js +34 -0
  42. package/src/mcp/tools/listDatabases.js +25 -0
  43. package/src/mcp/tools/listDomains.js +33 -0
  44. package/src/mcp/tools/listServices.js +20 -0
  45. package/src/mcp/tools/propose.js +124 -0
  46. package/src/mcp/tools/reads.js +756 -0
  47. package/src/mcp/tools/writes.js +632 -0
  48. package/src/mcpTools.js +265 -0
  49. package/src/output.js +56 -0
  50. package/src/prompt.js +127 -0
  51. package/src/region.js +5 -0
  52. package/src/resolve.js +64 -0
  53. package/src/status.js +178 -0
  54. package/src/stepup.js +69 -0
@@ -0,0 +1,72 @@
1
+ // Config comes from ENV first, then this device's stored credential file. It is never hardcoded (no
2
+ // default host lives in code) and never printed (golden rule #13).
3
+ // VOROA_MCP_URL - the Voroa API base (e.g. https://api.getvoroa.com)
4
+ // VOROA_MCP_TOKEN - the scoped token minted in the Voroa dashboard (secret)
5
+ //
6
+ // PRECEDENCE: env -> stored credentials file (~/.voroa/customer-mcp/credentials.json) -> unconfigured.
7
+ // ENV WINS, deliberately: this is a CLIENT credential, so a stale local file must never silently shadow
8
+ // an explicitly-set env var. (Rule #11's "admin DB -> env -> default" governs SERVER settings and does
9
+ // not transfer here.)
10
+ //
11
+ // The token is a SECRET: it is loaded here and handed only to the http helper's Authorization header. It
12
+ // is never logged, never returned in a tool response, never put in an error message.
13
+
14
+ import { readCredentials } from './credentials.js';
15
+
16
+ export function loadConfig(env = process.env, { readStored = readCredentials } = {}) {
17
+ // strip trailing slashes so path-joins stay clean
18
+ const envUrl = String(env.VOROA_MCP_URL || '').replace(/\/+$/, '');
19
+ const envToken = String(env.VOROA_MCP_TOKEN || '');
20
+ if (envUrl && envToken) return { baseUrl: envUrl, token: envToken, source: 'env' };
21
+
22
+ const stored = readStored(env) || {};
23
+ return {
24
+ baseUrl: envUrl || stored.baseUrl || '',
25
+ token: envToken || stored.token || '',
26
+ source: envToken ? 'env' : stored.token ? 'file' : 'none',
27
+ };
28
+ }
29
+
30
+ // isSecureBaseUrl - https, or plain http ONLY to the loopback interface (local development against a
31
+ // backend on this machine). Same discipline `scripts/lib/report-job.sh` applies server-side before a
32
+ // destructive token goes on the wire, and for the identical reason: every request this client makes
33
+ // carries a bearer in a header, so one mistyped `http://` scheme sends a live credential in cleartext
34
+ // across whatever network is between the laptop and the API. A typo must fail loudly here rather than
35
+ // succeed quietly on the wire.
36
+ export function isSecureBaseUrl(value) {
37
+ let url;
38
+ try {
39
+ url = new URL(String(value || ''));
40
+ } catch {
41
+ return false;
42
+ }
43
+ if (url.protocol === 'https:') return true;
44
+ if (url.protocol !== 'http:') return false;
45
+ const h = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
46
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1';
47
+ }
48
+
49
+ // Called at request time (not at boot) so `list_tools` works unconfigured for discovery, but any actual
50
+ // API call fails with a clean, secret-free message that tells the human exactly what to do.
51
+ export function assertConfigured(cfg) {
52
+ if (!cfg.baseUrl) {
53
+ const err = new Error('Not connected: set VOROA_MCP_URL to your Voroa API address.');
54
+ err.code = 'NOT_CONFIGURED';
55
+ throw err;
56
+ }
57
+ if (!isSecureBaseUrl(cfg.baseUrl)) {
58
+ const err = new Error(
59
+ 'Refusing to connect: VOROA_MCP_URL must start with https:// (http:// is allowed only for localhost). '
60
+ + 'Your token travels on every request and must not be sent unencrypted.'
61
+ );
62
+ err.code = 'INSECURE_URL';
63
+ throw err;
64
+ }
65
+ if (!cfg.token) {
66
+ const err = new Error(
67
+ 'Not connected: set VOROA_MCP_TOKEN to a token from your Voroa dashboard (Settings -> API tokens).'
68
+ );
69
+ err.code = 'NOT_CONFIGURED';
70
+ throw err;
71
+ }
72
+ }
@@ -0,0 +1,51 @@
1
+ // The stored-credential file for this device: ~/.voroa/customer-mcp/credentials.json holding
2
+ // { baseUrl, token }. Read by config.js as the fallback when env is unset.
3
+ //
4
+ // SECURITY (golden rule #13): this file holds the scoped bearer. Its VALUE is never logged, never echoed,
5
+ // never returned in a tool response - it is read straight into the Authorization header by lib/http.js.
6
+ //
7
+ // FILE PERMISSIONS - be honest about what protects it:
8
+ // * POSIX (macOS/Linux): chmod 0600 is attempted best-effort and IS a real control (owner-only).
9
+ // * Windows: chmod is a NO-OP. NTFS has no POSIX permission bits, so 0600 means NOTHING there. What
10
+ // actually protects the file on Windows is that it sits under the user's own profile
11
+ // (%USERPROFILE%\.voroa\customer-mcp\), which the default ACL restricts to that account. Do not claim
12
+ // 0600 as a control on Windows.
13
+
14
+ import { homedir } from 'node:os';
15
+ import { join, dirname } from 'node:path';
16
+ import { readFileSync } from 'node:fs';
17
+ import { mkdir, writeFile, chmod } from 'node:fs/promises';
18
+
19
+ // VOROA_CUSTOMER_MCP_HOME overrides the home dir (tests + odd profile paths). It is a PATH, never a secret.
20
+ export function credentialsPath(env = process.env) {
21
+ const home = env.VOROA_CUSTOMER_MCP_HOME || homedir();
22
+ return join(home, '.voroa', 'customer-mcp', 'credentials.json');
23
+ }
24
+
25
+ // Sync + tolerant: any problem (absent file, bad JSON, wrong shape) reads as "no stored credential", never
26
+ // throws - an unreadable file must degrade to "unconfigured", not break every tool call.
27
+ export function readCredentials(env = process.env) {
28
+ try {
29
+ const parsed = JSON.parse(readFileSync(credentialsPath(env), 'utf8'));
30
+ const baseUrl = String(parsed.baseUrl || '').replace(/\/+$/, '');
31
+ const token = String(parsed.token || '');
32
+ if (!baseUrl || !token) return null;
33
+ return { baseUrl, token };
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export async function writeCredentials({ baseUrl, token }, env = process.env) {
40
+ const path = credentialsPath(env);
41
+ await mkdir(dirname(path), { recursive: true });
42
+ await writeFile(path, JSON.stringify({ baseUrl, token }, null, 2) + '\n', { mode: 0o600 });
43
+ if (process.platform !== 'win32') {
44
+ try {
45
+ await chmod(path, 0o600);
46
+ } catch {
47
+ // non-fatal: the write succeeded, only the tighten failed
48
+ }
49
+ }
50
+ return path;
51
+ }
@@ -0,0 +1,136 @@
1
+ // The ONE HTTP seam to the customer token API (`/api/ext/customer/*`, ADR-0060). This package holds NO DB
2
+ // access and imports NO backend code - every read and every action goes over HTTP with the scoped bearer,
3
+ // so tenant isolation, the capability allow-list and the reversibility gate all stay server-side where
4
+ // they cannot be edited by whoever holds the laptop.
5
+ //
6
+ // SECURITY (golden rule #13): the Authorization header carries the secret token. It is attached here and
7
+ // NOWHERE logged, echoed, or surfaced in an error. Log lines carry method + path only - no headers, no
8
+ // token, no query VALUES (a service id in a query string is the customer's own, but the habit of logging
9
+ // query values is how a token in a query string gets logged one refactor later).
10
+ //
11
+ // WHITE-LABEL (golden rule 6): cleanError() below surfaces the API's own error string, which is generated
12
+ // by the backend's central error handler and is already generic by contract. It NEVER includes the URL,
13
+ // the host, or the response body of a non-JSON error.
14
+
15
+ import { assertConfigured } from './config.js';
16
+
17
+ const API_PREFIX = '/api/ext/customer';
18
+
19
+ // A hung backend must not hang the customer's AI client. Without a deadline a tool call waits on the
20
+ // socket forever and the conversation simply stops with no explanation - the worst failure mode available,
21
+ // because the human cannot tell it apart from the model thinking. 30s is comfortably above the slowest
22
+ // call on this surface (a build-log read that assembles phases) and far below a human's patience.
23
+ const DEFAULT_TIMEOUT_MS = 30_000;
24
+
25
+ export function createHttpClient({ getConfig, logger, fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
26
+ const doFetch = fetchImpl || globalThis.fetch;
27
+ if (typeof doFetch !== 'function') {
28
+ throw new Error('no fetch available (need Node >=20 or an injected fetchImpl)');
29
+ }
30
+
31
+ async function call(method, path, { query, body } = {}) {
32
+ const cfg = getConfig();
33
+ assertConfigured(cfg);
34
+
35
+ const url = new URL(cfg.baseUrl + API_PREFIX + path);
36
+ if (query) {
37
+ for (const [k, v] of Object.entries(query)) {
38
+ if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
39
+ }
40
+ }
41
+
42
+ logger.info({ method, path }, 'voroa_api_call'); // path only - never headers/token/query values
43
+
44
+ const headers = {
45
+ Authorization: `Bearer ${cfg.token}`,
46
+ Accept: 'application/json',
47
+ };
48
+ let payload;
49
+ if (body !== undefined) {
50
+ headers['Content-Type'] = 'application/json';
51
+ payload = JSON.stringify(body);
52
+ }
53
+
54
+ // AbortController rather than Promise.race: the race leaves the socket open and the response body
55
+ // unread, so a slow backend would still accumulate connections behind the scenes.
56
+ const controller = new AbortController();
57
+ // Deliberately NOT unref'd: the timer lives only while a request is in flight (the finally below
58
+ // clears it on every settled request), and that is exactly when the process must stay alive to
59
+ // deliver the timeout error. An unref'd timer let the event loop drain before it fired - the abort
60
+ // then never happened, which surfaced on CI (Node 20) as a hung-promise cancellation and would
61
+ // surface in a one-shot caller as a silent exit instead of the promised clean timeout.
62
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
63
+
64
+ let res;
65
+ try {
66
+ res = await doFetch(url, { method, headers, body: payload, signal: controller.signal });
67
+ } catch (err) {
68
+ // Timeout and network failure are DIFFERENT answers and are reported differently: "it did not
69
+ // respond in time" tells the caller to retry, "it is unreachable" tells them to check the address.
70
+ // Collapsing them is the bug #39 shape - two causes that look identical to whoever must act.
71
+ if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
72
+ const e = new Error(`Voroa did not respond within ${Math.round(timeoutMs / 1000)} seconds. Try again.`);
73
+ e.code = 'UPSTREAM_TIMEOUT';
74
+ throw e;
75
+ }
76
+ // network/DNS failure - a generic message, never the URL or the token
77
+ const e = new Error('Voroa is unreachable from this machine.');
78
+ e.code = 'UPSTREAM_UNREACHABLE';
79
+ throw e;
80
+ } finally {
81
+ clearTimeout(timer);
82
+ }
83
+
84
+ const text = await res.text();
85
+ let data = {};
86
+ if (text) {
87
+ try {
88
+ data = JSON.parse(text);
89
+ } catch {
90
+ // A non-JSON body is NOT surfaced. It could be a proxy error page carrying an infra hostname
91
+ // (golden rule 6) - so it is dropped entirely and only the status travels.
92
+ data = {};
93
+ }
94
+ }
95
+
96
+ if (!res.ok) {
97
+ const e = new Error(cleanError(res.status, data));
98
+ e.code = 'UPSTREAM_ERROR';
99
+ e.status = res.status;
100
+ throw e;
101
+ }
102
+ return data;
103
+ }
104
+
105
+ return {
106
+ get: (path, query) => call('GET', path, { query }),
107
+ post: (path, body) => call('POST', path, { body }),
108
+ // PUT and PATCH arrived with the Phase C configuration tools. Same `call` - the bearer, the timeout,
109
+ // the redaction and the error cleaning are all one path, so a new verb cannot acquire its own posture.
110
+ put: (path, body) => call('PUT', path, { body }),
111
+ patch: (path, body) => call('PATCH', path, { body }),
112
+ };
113
+ }
114
+
115
+ // Surface only a status + the API's own clean error string (the API returns generic JSON errors by
116
+ // contract - golden rule #3). Never the token, never the full URL/host.
117
+ //
118
+ // 404 gets its own sentence deliberately: on this surface a 404 means EITHER the feature is not enabled
119
+ // for this account OR the capability is not on the customer surface at all. The API cannot tell those
120
+ // apart without becoming an oracle (ADR-0060 D7), so the client says both rather than guessing one.
121
+ function cleanError(status, data) {
122
+ const detail =
123
+ (data && typeof data.error === 'string' && data.error) ||
124
+ (data && typeof data.message === 'string' && data.message) ||
125
+ '';
126
+ if (status === 401) {
127
+ return 'Voroa rejected this token. It may have been revoked, expired, or invalidated by a password change - mint a new one in your dashboard.';
128
+ }
129
+ if (status === 403) {
130
+ return 'This token does not have permission for that action. Mint a token with the required permission in your dashboard.';
131
+ }
132
+ if (status === 404 && !detail) {
133
+ return 'Not available: either this feature is not enabled for your account, or that action is not offered on the API-token surface.';
134
+ }
135
+ return detail ? `Voroa API error (status ${status}): ${detail}` : `Voroa API error (status ${status})`;
136
+ }
@@ -0,0 +1,14 @@
1
+ // Shared MCP tool response shaping - one home (rule #10) so every tool returns the same JSON-text shape.
2
+
3
+ export function jsonResponse(payload) {
4
+ return {
5
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
6
+ };
7
+ }
8
+
9
+ // A clean input-validation error (the client sees the message; no token/host in it).
10
+ export function badInput(message) {
11
+ const err = new Error(message);
12
+ err.code = 'BAD_INPUT';
13
+ return err;
14
+ }
@@ -0,0 +1,33 @@
1
+ // Reversibility gate (ADR-0060 D8, mirroring ADR-0013's LOCKED governing rule).
2
+ //
3
+ // The mapping is DATA, not a runtime judgement the model makes. An UNMAPPED tool defaults to IRREVERSIBLE
4
+ // (fail-safe), so a future tool that deletes something can never quietly become auto-runnable by omission.
5
+ //
6
+ // SINGLE source of truth = ../reversibilityMap.json, which backend/tests/customerMcpCapabilities.test.js
7
+ // pins against the backend's capability allow-list in BOTH directions. We DERIVE the set from that file
8
+ // rather than re-declaring it here, so the map cannot drift between the two homes (rule #10). Read via fs
9
+ // (not a JSON import assertion) to stay portable across Node versions.
10
+ import { readFileSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, resolve } from 'node:path';
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const MAP = JSON.parse(readFileSync(resolve(HERE, '..', 'reversibilityMap.json'), 'utf8'));
16
+
17
+ const REVERSIBLE_TOOLS = Object.freeze(
18
+ new Set((MAP.actions || []).filter((r) => r.reversible === true).map((r) => r.tool))
19
+ );
20
+
21
+ // True ONLY for an explicitly-mapped reversible tool. Anything else - an unknown name, a read tool, a
22
+ // mapped-irreversible tool - returns false (fail-safe: irreversible by default).
23
+ export function isReversible(toolName) {
24
+ return REVERSIBLE_TOOLS.has(toolName);
25
+ }
26
+
27
+ export function reversibleToolNames() {
28
+ return [...REVERSIBLE_TOOLS];
29
+ }
30
+
31
+ export function actionRows() {
32
+ return MAP.actions || [];
33
+ }
@@ -0,0 +1,77 @@
1
+ {
2
+ "note": "SINGLE source of truth for CUSTOMER MCP action reversibility (ADR-0060 D8, mirroring ADR-0013's governing rule). Each row binds a customer-mcp tool name to its backend capability and whether it may run without a confirmation step. reversible:true = the action is undoable or re-runnable: no resource is destroyed, no money moves, and a subsequent action restores the previous state. reversible:false = IRREVERSIBLE - it must never run unattended. An UNMAPPED tool defaults to IRREVERSIBLE (fail-safe) in lib/reversibility.js. READ tools carry NO row. EVERY row is still reversible, and that is still the point - including the two propose tools added in Phase D. Proposing is not the action: it writes a request that changes nothing and can be withdrawn, and the IRREVERSIBLE thing it asks for happens only when a person approves it in their dashboard, on a surface this token cannot reach. So an autopilot may call propose freely; what it cannot do is make anything happen. Parity is CI-asserted against the backend allow-list (backend/tests/customerMcpCapabilities.test.js): every services:act capability must appear here, and every row here must be a services:act capability.",
3
+ "actions": [
4
+ {
5
+ "tool": "redeploy",
6
+ "capability": "POST /apps/:uuid/redeploy",
7
+ "reversible": true,
8
+ "why": "Builds and ships the current branch again. The previous version stays available to rollback, and a failed build leaves whatever is serving in place."
9
+ },
10
+ {
11
+ "tool": "restart",
12
+ "capability": "POST /apps/:uuid/restart",
13
+ "reversible": true,
14
+ "why": "Restarts the running container. No code, configuration or data changes; the service comes back on its own."
15
+ },
16
+ {
17
+ "tool": "stop",
18
+ "capability": "POST /apps/:uuid/stop",
19
+ "reversible": true,
20
+ "why": "Stops the running container. The sharpest tool on this surface - the service is DOWN until someone starts it - but nothing is destroyed and start() restores service, so it is reversible rather than irreversible. The tool description says the service goes down."
21
+ },
22
+ {
23
+ "tool": "start",
24
+ "capability": "POST /apps/:uuid/start",
25
+ "reversible": true,
26
+ "why": "Starts a stopped container. stop() reverses it."
27
+ },
28
+ {
29
+ "tool": "cancel_deploy",
30
+ "capability": "POST /apps/:uuid/deploy/cancel",
31
+ "reversible": true,
32
+ "why": "Stops a build that has not finished: a queued job is dropped, a running build is killed. Nothing that is already serving is touched, and once the new version has gone live the cancel is refused outright rather than un-deploying it. Deploying again re-runs it. Note: a cancelled deploy still counts toward the plan's build minutes (time is rounded up to whole minutes), so a redeploy/cancel loop is not free."
33
+ },
34
+ {
35
+ "tool": "rollback",
36
+ "capability": "POST /apps/:uuid/rollback",
37
+ "reversible": true,
38
+ "why": "Serves the previous version again from the artifact already on the host. No build runs; the swap is readiness-gated, so a failed rollback leaves the current version serving. Deploying again moves forward. Note: a rollback attempt still counts toward the plan's build minutes (time is rounded up to whole minutes), even when there is no previous version to restore."
39
+ },
40
+ {
41
+ "tool": "set_maintenance_mode",
42
+ "capability": "PUT /apps/:uuid/maintenance",
43
+ "reversible": true,
44
+ "why": "Puts a maintenance page in front of the service, or takes it away again. Visitors stop reaching the service while it is on - the same honesty `stop` needs - but nothing is destroyed, no code or data changes, and turning it off restores service immediately."
45
+ },
46
+ {
47
+ "tool": "set_edge_protection",
48
+ "capability": "PATCH /apps/:uuid/edge-protection",
49
+ "reversible": true,
50
+ "why": "Changes whether the service's address is served through the edge network or directly. The service keeps running, nothing is destroyed and no code or data changes; setting it back restores the previous path within a few minutes."
51
+ },
52
+ {
53
+ "tool": "purge_edge_cache",
54
+ "capability": "POST /apps/:uuid/edge-cache/purge",
55
+ "reversible": true,
56
+ "why": "Empties the edge cache of a web service. Nothing on the service is touched; the cache refills from the service on the next request, so there is nothing to undo."
57
+ },
58
+ {
59
+ "tool": "scan_disk",
60
+ "capability": "POST /apps/:uuid/disk/scan",
61
+ "reversible": true,
62
+ "why": "Reads what is using the service's disk and reports the largest paths. A measurement: it changes nothing on the service."
63
+ },
64
+ {
65
+ "tool": "propose",
66
+ "capability": "POST /propose/:action",
67
+ "reversible": true,
68
+ "why": "Records a request and changes nothing at all. The service, database or setting it names is untouched until a person approves it in the Voroa dashboard, which this token cannot do. A proposal expires on its own, and discard_proposal withdraws it."
69
+ },
70
+ {
71
+ "tool": "discard_proposal",
72
+ "capability": "POST /propose/:nonce/discard",
73
+ "reversible": true,
74
+ "why": "Withdraws a request this same token made. The only possible outcome is that nothing happens; proposing again re-raises it."
75
+ }
76
+ ]
77
+ }
@@ -0,0 +1,49 @@
1
+ // The ONE factory every ACTION tool is built from (rule #10). Six near-identical tools written out by
2
+ // hand would be six places the reversibility gate could be forgotten in exactly the way that matters.
3
+ //
4
+ // THE GATE. Before any request leaves this machine, the tool asserts `ctx.isReversible(name)` - which reads
5
+ // ../reversibilityMap.json and returns false for anything not explicitly mapped reversible:true. So a tool
6
+ // added later WITHOUT a map row cannot call the API at all; it fails locally with a message saying it needs
7
+ // a confirmation path. That is the fail-safe direction ADR-0013 locked and ADR-0060 D8 inherits, expressed
8
+ // as a runtime assertion rather than a comment.
9
+ //
10
+ // The gate is DEFENCE IN DEPTH, not the boundary. The real boundary is server-side: the backend capability
11
+ // allow-list (lib/customerMcpCapabilities.js) simply has no irreversible entry, so even a tampered copy of
12
+ // this package reaches nothing destructive. Both matter - this one gives an honest local refusal instead of
13
+ // a confusing 404.
14
+
15
+ import { jsonResponse, badInput } from '../lib/response.js';
16
+ import { serviceId } from './_ids.js';
17
+ import { actionResult } from './_shape.js';
18
+
19
+ export function makeServiceAction({ name, path, description, successMessage }) {
20
+ return {
21
+ name,
22
+ kind: 'action',
23
+ scope: 'services:act',
24
+ description,
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: {
28
+ serviceId: { type: 'string', description: 'The service id, from list_services.' },
29
+ },
30
+ required: ['serviceId'],
31
+ additionalProperties: false,
32
+ },
33
+ async handler(args, ctx) {
34
+ // Fail-safe: unmapped -> irreversible -> refuse here, before any request.
35
+ if (typeof ctx.isReversible !== 'function' || !ctx.isReversible(name)) {
36
+ const err = new Error(
37
+ `${name} is not marked as a reversible action, so it will not run unattended. `
38
+ + 'An action that cannot be undone needs an explicit confirmation path that this client does not have.'
39
+ );
40
+ err.code = 'IRREVERSIBLE_BLOCKED';
41
+ throw err;
42
+ }
43
+ const id = serviceId(args.serviceId);
44
+ if (!id) throw badInput('serviceId is required and must be a valid service id from list_services.');
45
+ const data = await ctx.http.post(`/apps/${encodeURIComponent(id)}/${path}`);
46
+ return jsonResponse(actionResult(data, { message: successMessage }));
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,42 @@
1
+ // Identifier validation for tool arguments - one home (rule #10).
2
+ //
3
+ // These ids reach a URL PATH segment, so the charset gate here is a real control and not a nicety: it is
4
+ // what stops an argument like `../../admin` or a value carrying `?`/`#`/`/` from being composed into a
5
+ // different path than the tool intends. The backend's capability allow-list would 404 such a request
6
+ // anyway (anchored, single-segment patterns), so this is defence in depth - but it turns a confusing 404
7
+ // into a clear message the model can act on.
8
+ //
9
+ // Voroa ids are lower-case alphanumeric; a native deployment id is `<uuid>-<epoch>`. Hyphens are therefore
10
+ // allowed, dots and slashes are not.
11
+ //
12
+ // UNDERSCORE IS ALLOWED, and it is not a loosening of the gate above - it is the gate finally admitting an
13
+ // id the platform has always minted. A deploy that FAILED before it had a real deployment id is referred to
14
+ // by `vfail_<26>` (`backend/src/lib/appId.js`, FAILED_REF_PREFIX), so `get_deploy_history` returned that id
15
+ // and `get_build_log` refused the very same value as "not a deploy id from get_deploy_history". Measured
16
+ // 2026-09-26 on a customer's own report: the one tool built to answer "why did my deploy fail" was broken
17
+ // for exactly the deploys that failed, and only for those.
18
+ //
19
+ // The charset gate is unchanged in the way that matters: `/`, `.`, `?`, `#` and every other path-significant
20
+ // character are still out, so nothing here can compose a different path than the tool intends. The first
21
+ // character is still alphanumeric, so no id can lead with `_`.
22
+ //
23
+ // `backend/tests/customerMcpIdShapes.test.js` now checks the platform's minted shapes against this regex, so
24
+ // the next prefix cannot break the same way unnoticed.
25
+ const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
26
+
27
+ export function serviceId(value) {
28
+ const s = String(value == null ? '' : value).trim();
29
+ return ID_RE.test(s) ? s : null;
30
+ }
31
+
32
+ export function deploymentId(value) {
33
+ const s = String(value == null ? '' : value).trim();
34
+ return ID_RE.test(s) ? s : null;
35
+ }
36
+
37
+ // A YYYY-MM billing period, or null. Absent is legal (the API defaults to the current period).
38
+ export function period(value) {
39
+ if (value == null || value === '') return null;
40
+ const s = String(value).trim();
41
+ return /^\d{4}-\d{2}$/.test(s) ? s : null;
42
+ }