@tokenoftrust/cli 1.4.0-rc.17 → 1.4.0-rc.19

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/bin/tot.mjs CHANGED
@@ -40,6 +40,7 @@ import { readFileSync } from "node:fs";
40
40
  import { detectContext } from "../src/context.mjs";
41
41
  import { printError } from "../src/errors.mjs";
42
42
  import { recordActivity, redactArgs } from "../src/activity-log.mjs";
43
+ import { emitActivity, capRendered } from "../src/activity.mjs";
43
44
  import { maybeNotifyUpdate } from "../src/update-check.mjs";
44
45
 
45
46
  const BUILD_ORDER = ["clone", "validate", "dev", "preview"];
@@ -295,12 +296,39 @@ async function dispatch(cmd, rest, ctx) {
295
296
  return 2;
296
297
  }
297
298
 
299
+ /**
300
+ * The safe `subcommand` value for a command's activity event. Only the fixed
301
+ * sub-dispatcher verbs (e.g. `app scaffold` / `app dev`) are surfaced — an
302
+ * arbitrary positional (a tenant name, a path, a code) is NEVER used, so the
303
+ * activity `subcommand` field can't carry PII even before redaction. Returns
304
+ * undefined for every command without a fixed subcommand vocabulary.
305
+ */
306
+ function safeSubcommand(cmd, rest) {
307
+ if (cmd === "app") {
308
+ const sub = rest.find((t) => t && !t.startsWith("-"));
309
+ if (sub === "scaffold" || sub === "dev") return sub;
310
+ }
311
+ return undefined;
312
+ }
313
+
298
314
  async function main() {
299
315
  const [cmd, ...rest] = process.argv.slice(2);
300
316
  const ctx = detectContext();
301
317
  const startedAt = Date.now();
318
+ const command = cmd || "(none)";
319
+ const subcommand = safeSubcommand(cmd, rest);
302
320
  let code = 0;
303
321
  let errMsg = null;
322
+
323
+ // D3: emit `cli.command.invoked` on start — best-effort, a SILENT no-op without a
324
+ // hosted-bridge credential (never a network call / never blocks). Fired without
325
+ // await so it adds no latency to the command; settled alongside the result below.
326
+ const invokedEmit = emitActivity({
327
+ action: "cli.command.invoked",
328
+ outcome: { status: "invoked" },
329
+ payload: { args: { command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION, node: process.version } },
330
+ });
331
+
304
332
  try {
305
333
  code = await dispatch(cmd, rest, ctx);
306
334
  return code;
@@ -308,17 +336,35 @@ async function main() {
308
336
  errMsg = e?.message || String(e);
309
337
  throw e;
310
338
  } finally {
339
+ const exitCode = errMsg ? 1 : (code ?? 0);
340
+ const durationMs = Date.now() - startedAt;
311
341
  // Best-effort activity breadcrumb (never throws, never blocks). `feedback`'s own
312
342
  // free-text message is omitted — it's user-typed and belongs only in the report.
313
343
  recordActivity({
314
344
  ts: new Date().toISOString(),
315
345
  v: VERSION,
316
- cmd: cmd || "(none)",
346
+ cmd: command,
317
347
  args: cmd === "feedback" ? ["«omitted»"] : redactArgs(rest),
318
- code: errMsg ? 1 : (code ?? 0),
319
- ms: Date.now() - startedAt,
348
+ code: exitCode,
349
+ ms: durationMs,
320
350
  ...(errMsg ? { err: String(errMsg).slice(0, 200) } : {}),
321
351
  });
352
+ // D3: emit `cli.command.result` (exit code + duration + a bounded/redacted
353
+ // rendered field). The house-style error text is capped + run through the JS
354
+ // redaction mirror; per the D0 catalog the `cli.*` rendered lane is empty-
355
+ // allowlisted, so it's DROPPED by design (the safe default for a high-volume
356
+ // action) — the emit path still exercises the mirror. Bounded-await here (with
357
+ // the invoked emit) so a live bridge flushes before the process exits; a no-op
358
+ // when there's no credential.
359
+ const resultEmit = emitActivity({
360
+ action: "cli.command.result",
361
+ outcome: { status: errMsg ? "failed" : "succeeded", durationMs },
362
+ payload: {
363
+ args: { command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION, exitCode, durationMs },
364
+ ...(errMsg ? { rendered: { output: capRendered(errMsg) } } : {}),
365
+ },
366
+ });
367
+ await Promise.allSettled([invokedEmit, resultEmit]);
322
368
  // Nudge if a newer/unsupported version exists (drawn from cache — instant),
323
369
  // and kick a detached registry refresh if stale. Never throws, never blocks.
324
370
  maybeNotifyUpdate(VERSION);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.17",
3
+ "version": "1.4.0-rc.19",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -0,0 +1,378 @@
1
+ /**
2
+ * emitActivity — the CLI's operational-activity emitter (card D3).
3
+ *
4
+ * This is a hand-maintained JS MIRROR of the D0 actor×action contract that lives,
5
+ * in TypeScript, in `packages/public-runtime/src/activity/` (event.ts / catalog.ts
6
+ * / redaction.ts). The CLI is published to npm STANDALONE and is deliberately pure
7
+ * JS with NO dependency on `@tot/public-runtime` (a TS package) — see that module's
8
+ * README ("Consuming this from the downstream cards → D3"). So instead of importing
9
+ * the contract, we mirror the slice of it the CLI needs, and a keep-in-step test
10
+ * (activity-mirror.test.mjs) fails LOUDLY the moment this mirror drifts from the TS
11
+ * source of truth (the same discipline as no-gitea-links.test.mjs).
12
+ *
13
+ * What this mirrors, exactly:
14
+ * • the ActivityEvent envelope shape + ACTIVITY_SCHEMA_VERSION + newEventId()
15
+ * • the CLI-relevant subset of ACTION_CATALOG (the `cli.*` action keys + their
16
+ * default-deny argsAllow/renderedAllow allowlists)
17
+ * • the redaction contract (REDACTION_MARKER, MAX_VALUE_LEN, the VALUE_CANARIES,
18
+ * the KEY_NAME_CANARY, and redactPayload/createActivityEvent) — byte-for-byte,
19
+ * so a value that the server-side contract would scrub is scrubbed here too.
20
+ * If you add a `cli.*` action, change an allowlist, or touch a canary in the TS
21
+ * source, you MUST update this file in lockstep — the keep-in-step test enforces it.
22
+ *
23
+ * Transport + gate (generalized from the obstacle beacon, obstacle-beacon.cjs):
24
+ * emitActivity() is a SILENT NO-OP without a bridge credential configured — it
25
+ * never makes a network call and never fails/blocks a command when the developer
26
+ * isn't connected to a hosted /dev bridge (a bare `tot login`, an older session,
27
+ * CI, the `--sample` path). With a credential it POSTs the redacted envelope to
28
+ * the SAME `<activityUrl>/api/dev/activity` endpoint the obstacle beacon and the
29
+ * dev heartbeat already use — best-effort, bounded by a hard timeout, and fully
30
+ * swallowed so telemetry can never disrupt or delay the developer's command.
31
+ *
32
+ * Dependency-free (node: builtins + global fetch, Node ≥22).
33
+ */
34
+ import { createHash } from "node:crypto";
35
+ import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Envelope (mirror of event.ts)
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+
41
+ /** Envelope contract version — MIRROR of event.ts ACTIVITY_SCHEMA_VERSION. */
42
+ export const ACTIVITY_SCHEMA_VERSION = 1;
43
+
44
+ /** Node-native UUID v4 (Node ≥22 has globalThis.crypto). Mirror of event.ts newEventId(). */
45
+ export function newEventId() {
46
+ const c = globalThis.crypto;
47
+ if (c && typeof c.randomUUID === "function") return c.randomUUID();
48
+ // Fallback (should never be reached on a supported Node) — node:crypto.
49
+ return createHash("sha256").update(String(Math.random()) + Date.now()).digest("hex").slice(0, 32);
50
+ }
51
+
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ // Action catalog — the CLI-relevant SUBSET (mirror of the `cli.*` keys in catalog.ts).
54
+ //
55
+ // Keep this in lockstep with packages/public-runtime/src/activity/catalog.ts. Only
56
+ // the `cli.*` domain is mirrored here (the CLI never emits server/ui actions); the
57
+ // keep-in-step test asserts that (a) this set is EXACTLY the `cli.*` keys in the TS
58
+ // catalog and (b) each argsAllow/renderedAllow matches the TS entry.
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+ export const CLI_ACTION_CATALOG = {
61
+ "cli.command.invoked": {
62
+ argsAllow: ["command", "subcommand", "cliVersion", "node"],
63
+ renderedAllow: [],
64
+ },
65
+ "cli.command.result": {
66
+ argsAllow: ["command", "subcommand", "cliVersion", "exitCode", "durationMs"],
67
+ renderedAllow: [],
68
+ },
69
+ "cli.obstacle.reported": {
70
+ argsAllow: ["kind", "have", "need", "cliVersion"],
71
+ renderedAllow: [],
72
+ },
73
+ "cli.signin.requested": {
74
+ argsAllow: ["cliVersion"],
75
+ renderedAllow: [],
76
+ },
77
+ };
78
+
79
+ /** All CLI action keys as a runtime array. */
80
+ export const CLI_ACTION_KEYS = Object.keys(CLI_ACTION_CATALOG);
81
+
82
+ /** Deny-by-default guard — is this an allowlisted CLI action key? */
83
+ export function isCliActionKey(v) {
84
+ return typeof v === "string" && Object.prototype.hasOwnProperty.call(CLI_ACTION_CATALOG, v);
85
+ }
86
+
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+ // Redaction contract (mirror of redaction.ts) — MUST match byte-for-byte.
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+
91
+ /** Marker stored in place of a value that tripped a canary. Mirror of REDACTION_MARKER. */
92
+ export const REDACTION_MARKER = "«redacted»";
93
+ /** Cap on a stored rendered/arg string. Mirror of MAX_VALUE_LEN. */
94
+ export const MAX_VALUE_LEN = 512;
95
+
96
+ /**
97
+ * Secret/PII VALUE canaries — MIRROR of redaction.ts VALUE_CANARIES. The regex
98
+ * SOURCES here must stay identical to the TS side; the keep-in-step test compares
99
+ * them literally.
100
+ */
101
+ export const VALUE_CANARIES = [
102
+ { label: "private_key", re: /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----/ },
103
+ { label: "jwt", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/ },
104
+ { label: "bearer", re: /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/i },
105
+ { label: "aws_access_key", re: /\bAKIA[0-9A-Z]{16}\b/ },
106
+ { label: "gh_token", re: /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}/ },
107
+ { label: "slack_token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
108
+ { label: "openai_key", re: /\bsk-[A-Za-z0-9]{20,}/ },
109
+ { label: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/ },
110
+ { label: "card_number", re: /\b(?:\d[ -]?){13,19}\b/ },
111
+ { label: "phone", re: /\b\+?\d[\d ().-]{9,}\d\b/ },
112
+ { label: "high_entropy", re: /(?:^|[^A-Za-z0-9_-])[A-Za-z0-9_-]{28,}(?:$|[^A-Za-z0-9_-])/ },
113
+ ];
114
+
115
+ /** Secret-looking KEY NAMES — dropped outright. MIRROR of redaction.ts KEY_NAME_CANARY. */
116
+ export const KEY_NAME_CANARY =
117
+ /(?:secret|token|passwd|password|authorization|api[_-]?key|private[_-]?key|credential|cookie|session|bearer|jwt)/i;
118
+
119
+ function isPrimitive(v) {
120
+ const t = typeof v;
121
+ return t === "string" || t === "number" || t === "boolean";
122
+ }
123
+
124
+ /** Scan a stringified value against the value canaries. Mirror of scanValueCanary. */
125
+ export function scanValueCanary(value) {
126
+ for (const c of VALUE_CANARIES) if (c.re.test(value)) return c.label;
127
+ return undefined;
128
+ }
129
+
130
+ function redactLane(lane, raw, allow, hits) {
131
+ if (!raw) return undefined;
132
+ const allowSet = new Set(allow);
133
+ const out = {};
134
+ for (const [key, value] of Object.entries(raw)) {
135
+ if (KEY_NAME_CANARY.test(key)) {
136
+ hits.push({ lane, key, reason: "key_name_canary" });
137
+ continue;
138
+ }
139
+ if (!allowSet.has(key)) {
140
+ hits.push({ lane, key, reason: "not_allowlisted" });
141
+ continue;
142
+ }
143
+ if (!isPrimitive(value)) {
144
+ hits.push({ lane, key, reason: "non_primitive" });
145
+ continue;
146
+ }
147
+ const str = String(value).slice(0, MAX_VALUE_LEN);
148
+ const canary = scanValueCanary(str);
149
+ if (canary) {
150
+ hits.push({ lane, key, reason: "value_canary", canary });
151
+ out[key] = REDACTION_MARKER;
152
+ continue;
153
+ }
154
+ out[key] = typeof value === "string" ? str : value;
155
+ }
156
+ return Object.keys(out).length > 0 ? out : undefined;
157
+ }
158
+
159
+ /**
160
+ * Apply the redaction contract to a raw payload for a CLI action. Pure. Mirror of
161
+ * redaction.ts redactPayload (restricted to the CLI catalog). An unknown action
162
+ * yields an empty payload (deny-all).
163
+ */
164
+ export function redactPayload(action, raw) {
165
+ const hits = [];
166
+ const spec = CLI_ACTION_CATALOG[action];
167
+ if (!spec) {
168
+ if (raw?.args) for (const k of Object.keys(raw.args)) hits.push({ lane: "args", key: k, reason: "unknown_action" });
169
+ if (raw?.rendered) for (const k of Object.keys(raw.rendered)) hits.push({ lane: "rendered", key: k, reason: "unknown_action" });
170
+ return { payload: undefined, report: { keptArgs: [], keptRendered: [], hits, canaryTripped: false } };
171
+ }
172
+ const args = redactLane("args", raw?.args, spec.argsAllow, hits);
173
+ const renderedRaw = redactLane("rendered", raw?.rendered, spec.renderedAllow, hits);
174
+ const rendered = renderedRaw
175
+ ? Object.fromEntries(Object.entries(renderedRaw).map(([k, v]) => [k, String(v)]))
176
+ : undefined;
177
+ const payload = args || rendered ? { ...(args ? { args } : {}), ...(rendered ? { rendered } : {}) } : undefined;
178
+ return {
179
+ payload,
180
+ report: {
181
+ keptArgs: args ? Object.keys(args) : [],
182
+ keptRendered: rendered ? Object.keys(rendered) : [],
183
+ hits,
184
+ canaryTripped: hits.some((h) => h.reason === "value_canary"),
185
+ },
186
+ };
187
+ }
188
+
189
+ /**
190
+ * The safe constructor — mirror of redaction.ts createActivityEvent. Fills v/id/at
191
+ * and runs the raw payload through redactPayload() so the returned event's payload
192
+ * is already default-denied + canary-scanned. Throws only on an out-of-catalog
193
+ * (non-`cli.*`) action — a programming error.
194
+ */
195
+ export function createActivityEvent({ action, actor, source = "cli", outcome, scope, payload, at, id }) {
196
+ if (!isCliActionKey(action)) throw new Error(`activity: unknown CLI action "${action}" (not in the CLI mirror catalog)`);
197
+ const { payload: redacted, report } = redactPayload(action, payload);
198
+ const event = {
199
+ v: ACTIVITY_SCHEMA_VERSION,
200
+ id: id ?? newEventId(),
201
+ at: at ?? new Date().toISOString(),
202
+ actor,
203
+ action,
204
+ source,
205
+ scope: scope ?? {},
206
+ outcome,
207
+ ...(redacted ? { payload: redacted } : {}),
208
+ };
209
+ return { event, report };
210
+ }
211
+
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+ // Bridge resolution + emit
214
+ // ─────────────────────────────────────────────────────────────────────────────
215
+
216
+ /** Hard cap on how long a single emit may take before it's abandoned (never blocks a command). */
217
+ const EMIT_TIMEOUT_MS = 1500;
218
+
219
+ /**
220
+ * The ONE activity-telemetry kill switch (card D8) — the SAME flag name the server
221
+ * honours (`apps/storefront/src/lib/activity/killSwitch.ts`). Set
222
+ * `ACTIVITY_TELEMETRY_DISABLED=1` (or true/yes/on) and every CLI emit becomes a silent
223
+ * no-op — no build, no redaction, no network — while the command itself runs unchanged
224
+ * (fail-open). Unset ⇒ telemetry enabled (the normal state).
225
+ */
226
+ export const ACTIVITY_KILL_SWITCH_FLAG = "ACTIVITY_TELEMETRY_DISABLED";
227
+ export function isActivityDisabled(env = process.env) {
228
+ const v = env && env[ACTIVITY_KILL_SWITCH_FLAG];
229
+ return typeof v === "string" && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
230
+ }
231
+
232
+ /**
233
+ * Resolve the local→hosted activity-bridge credential, or null when none is
234
+ * configured (→ emitActivity is a no-op). Mirrors the resolution the obstacle
235
+ * beacon (obstacle.mjs) and the heartbeat (dev-heartbeat.mjs) use: the env override
236
+ * `TOT_DEV_ACTIVITY_URL`/`TOT_DEV_ACTIVITY_TOKEN` (threaded to runner-spawning
237
+ * paths) wins, else the `activityUrl`/`activityToken` cached in ~/.tot/credentials.json.
238
+ */
239
+ export function resolveActivityBridge(env = process.env) {
240
+ const url = env.TOT_DEV_ACTIVITY_URL;
241
+ const token = env.TOT_DEV_ACTIVITY_TOKEN;
242
+ if (url && token) return { url, token };
243
+ try {
244
+ const creds = readCredentials(defaultCredentialsPath(env));
245
+ if (creds?.activityUrl && creds?.activityToken) {
246
+ return { url: creds.activityUrl, token: creds.activityToken };
247
+ }
248
+ } catch {
249
+ /* best-effort — no bridge */
250
+ }
251
+ return null;
252
+ }
253
+
254
+ /**
255
+ * The event's actor — kind `dev` (or `agent` when the caller signals an automated
256
+ * run) with an OPAQUE, non-reversible id, per the D0 contract (actor.id in the core
257
+ * zone is NEVER raw PII). Until the canonical pseudonymization salt lands (an open
258
+ * question owned by D1/D4 — see redaction.ts header), we derive the id from an
259
+ * already-opaque handle: the masked emailHint if present, else a one-way hash of the
260
+ * bridge token, else "anonymous". Never reversible, never raw PII.
261
+ */
262
+ export function resolveActor(env = process.env) {
263
+ const kind = env.TOT_ACTOR_KIND === "agent" ? "agent" : "dev";
264
+ let seed = "anonymous";
265
+ try {
266
+ const creds = readCredentials(defaultCredentialsPath(env));
267
+ if (creds?.emailHint) seed = `hint:${creds.emailHint}`;
268
+ else if (creds?.activityToken) seed = `tok:${creds.activityToken}`;
269
+ } catch {
270
+ /* fall through to anonymous */
271
+ }
272
+ const id = seed === "anonymous" ? "anonymous" : createHash("sha256").update(seed).digest("hex").slice(0, 16);
273
+ return { kind, id };
274
+ }
275
+
276
+ /**
277
+ * The scope threaded onto every CLI event — the storefront-minted invite→problem
278
+ * `traceId` from the credential cache, when present, so a CLI event and the server
279
+ * event it triggers share one trace (matching the existing feedback/heartbeat use).
280
+ */
281
+ function resolveScope(env = process.env, extra = {}) {
282
+ const scope = { ...extra };
283
+ try {
284
+ const creds = readCredentials(defaultCredentialsPath(env));
285
+ if (creds?.traceId && !scope.traceId) scope.traceId = creds.traceId;
286
+ } catch {
287
+ /* best-effort */
288
+ }
289
+ return scope;
290
+ }
291
+
292
+ /**
293
+ * Emit one activity event. SILENT NO-OP without a bridge credential (returns
294
+ * `{ sent:false }`) — no network call, never throws, never blocks a command. With
295
+ * a credential it builds the redacted envelope (via createActivityEvent) and POSTs
296
+ * it best-effort to `<url>/api/dev/activity`, bounded by EMIT_TIMEOUT_MS.
297
+ *
298
+ * Returns `{ sent, event?, report? }` so callers/tests can assert what would be
299
+ * emitted without any network. `fetchImpl` and `bridge` are injectable for tests.
300
+ *
301
+ * @param {{ action: string, outcome: object, actor?: object, scope?: object,
302
+ * payload?: object, source?: string, env?: NodeJS.ProcessEnv,
303
+ * fetchImpl?: typeof fetch, bridge?: {url:string,token:string}|null }} input
304
+ * @returns {Promise<{sent:boolean, event?:object, report?:object}>}
305
+ */
306
+ export async function emitActivity({
307
+ action,
308
+ outcome,
309
+ actor,
310
+ scope,
311
+ payload,
312
+ source = "cli",
313
+ env = process.env,
314
+ fetchImpl,
315
+ bridge,
316
+ } = {}) {
317
+ try {
318
+ // Kill switch (D8): telemetry off ⇒ true no-op (no build/redaction/network),
319
+ // fail-open — the command is unaffected.
320
+ if (isActivityDisabled(env)) return { sent: false, disabled: true };
321
+ const resolvedBridge = bridge !== undefined ? bridge : resolveActivityBridge(env);
322
+ // Build the redacted event even when we won't send it — so a caller/test can
323
+ // inspect the exact shape, and so redaction always runs on the emit path.
324
+ const { event, report } = createActivityEvent({
325
+ action,
326
+ actor: actor ?? resolveActor(env),
327
+ source,
328
+ outcome,
329
+ scope: resolveScope(env, scope ?? {}),
330
+ payload,
331
+ });
332
+ if (!resolvedBridge) return { sent: false, event, report };
333
+
334
+ const doFetch = fetchImpl || globalThis.fetch;
335
+ if (typeof doFetch !== "function") return { sent: false, event, report };
336
+
337
+ const controller = new AbortController();
338
+ const timer = setTimeout(() => controller.abort(), EMIT_TIMEOUT_MS);
339
+ if (typeof timer.unref === "function") timer.unref();
340
+ try {
341
+ await doFetch(`${String(resolvedBridge.url).replace(/\/+$/, "")}/api/dev/activity`, {
342
+ method: "POST",
343
+ headers: {
344
+ "content-type": "application/json",
345
+ authorization: `Bearer ${resolvedBridge.token}`,
346
+ },
347
+ // `event: "activity"` tags the stream for the bridge/D4 ingest to route,
348
+ // alongside the D0 envelope. Best-effort — an older bridge that doesn't
349
+ // recognize it simply ignores the post.
350
+ body: JSON.stringify({ event: "activity", ...event }),
351
+ signal: controller.signal,
352
+ });
353
+ } catch {
354
+ /* best-effort — a failed/offline/aborted post never disrupts the command */
355
+ } finally {
356
+ clearTimeout(timer);
357
+ }
358
+ return { sent: true, event, report };
359
+ } catch {
360
+ // Absolutely never throw into a command's control flow.
361
+ return { sent: false };
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Cap + surface a bit of rendered output for a command-result event. The full raw
367
+ * stdout is NEVER stored — this caps to MAX_VALUE_LEN and hands the string to the
368
+ * redaction mirror via payload.rendered. NB the `cli.command.*` actions carry an
369
+ * EMPTY renderedAllow in the D0 catalog, so this string is DROPPED at redaction by
370
+ * design (the safe default for a high-volume action) — running it through the
371
+ * mirror is the belt-and-suspenders the contract prescribes, and keeps the emit
372
+ * path honest if the allowlist ever opens. Pure.
373
+ */
374
+ export function capRendered(text) {
375
+ const s = String(text ?? "").trim();
376
+ if (!s) return undefined;
377
+ return s.slice(0, MAX_VALUE_LEN);
378
+ }
@@ -194,6 +194,22 @@ export async function run(argv, ctx) {
194
194
  */
195
195
  export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
196
196
  await client.callTool("client_switch", { tenant });
197
+ // vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
198
+ // storefront version-control app BEFORE checkout, so tenant_checkout can resolve
199
+ // `appForCaller` for them instead of 403-ing "No version-control app is bound." The
200
+ // human's OWN session self-triggers it (no subject_token — the subject is the
201
+ // caller's already-verified identity); the MCP entitlement-gates + authors the bind.
202
+ // Best-effort + FAIL-OPEN: a missing tool / cold MCP / any fault must never block a
203
+ // checkout that would otherwise succeed, and if the developer is still unbound the
204
+ // tenant_checkout error below (via checkoutError) is the honest backstop. Binding is
205
+ // also ensured at the authority seams (native OAuth sign-in) + backfill; this line
206
+ // self-heals a legacy session that predates them.
207
+ try {
208
+ // Wire name is `identity_bind` (tot-mcp taxonomy: <subject>_<action>).
209
+ await client.callTool("identity_bind", {});
210
+ } catch {
211
+ /* fail-open — see above */
212
+ }
197
213
  const checkout = await client.callTool("tenant_checkout", { tenant, tag });
198
214
 
199
215
  // A non-checkout result (not provisioned / not entitled / failed) must surface
@@ -271,6 +287,22 @@ export function checkoutError(checkout) {
271
287
  next: "ask your Token of Trust contact to finish setting up your store, then re-run",
272
288
  };
273
289
  }
290
+ // Entitled, but not yet BOUND to the version-control app (appForCaller resolves
291
+ // nothing for this identity) — the 08-18 vc-app-binding gap. Distinct from
292
+ // not-provisioned: the STORE is fine; the developer just isn't onboarded to act
293
+ // THROUGH the app yet. The checkout preflight tries to self-heal this; if it still
294
+ // surfaces, the binding seam/backfill hasn't reached this identity — say so plainly
295
+ // instead of the generic "confirm you're entitled" (they ARE entitled).
296
+ if (
297
+ typeof msg === "string" &&
298
+ /version-control app is bound|registered version-control app/i.test(msg)
299
+ ) {
300
+ return {
301
+ message:
302
+ "your account isn't onboarded to check out this store yet (no version-control app is bound to your session)",
303
+ next: "re-run `tot login` to refresh your access, then retry — if it persists, ask your Token of Trust contact to finish onboarding your account",
304
+ };
305
+ }
274
306
  return {
275
307
  message: msg || "the store checkout couldn't be completed",
276
308
  next: "confirm you're entitled to this store — `tot clone` (lists your stores)",