run402 3.0.0 → 3.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 (51) hide show
  1. package/core-dist/write-auth-session.js +174 -0
  2. package/lib/deploy-v2.mjs +13 -8
  3. package/lib/functions.mjs +6 -6
  4. package/lib/operator.mjs +234 -1
  5. package/lib/projects.mjs +9 -4
  6. package/lib/sdk.mjs +3 -1
  7. package/package.json +1 -1
  8. package/sdk/core-dist/write-auth-session.js +174 -0
  9. package/sdk/dist/credentials.d.ts +28 -1
  10. package/sdk/dist/credentials.d.ts.map +1 -1
  11. package/sdk/dist/errors.d.ts +43 -1
  12. package/sdk/dist/errors.d.ts.map +1 -1
  13. package/sdk/dist/errors.js +71 -0
  14. package/sdk/dist/errors.js.map +1 -1
  15. package/sdk/dist/index.d.ts +1 -1
  16. package/sdk/dist/index.d.ts.map +1 -1
  17. package/sdk/dist/index.js +1 -1
  18. package/sdk/dist/index.js.map +1 -1
  19. package/sdk/dist/kernel.d.ts +3 -1
  20. package/sdk/dist/kernel.d.ts.map +1 -1
  21. package/sdk/dist/kernel.js +35 -4
  22. package/sdk/dist/kernel.js.map +1 -1
  23. package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
  24. package/sdk/dist/namespaces/deploy.js +20 -4
  25. package/sdk/dist/namespaces/deploy.js.map +1 -1
  26. package/sdk/dist/namespaces/deploy.types.d.ts +13 -0
  27. package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
  28. package/sdk/dist/namespaces/deploy.types.js.map +1 -1
  29. package/sdk/dist/namespaces/functions.d.ts +28 -15
  30. package/sdk/dist/namespaces/functions.d.ts.map +1 -1
  31. package/sdk/dist/namespaces/functions.js +64 -28
  32. package/sdk/dist/namespaces/functions.js.map +1 -1
  33. package/sdk/dist/namespaces/operator.d.ts +86 -3
  34. package/sdk/dist/namespaces/operator.d.ts.map +1 -1
  35. package/sdk/dist/namespaces/operator.js +55 -0
  36. package/sdk/dist/namespaces/operator.js.map +1 -1
  37. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  38. package/sdk/dist/namespaces/projects.js +11 -0
  39. package/sdk/dist/namespaces/projects.js.map +1 -1
  40. package/sdk/dist/namespaces/transfers.d.ts +4 -0
  41. package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
  42. package/sdk/dist/namespaces/transfers.js +16 -1
  43. package/sdk/dist/namespaces/transfers.js.map +1 -1
  44. package/sdk/dist/node/credentials.d.ts +32 -6
  45. package/sdk/dist/node/credentials.d.ts.map +1 -1
  46. package/sdk/dist/node/credentials.js +57 -4
  47. package/sdk/dist/node/credentials.js.map +1 -1
  48. package/sdk/dist/node/index.d.ts +11 -1
  49. package/sdk/dist/node/index.d.ts.map +1 -1
  50. package/sdk/dist/node/index.js +3 -1
  51. package/sdk/dist/node/index.js.map +1 -1
@@ -0,0 +1,174 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, statSync, rmSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { randomBytes, createHash } from "node:crypto";
4
+ import { getConfigBaseDir } from "./config.js";
5
+ const DEFAULT_TTL_MS = 30 * 60 * 1000;
6
+ /**
7
+ * Path to the approval cache: `{base}/write-auth-session.json`.
8
+ * `RUN402_WRITE_AUTH_SESSION_PATH` overrides for testing.
9
+ */
10
+ export function getWriteAuthSessionPath() {
11
+ return (process.env.RUN402_WRITE_AUTH_SESSION_PATH ||
12
+ join(getConfigBaseDir(), "write-auth-session.json"));
13
+ }
14
+ /** Stable short hash binding an approval to a control-plane session token. */
15
+ export function hashControlPlaneSession(token) {
16
+ return createHash("sha256").update(token).digest("hex").slice(0, 32);
17
+ }
18
+ /** Tighten 0600 if group/other-readable, warning once. Best-effort, POSIX-only. */
19
+ function selfHealPermissions(p) {
20
+ if (process.platform === "win32")
21
+ return;
22
+ try {
23
+ const mode = statSync(p).mode & 0o777;
24
+ if ((mode & 0o077) !== 0) {
25
+ chmodSync(p, 0o600);
26
+ process.stderr.write(`warning: tightened permissions on ${p} from ${mode.toString(8)} to 600 (was readable by other users).\n`);
27
+ }
28
+ }
29
+ catch {
30
+ // Best-effort; never block a read on a chmod/stat failure.
31
+ }
32
+ }
33
+ function isApproval(x) {
34
+ if (!x || typeof x !== "object")
35
+ return false;
36
+ const a = x;
37
+ return (typeof a.write_auth_token === "string" &&
38
+ a.write_auth_token.length > 0 &&
39
+ typeof a.action === "string" &&
40
+ typeof a.api_origin === "string" &&
41
+ typeof a.control_plane_session_hash === "string" &&
42
+ typeof a.expires_at === "number" &&
43
+ Number.isFinite(a.expires_at));
44
+ }
45
+ /**
46
+ * Read all cached approvals. Returns `[]` for the "no cache" cases (absent,
47
+ * unreadable, unparseable). Throws when the file parses as JSON but the shape
48
+ * is wrong, so a corrupted cache surfaces a clear fix-it.
49
+ */
50
+ export function readApprovals(path) {
51
+ const p = path ?? getWriteAuthSessionPath();
52
+ if (!existsSync(p))
53
+ return [];
54
+ selfHealPermissions(p);
55
+ let raw;
56
+ try {
57
+ raw = readFileSync(p, "utf-8");
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(raw);
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
70
+ throw new Error("write-auth-session.json must contain a JSON object. Delete it and re-run 'run402 operator approve' to recreate it.");
71
+ }
72
+ const approvals = parsed.approvals;
73
+ if (!Array.isArray(approvals)) {
74
+ throw new Error("write-auth-session.json missing an 'approvals' array. Delete it and re-run 'run402 operator approve'.");
75
+ }
76
+ return approvals.filter(isApproval);
77
+ }
78
+ function writeApprovals(approvals, path) {
79
+ const p = path ?? getWriteAuthSessionPath();
80
+ const dir = dirname(p);
81
+ mkdirSync(dir, { recursive: true });
82
+ const tmp = join(dir, `.write-auth-session.${randomBytes(4).toString("hex")}.tmp`);
83
+ writeFileSync(tmp, JSON.stringify({ approvals }, null, 2), { mode: 0o600 });
84
+ renameSync(tmp, p);
85
+ chmodSync(p, 0o600);
86
+ }
87
+ function sameTarget(a, b) {
88
+ return (a.org_id ?? null) === (b.org_id ?? null) && (a.project_id ?? null) === (b.project_id ?? null);
89
+ }
90
+ function sameKey(a, b) {
91
+ return (a.api_origin === b.api_origin &&
92
+ a.control_plane_session_hash === b.control_plane_session_hash &&
93
+ a.action === b.action &&
94
+ sameTarget(a, b));
95
+ }
96
+ /**
97
+ * Persist an approval. Replaces any existing entry with the same
98
+ * `(api_origin, control_plane_session_hash, action, target)` key and leaves
99
+ * every other entry intact (multi-entry, non-thrashing). Atomic, mode 0600.
100
+ */
101
+ export function saveApproval(approval, path) {
102
+ const existing = readApprovals(path).filter((a) => !sameKey(a, approval));
103
+ existing.push(approval);
104
+ writeApprovals(existing, path);
105
+ }
106
+ /** Delete the whole approval cache — local half of `operator logout`. Idempotent. */
107
+ export function clearApprovals(path) {
108
+ const p = path ?? getWriteAuthSessionPath();
109
+ try {
110
+ rmSync(p, { force: true });
111
+ }
112
+ catch {
113
+ // Best-effort: a failed unlink should never crash logout.
114
+ }
115
+ }
116
+ /** Whether an approval is past its usable life (with a small skew buffer). */
117
+ export function isApprovalExpired(approval, nowMs = Date.now(), skewMs = 10_000) {
118
+ return nowMs + skewMs >= approval.expires_at;
119
+ }
120
+ /**
121
+ * Return the cached approval matching ALL of `(apiOrigin, cpSessionHash,
122
+ * capability, target)` and still live, or `null` if none matches or it is
123
+ * expired. This is the exact-match the gateway's target gate requires — a
124
+ * non-match (wrong action/target/origin/session) fails closed.
125
+ */
126
+ export function loadLiveApproval(q, path, nowMs = Date.now()) {
127
+ for (const a of readApprovals(path)) {
128
+ if (a.api_origin === q.apiOrigin &&
129
+ a.control_plane_session_hash === q.cpSessionHash &&
130
+ a.action === q.capability &&
131
+ sameTarget(a, q.target) &&
132
+ !isApprovalExpired(a, nowMs)) {
133
+ return a;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+ /** Parse a gateway-returned session expiry (ISO string or epoch) to epoch ms. */
139
+ function sessionExpiryMs(session, nowMs) {
140
+ const raw = session?.expires_at ?? session?.absolute_expires_at;
141
+ if (typeof raw === "number" && Number.isFinite(raw))
142
+ return raw > 1e12 ? raw : raw * 1000;
143
+ if (typeof raw === "string") {
144
+ const ms = Date.parse(raw);
145
+ if (Number.isFinite(ms))
146
+ return ms;
147
+ }
148
+ return nowMs + DEFAULT_TTL_MS;
149
+ }
150
+ /**
151
+ * Build a cache entry from the gateway token response + the binding context
152
+ * (the cp-session it was minted under, the API origin, and the `(action,
153
+ * target)` it covers). Expiry is taken from the returned `session`.
154
+ */
155
+ export function approvalFromTokenResponse(resp, binding, nowMs = Date.now()) {
156
+ const amr = Array.isArray(resp.session?.amr)
157
+ ? resp.session.amr.filter((a) => typeof a === "string")
158
+ : undefined;
159
+ return {
160
+ write_auth_token: resp.write_auth_token,
161
+ token_type: typeof resp.token_type === "string" ? resp.token_type : "write_auth",
162
+ header: typeof resp.header === "string" ? resp.header : "X-Run402-Write-Auth",
163
+ action: binding.action,
164
+ ...(binding.target.org_id ? { org_id: binding.target.org_id } : {}),
165
+ ...(binding.target.project_id ? { project_id: binding.target.project_id } : {}),
166
+ expires_at: sessionExpiryMs(resp.session, nowMs),
167
+ control_plane_session_hash: binding.controlPlaneSessionHash,
168
+ control_plane_principal_id: binding.controlPlanePrincipalId,
169
+ api_origin: binding.apiOrigin,
170
+ ...(amr ? { amr } : {}),
171
+ minted_at: nowMs,
172
+ };
173
+ }
174
+ //# sourceMappingURL=write-auth-session.js.map
package/lib/deploy-v2.mjs CHANGED
@@ -34,6 +34,8 @@ import { getSdk } from "./sdk.mjs";
34
34
  import { reportSdkError, fail } from "./sdk-errors.mjs";
35
35
  import { API, allowanceAuthHeaders, getActiveProjectId, resolveProjectId } from "./config.mjs";
36
36
  import { normalizeArgv } from "./argparse.mjs";
37
+ import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.js";
38
+ import { withAutoApprove } from "./operator.mjs";
37
39
 
38
40
  const APPLY_HELP = `run402 deploy apply — Unified deploy primitive (v1.34+)
39
41
 
@@ -810,18 +812,21 @@ async function applyCmd(args) {
810
812
  credentials: githubActionsCredentials({ projectId: releaseSpec.project, apiBase: API }),
811
813
  disablePaidFetch: true,
812
814
  };
813
- } else {
814
- // Preserve the aggressive early exit when no allowance is configured.
815
+ } else if (!loadLiveControlPlaneSession()) {
816
+ // Aggressive early exit when no allowance is configured — unless a
817
+ // wallet-less human is deploying via their operator (control-plane) session.
815
818
  allowanceAuthHeaders("/apply/v1/plans");
816
819
  }
817
820
 
818
821
  try {
819
- const result = await getSdk(sdkOpts)._applyEngine.apply(releaseSpec, {
820
- onEvent: makeStderrEventWriter(opts.quiet),
821
- idempotencyKey,
822
- allowWarnings: opts.allowWarnings,
823
- allowWarningCodes: opts.allowWarningCodes,
824
- });
822
+ const result = await withAutoApprove(() =>
823
+ getSdk(sdkOpts)._applyEngine.apply(releaseSpec, {
824
+ onEvent: makeStderrEventWriter(opts.quiet),
825
+ idempotencyKey,
826
+ allowWarnings: opts.allowWarnings,
827
+ allowWarningCodes: opts.allowWarningCodes,
828
+ }),
829
+ );
825
830
  console.log(JSON.stringify(result, null, 2));
826
831
  } catch (err) {
827
832
  reportDeployApplyError(err, useGithubActionsOidc);
package/lib/functions.mjs CHANGED
@@ -87,12 +87,12 @@ Notes:
87
87
  export default async (req: Request) => Response
88
88
  Deploy may require payment if the project lease has expired.
89
89
 
90
- The deploy response includes:
91
- - runtime_version: the bundled @run402/functions version (e.g. "1.48.0")
92
- - deps_resolved: map of each --deps name to the actually-installed
93
- concrete version (e.g. {"lodash":"4.17.21"})
94
- - warnings (optional, top-level, sibling to the record): non-fatal
95
- notes such as bundle-size advisories
90
+ Deploy routes through the unified apply engine (functions.patch.set), not
91
+ a legacy admin route. The result sets runtime_version and deps_resolved to
92
+ null (apply returns release-level data, not per-function build metadata) -
93
+ read the resolved versions from 'run402 functions list', whose records
94
+ carry both. The result still includes an optional top-level warnings:
95
+ string[] for non-fatal notes such as bundle-size advisories.
96
96
 
97
97
  Examples:
98
98
  run402 functions deploy prj_abc123 stripe-webhook --file handler.ts
package/lib/operator.mjs CHANGED
@@ -23,7 +23,7 @@ import { createServer } from "node:http";
23
23
  import { randomBytes, createHash } from "node:crypto";
24
24
  import { fail, reportSdkError } from "./sdk-errors.mjs";
25
25
  import { getSdk } from "./sdk.mjs";
26
- import { claimWalletOrg, isStepUpRequired } from "#sdk/node";
26
+ import { claimWalletOrg, isStepUpRequired, isOperatorApprovalRequired } from "#sdk/node";
27
27
  import { normalizeArgv, hasHelp, assertKnownFlags, flagValue } from "./argparse.mjs";
28
28
  import {
29
29
  saveOperatorSession,
@@ -38,8 +38,33 @@ import {
38
38
  clearControlPlaneSession,
39
39
  readControlPlaneSession,
40
40
  isControlPlaneSessionExpired,
41
+ loadLiveControlPlaneSession,
41
42
  controlPlaneSessionFromTokenResponse,
42
43
  } from "../core-dist/control-plane-session.js";
44
+ import {
45
+ saveApproval,
46
+ clearApprovals,
47
+ readApprovals,
48
+ hashControlPlaneSession,
49
+ approvalFromTokenResponse,
50
+ } from "../core-dist/write-auth-session.js";
51
+ import { getApiBase } from "../core-dist/config.js";
52
+
53
+ /** Gateway write-auth capabilities → required target scope. */
54
+ const APPROVAL_ACTIONS = {
55
+ "org.project.create": "org",
56
+ "project.deploy": "project",
57
+ "project.secret.write": "project",
58
+ };
59
+
60
+ /** True when `url` is same-origin as the API base (confirm_url validation). */
61
+ function sameApiOrigin(url) {
62
+ try {
63
+ return new URL(url).origin === new URL(getApiBase()).origin;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
43
68
 
44
69
  const CLIENT_NAME = "run402 CLI";
45
70
 
@@ -57,6 +82,8 @@ Usage:
57
82
  run402 operator whoami
58
83
  run402 operator logout
59
84
  run402 operator claim-wallet-org [--org <id>] [--name <label>]
85
+ run402 operator approve --action <cap> (--org <id> | --project <id>) [--no-open]
86
+ run402 operator status
60
87
 
61
88
  Subcommands:
62
89
  login Sign in via the browser. Default = device-flow READ session (powers
@@ -68,6 +95,12 @@ Subcommands:
68
95
  claim-wallet-org Claim an org owned by your wallet's agent into your human identity
69
96
  (ownership transfer). Dual proof: your write-capable session
70
97
  ('login --loopback' first) + a fresh signature from the active wallet.
98
+ approve Mint a passkey operator-approval scoped to one (action, target) so a
99
+ wallet-less human can provision/deploy. --action is one of
100
+ org.project.create (--org), project.deploy (--project),
101
+ project.secret.write (--project). Requires 'login --loopback' first.
102
+ status Show the control-plane login state + each cached approval (action,
103
+ target, expiry). Local, no network.
71
104
 
72
105
  Options:
73
106
  --no-open (login) Do not auto-open the browser; just print the URL.
@@ -275,6 +308,8 @@ async function loopbackLogin(args, { stepUp }) {
275
308
  close();
276
309
 
277
310
  const cached = controlPlaneSessionFromTokenResponse(session);
311
+ // A fresh control-plane session invalidates any approvals bound to the old one.
312
+ clearApprovals();
278
313
  saveControlPlaneSession(cached);
279
314
  process.stderr.write(`\nSigned in (write-capable, provenance=${cached.provenance}).\n`);
280
315
 
@@ -394,6 +429,7 @@ async function logout(args) {
394
429
  }
395
430
  clearOperatorSession();
396
431
  clearControlPlaneSession();
432
+ clearApprovals();
397
433
  console.log(JSON.stringify({ revoked, cleared: true }));
398
434
  }
399
435
 
@@ -510,12 +546,203 @@ async function claimWalletOrgCmd(args) {
510
546
  }
511
547
  }
512
548
 
549
+ /**
550
+ * Run the scoped operator-approval ceremony (loopback + passkey) and cache the
551
+ * minted approval. Requires a live control-plane session. Returns the cached
552
+ * approval (or exits non-zero via fail/reportSdkError). Reused by the
553
+ * `operator approve` verb AND the TTY auto-approve path in provision/deploy.
554
+ */
555
+ export async function mintApproval({ action, orgId, projectId, noOpen = false }) {
556
+ const cp = loadLiveControlPlaneSession();
557
+ if (!cp) {
558
+ return fail({
559
+ code: "OPERATOR_LOGIN_REQUIRED",
560
+ message: "No control-plane session. Run 'run402 operator login --loopback' first, then approve.",
561
+ hint: "operator approval is minted on top of your write-capable control-plane session.",
562
+ });
563
+ }
564
+ const sdk = getSdk();
565
+ const { codeVerifier, codeChallenge, state } = pkce();
566
+ const { ready, codePromise, close } = startLoopbackServer({ expectedState: state, timeoutMs: 300_000 });
567
+
568
+ let port;
569
+ try {
570
+ port = await ready;
571
+ } catch (err) {
572
+ close();
573
+ return fail({ code: "OPERATOR_APPROVE_FAILED", message: `Could not start the loopback server: ${err.message}` });
574
+ }
575
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
576
+
577
+ let challenge;
578
+ try {
579
+ challenge = await sdk.operator.approval.requestChallenge({
580
+ action,
581
+ orgId: orgId ?? undefined,
582
+ projectId: projectId ?? undefined,
583
+ cliRedirectUri: redirectUri,
584
+ codeChallenge,
585
+ state,
586
+ token: cp.control_plane_session_token,
587
+ });
588
+ } catch (err) {
589
+ close();
590
+ return reportSdkError(err);
591
+ }
592
+
593
+ const confirmUrl = challenge?.confirm_url;
594
+ if (!confirmUrl || !sameApiOrigin(confirmUrl)) {
595
+ close();
596
+ return fail({
597
+ code: "OPERATOR_APPROVE_BAD_CONFIRM_URL",
598
+ message: "The gateway returned a confirm_url that is not same-origin as the API base; aborting.",
599
+ });
600
+ }
601
+ const targetLabel = orgId ? `org ${orgId}` : projectId ? `project ${projectId}` : "(no target)";
602
+ process.stderr.write(
603
+ `\nTo approve '${action}' for ${targetLabel}, open and approve with your passkey:\n ${confirmUrl}\n\n`,
604
+ );
605
+ if (!noOpen && process.stderr.isTTY) {
606
+ openBrowser(confirmUrl);
607
+ process.stderr.write("(opening your browser…)\n\n");
608
+ }
609
+ process.stderr.write("Waiting for passkey approval…\n");
610
+
611
+ let code;
612
+ try {
613
+ code = await codePromise;
614
+ } catch (err) {
615
+ close();
616
+ return fail({ code: "OPERATOR_APPROVE_FAILED", message: err.message, hint: "Run 'run402 operator approve' to try again." });
617
+ }
618
+
619
+ let token;
620
+ try {
621
+ token = await sdk.operator.approval.exchangeClaimCode({ code, codeVerifier, state });
622
+ } catch (err) {
623
+ close();
624
+ return reportSdkError(err);
625
+ }
626
+ close();
627
+
628
+ const approval = approvalFromTokenResponse(token, {
629
+ action,
630
+ target: { org_id: orgId ?? undefined, project_id: projectId ?? undefined },
631
+ apiOrigin: new URL(getApiBase()).origin,
632
+ controlPlaneSessionHash: hashControlPlaneSession(cp.control_plane_session_token),
633
+ controlPlanePrincipalId: cp.principal_id || "",
634
+ });
635
+ saveApproval(approval);
636
+ process.stderr.write(`\nApproved '${action}' for ${targetLabel}.\n`);
637
+ return approval;
638
+ }
639
+
640
+ /**
641
+ * Run a write action; on `OperatorApprovalRequiredError` AND an interactive
642
+ * TTY (CLI), run the scoped approval ceremony and retry once. In MCP / CI /
643
+ * non-TTY the typed error is rethrown unchanged — the agent relays the resolved
644
+ * `operator approve …` command rather than a browser opening unexpectedly.
645
+ */
646
+ export async function withAutoApprove(fn) {
647
+ try {
648
+ return await fn();
649
+ } catch (err) {
650
+ if (!isOperatorApprovalRequired(err) || !process.stderr.isTTY) throw err;
651
+ const action = err.capability;
652
+ const target = err.target || {};
653
+ if (!action) throw err;
654
+ process.stderr.write(`\nThis '${action}' needs a one-time operator approval. Opening the browser to approve…\n`);
655
+ await mintApproval({ action, orgId: target.org_id ?? undefined, projectId: target.project_id ?? undefined });
656
+ return await fn(); // retry once with the now-cached approval
657
+ }
658
+ }
659
+
660
+ /**
661
+ * `operator approve --action <cap> (--org <id> | --project <id>)` — mint a
662
+ * passkey approval scoped to one (action, target). Hidden alias: `write-auth`.
663
+ */
664
+ async function approve(args) {
665
+ assertKnownFlags(
666
+ args,
667
+ ["--help", "-h", "--no-open", "--action", "--org", "--project"],
668
+ ["--action", "--org", "--project"],
669
+ );
670
+ const action = flagValue(args, "--action");
671
+ const org = flagValue(args, "--org");
672
+ const project = flagValue(args, "--project");
673
+
674
+ if (!action || !APPROVAL_ACTIONS[action]) {
675
+ fail({
676
+ code: "BAD_FLAG",
677
+ message: `--action must be one of: ${Object.keys(APPROVAL_ACTIONS).join(", ")}`,
678
+ details: { flag: "--action", value: action, allowed: Object.keys(APPROVAL_ACTIONS) },
679
+ });
680
+ }
681
+ const scope = APPROVAL_ACTIONS[action];
682
+ if (scope === "org" && !org) fail({ code: "BAD_FLAG", message: `--org <id> is required for --action ${action}` });
683
+ if (scope === "project" && !project) fail({ code: "BAD_FLAG", message: `--project <id> is required for --action ${action}` });
684
+ if (scope === "org" && project) fail({ code: "BAD_FLAG", message: `--project is not valid for org-scoped --action ${action}; use --org` });
685
+ if (scope === "project" && org) fail({ code: "BAD_FLAG", message: `--org is not valid for project-scoped --action ${action}; use --project` });
686
+
687
+ const approval = await mintApproval({
688
+ action,
689
+ orgId: org ?? undefined,
690
+ projectId: project ?? undefined,
691
+ noOpen: args.includes("--no-open"),
692
+ });
693
+ console.log(
694
+ JSON.stringify({
695
+ approved: true,
696
+ action,
697
+ ...(org ? { org_id: org } : {}),
698
+ ...(project ? { project_id: project } : {}),
699
+ expires_at: new Date(approval.expires_at).toISOString(),
700
+ }),
701
+ );
702
+ }
703
+
704
+ /** `operator status` — operator-login state + each cached approval's (action, target, expiry). */
705
+ async function status(args) {
706
+ assertKnownFlags(args, ["--help", "-h"]);
707
+ const now = Date.now();
708
+ const cp = readControlPlaneSession();
709
+ const liveCp = cp && !isControlPlaneSessionExpired(cp, now) ? cp : null;
710
+ const approvals = readApprovals()
711
+ .filter((a) => a.expires_at > now)
712
+ .map((a) => ({
713
+ action: a.action,
714
+ org_id: a.org_id ?? null,
715
+ project_id: a.project_id ?? null,
716
+ expires_at: new Date(a.expires_at).toISOString(),
717
+ }));
718
+ const out = {
719
+ operator_login: liveCp
720
+ ? { active: true, provenance: liveCp.provenance, amr: liveCp.amr, expires_at: new Date(liveCp.expires_at).toISOString() }
721
+ : { active: false },
722
+ approvals,
723
+ };
724
+ if (liveCp) {
725
+ process.stderr.write(`Operator login: active (${liveCp.provenance})\n`);
726
+ process.stderr.write(
727
+ approvals.length
728
+ ? `Approvals (${approvals.length}):\n` +
729
+ approvals.map((a) => ` - ${a.action} ${a.org_id || a.project_id || ""} (expires ${a.expires_at})`).join("\n") +
730
+ "\n"
731
+ : "Approvals: none. Run 'run402 operator approve --action <cap> --org|--project <id>'.\n",
732
+ );
733
+ } else {
734
+ process.stderr.write("Operator login: none. Run 'run402 operator login --loopback'.\n");
735
+ }
736
+ console.log(JSON.stringify(out, null, 2));
737
+ }
738
+
513
739
  export async function run(sub, args = []) {
514
740
  args = normalizeArgv(args);
515
741
  if (!sub || sub === "--help" || sub === "-h" || hasHelp(args)) {
516
742
  console.log(HELP);
517
743
  process.exit(0);
518
744
  }
745
+ if (sub === "write-auth") sub = "approve"; // hidden alias (transport name)
519
746
  switch (sub) {
520
747
  case "login":
521
748
  await login(args);
@@ -532,6 +759,12 @@ export async function run(sub, args = []) {
532
759
  case "claim-wallet-org":
533
760
  await claimWalletOrgCmd(args);
534
761
  break;
762
+ case "approve":
763
+ await approve(args);
764
+ break;
765
+ case "status":
766
+ await status(args);
767
+ break;
535
768
  default:
536
769
  fail({
537
770
  code: "BAD_USAGE",
package/lib/projects.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { loadKeyStore, API, allowanceAuthHeaders, resolveProjectId, getActiveProjectId } from "./config.mjs";
3
3
  import { loadLiveOperatorSession } from "../core-dist/operator-session.js";
4
+ import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.js";
5
+ import { withAutoApprove } from "./operator.mjs";
4
6
  import { getSdk } from "./sdk.mjs";
5
7
  import { reportSdkError, fail, parseFlagJson } from "./sdk-errors.mjs";
6
8
  import { assertKnownFlags, failBadProjectId, flagValue, hasHelp, normalizeArgv, positionalArgs, resolvePositionalProject, validateRegularFile } from "./argparse.mjs";
@@ -302,13 +304,16 @@ async function provision(args) {
302
304
  });
303
305
  }
304
306
  }
305
- // Preserve the aggressive early exit when no allowance is configured —
306
- // gives the user a more specific prompt than the SDK's 401/402 path.
307
- allowanceAuthHeaders("/projects/v1");
307
+ // Aggressive early exit when no agent allowance is configured — but only when
308
+ // there's also no operator (control-plane) session, since a wallet-less human
309
+ // provisions into an org via their operator approval instead of a wallet.
310
+ if (!loadLiveControlPlaneSession()) allowanceAuthHeaders("/projects/v1");
308
311
 
309
312
  const activeBefore = getActiveProjectId();
310
313
  try {
311
- const data = await getSdk().projects.provision({ tier: opts.tier, name: opts.name, orgId: opts.orgId });
314
+ const data = await withAutoApprove(() =>
315
+ getSdk().projects.provision({ tier: opts.tier, name: opts.name, orgId: opts.orgId }),
316
+ );
312
317
  const activeAfter = getActiveProjectId();
313
318
  const out = { ...data };
314
319
  if (activeBefore && activeAfter && activeBefore !== activeAfter) {
package/lib/sdk.mjs CHANGED
@@ -10,5 +10,7 @@
10
10
  import { run402 } from "#sdk/node";
11
11
 
12
12
  export function getSdk(opts = {}) {
13
- return run402(opts);
13
+ // surface: "cli" opts the default credential resolution into `auto` — wallet
14
+ // if present, else the operator (control-plane) session + matched approval.
15
+ return run402({ surface: "cli", ...opts });
14
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
5
5
  "type": "module",
6
6
  "bin": {