@cotal-ai/auth 0.90.0 → 0.91.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 (80) hide show
  1. package/dist/authority-client.d.ts.map +1 -1
  2. package/dist/authority-client.js +4 -1
  3. package/dist/authority-client.js.map +1 -1
  4. package/dist/chat-parent-pin.d.ts +43 -0
  5. package/dist/chat-parent-pin.d.ts.map +1 -0
  6. package/dist/chat-parent-pin.js +115 -0
  7. package/dist/chat-parent-pin.js.map +1 -0
  8. package/dist/chat-session-authority.d.ts +120 -0
  9. package/dist/chat-session-authority.d.ts.map +1 -0
  10. package/dist/chat-session-authority.js +924 -0
  11. package/dist/chat-session-authority.js.map +1 -0
  12. package/dist/chat-session-row.d.ts +117 -0
  13. package/dist/chat-session-row.d.ts.map +1 -0
  14. package/dist/chat-session-row.js +358 -0
  15. package/dist/chat-session-row.js.map +1 -0
  16. package/dist/chat-session-store.d.ts +126 -0
  17. package/dist/chat-session-store.d.ts.map +1 -0
  18. package/dist/chat-session-store.js +392 -0
  19. package/dist/chat-session-store.js.map +1 -0
  20. package/dist/commands.d.ts +3 -1
  21. package/dist/commands.d.ts.map +1 -1
  22. package/dist/commands.js +497 -122
  23. package/dist/commands.js.map +1 -1
  24. package/dist/continuity.js +1 -1
  25. package/dist/continuity.js.map +1 -1
  26. package/dist/daemon-flags.d.ts +42 -0
  27. package/dist/daemon-flags.d.ts.map +1 -0
  28. package/dist/daemon-flags.js +12 -0
  29. package/dist/daemon-flags.js.map +1 -0
  30. package/dist/handle-inspect.d.ts +101 -0
  31. package/dist/handle-inspect.d.ts.map +1 -0
  32. package/dist/handle-inspect.js +50 -0
  33. package/dist/handle-inspect.js.map +1 -0
  34. package/dist/handle-verification.d.ts +75 -0
  35. package/dist/handle-verification.d.ts.map +1 -0
  36. package/dist/handle-verification.js +135 -0
  37. package/dist/handle-verification.js.map +1 -0
  38. package/dist/idp.d.ts +23 -1
  39. package/dist/idp.d.ts.map +1 -1
  40. package/dist/idp.js +54 -8
  41. package/dist/idp.js.map +1 -1
  42. package/dist/index.d.ts +3 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +3 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/ledger.d.ts +58 -5
  47. package/dist/ledger.d.ts.map +1 -1
  48. package/dist/ledger.js +126 -6
  49. package/dist/ledger.js.map +1 -1
  50. package/dist/lifecycle-registry.d.ts +1 -70
  51. package/dist/lifecycle-registry.d.ts.map +1 -1
  52. package/dist/lifecycle-registry.js +4 -35
  53. package/dist/lifecycle-registry.js.map +1 -1
  54. package/dist/login.d.ts +9 -0
  55. package/dist/login.d.ts.map +1 -1
  56. package/dist/login.js +53 -8
  57. package/dist/login.js.map +1 -1
  58. package/dist/permissions.js +4 -0
  59. package/dist/permissions.js.map +1 -1
  60. package/dist/provider.d.ts +23 -0
  61. package/dist/provider.d.ts.map +1 -1
  62. package/dist/provider.js +244 -44
  63. package/dist/provider.js.map +1 -1
  64. package/dist/service-adapter.d.ts +6 -0
  65. package/dist/service-adapter.d.ts.map +1 -0
  66. package/dist/service-adapter.js +120 -0
  67. package/dist/service-adapter.js.map +1 -0
  68. package/dist/service.d.ts +61 -2
  69. package/dist/service.d.ts.map +1 -1
  70. package/dist/service.js +399 -84
  71. package/dist/service.js.map +1 -1
  72. package/dist/store.d.ts +10 -0
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js +25 -8
  75. package/dist/store.js.map +1 -1
  76. package/dist/token.d.ts +1 -1
  77. package/dist/token.d.ts.map +1 -1
  78. package/dist/token.js +4 -1
  79. package/dist/token.js.map +1 -1
  80. package/package.json +11 -7
package/dist/commands.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { authServiceFlags } from "./daemon-flags.js";
1
2
  /**
2
3
  * Self-registering `login` / `logout` commands (the delivery-daemon pattern): importing
3
4
  * `@cotal-ai/auth` registers them into the core `Registry`, and the `cotal` binary pulls the
@@ -5,35 +6,56 @@
5
6
  * (`~/.cotal`, `COTAL_HOME`-overridable) — per human, per machine, NOT per checkout: you are
6
7
  * logged in as YOU across every repo on this box.
7
8
  */
8
- import { readFileSync, writeFileSync } from "node:fs";
9
- import { assertLifecycleToken, isLoopbackLiteral, registry, resolveAuthProvider, resolveSpaceCatalogConsumer } from "@cotal-ai/core";
10
- import { CLI_USER_ACTOR, findCotalRoot, getSpaceAuth, homeCotalDir, loadMeshes, probeLiveness, removeCatalogMeshes, resolveSpace, userAuthStateDir, workspaceSecretStore } from "@cotal-ai/workspace";
11
- import { deleteIdpSession, deleteIdpSpaceCatalog, establishIdpSession, loadIdpSession, normalizeIdpUrl, revokeIdpSession, } from "./login.js";
9
+ import { readFileSync } from "node:fs";
10
+ import * as prompts from "@clack/prompts";
11
+ import { assertDerivedOwnerToken, assertLifecycleToken, assertValidChannel, assertValidOwnerToken, assertValidRole, isLoopbackLiteral, registry, resolveAuthProvider, resolveSpaceCatalogConsumer, writePrivateRecord } from "@cotal-ai/core";
12
+ import { CommandFailure, credentialMintFlags, validateCredentialMintArguments, exportSpaceSigner, signerExportFlags, validateSignerExportInput, inputDisabledByFlag, isWorkspaceTargetError, resolveMeshTarget, targetErrorCode, spaceFlag, isCommandFailure, oneLine, jsonFlag, plainFlag, presentationOf, renderReport, terminalWidth, writeResult, CLI_USER_ACTOR, findCotalRoot, getSpaceAuth, homeCotalDir, loadMeshes, probeLiveness, removeCatalogMeshes, untilInputEnds, userAuthStateDir, deprecatedDaemonEntry, workspaceSecretStore } from "@cotal-ai/workspace";
13
+ import { deleteIdpSession, deleteIdpSpaceCatalog, establishIdpSession, IdpUnreachableError, loadIdpSession, listIdpAccounts, normalizeIdpUrl, revokeIdpSession, } from "./login.js";
14
+ import { inspectHandleFile } from "./handle-inspect.js";
12
15
  import { checkedEviction } from "./barrier-evict.js";
13
16
  import { deriveOwnerForIdpSubject } from "./derive.js";
14
17
  import { withDeliveryAdminEndpoint } from "./delivery-admin.js";
15
- import { findInteractiveActor, findManagedActor, grantActor, loadActorLedger, revokeActor } from "./ledger.js";
18
+ import { findInteractiveActor, findManagedActor, loadActorLedger, withManagedGrantTransaction } from "./ledger.js";
16
19
  import { INTERACTIVE_RETIRE_PATH, runAuthService } from "./service.js";
17
20
  import { loadAuthServiceInfo, loadOwnerSecret, loadPinnedIdp } from "./store.js";
18
21
  const DEFAULT_CLIENT_ID = "cotal-cli";
19
22
  /** Every operational failure in these commands is a deliberately-legible thrown sentence
20
23
  * (a refused client id, a revoked session, a malformed IdP response …) — the CLI's generic
21
24
  * catch would re-throw it into a raw stack trace. Print the sentence, exit 1. */
22
- async function legibly(fn) {
25
+ async function legibly(fn, failure) {
23
26
  try {
24
27
  await fn();
25
28
  }
26
29
  catch (e) {
30
+ if (isCommandFailure(e))
31
+ throw e;
32
+ if (failure)
33
+ throw failure();
27
34
  console.error(e instanceof Error ? e.message : String(e));
28
35
  process.exit(1);
29
36
  }
30
37
  }
31
- async function retireInteractiveActorLifecycle(dir, row, operation) {
38
+ /** The refusal each status of the retirement route is, once the service states the retirement did
39
+ * not run (`outcome: "not-executed"`). */
40
+ const RETIREMENT_REFUSAL = {
41
+ 400: "UNSUPPORTED", 401: "PERMISSION_DENIED", 403: "PERMISSION_DENIED", 404: "CONFLICT", 405: "UNSUPPORTED",
42
+ 409: "CONFLICT", 412: "CONFLICT", 415: "UNSUPPORTED", 503: "DEPENDENCY_UNAVAILABLE",
43
+ };
44
+ /** Retire the row's interactive lifecycle through the auth service. True only when this call
45
+ * retired it: a row with no lifecycle, a lifecycle the service reports never started
46
+ * (`retired: false`) and one that was already retired change nothing here. A retirement whose
47
+ * request never reached the service, or that the service states did not run, refuses, naming the
48
+ * unchanged row; any other failure is unknown. Neither writes the row. */
49
+ async function retireInteractiveActorLifecycle(dir, space, row, operation) {
32
50
  if (!row.lifecycleUid)
33
- return;
51
+ return false;
52
+ const target = { space, owner: row.owner, actor: row.actor };
53
+ const subject = `cannot ${operation} "${row.owner}/${row.actor}"`;
54
+ const notRun = (code, why, remedy) => new CommandFailure("refused", code, `${subject}: ${why}; its lifecycle was not retired and the actor row was not changed`, { target, remedy });
55
+ const mayHaveRun = (why) => new CommandFailure("unknown", "OPERATION_UNKNOWN", `${subject}: ${why}; its lifecycle may have been retired, and the actor row was not changed`, { target, remedy: "read the authoritative grant ledger before another mutation" });
34
56
  const service = loadAuthServiceInfo(dir);
35
57
  if (!service)
36
- throw new Error(`cannot ${operation} "${row.owner}/${row.actor}": the auth service is not running, so its current lifecycle cannot be retired safely. Restart the stack with \`cotal up\`, then retry; the actor row was not changed.`);
58
+ throw notRun("DEPENDENCY_UNAVAILABLE", "the auth service is not running", "restart the stack with `cotal up`, then retry");
37
59
  let response;
38
60
  try {
39
61
  response = await fetch(`${service.url}${INTERACTIVE_RETIRE_PATH}`, {
@@ -47,32 +69,108 @@ async function retireInteractiveActorLifecycle(dir, row, operation) {
47
69
  });
48
70
  }
49
71
  catch (error) {
50
- throw new Error(`cannot ${operation} "${row.owner}/${row.actor}": its lifecycle retirement could not reach the auth service (${error instanceof Error ? error.message : String(error)}). The actor row was not changed; recover with \`cotal up\`, then retry.`);
72
+ // A refused connection carried no request. Any other transport failure, a timeout included, may
73
+ // have followed a request the service received.
74
+ if (error?.cause?.code === "ECONNREFUSED")
75
+ throw notRun("DEPENDENCY_UNAVAILABLE", `the auth service at ${service.url} refused the connection`, "restart the stack with `cotal up`, then retry");
76
+ throw mayHaveRun(`its lifecycle retirement request failed (${error instanceof Error ? error.message : String(error)})`);
51
77
  }
52
78
  const body = await response.json().catch(() => ({}));
53
- if (!response.ok)
54
- throw new Error(`cannot ${operation} "${row.owner}/${row.actor}": the auth service refused lifecycle retirement (${body.error ?? `HTTP ${response.status}`}). The actor row was not changed; repair the stack, then retry.`);
79
+ if (!response.ok) {
80
+ const answer = body.error ?? `HTTP ${response.status}`;
81
+ // Only the service knows whether the retirement began, and an error without its outcome may
82
+ // have followed a barrier that had (SPEC 13.3).
83
+ if (body.outcome !== "not-executed")
84
+ throw mayHaveRun(`the auth service answered lifecycle retirement with an error (${answer})`);
85
+ const code = RETIREMENT_REFUSAL[response.status] ?? "DEPENDENCY_UNAVAILABLE";
86
+ throw notRun(code, `the auth service refused lifecycle retirement (${answer})`, code === "CONFLICT" ? "re-read `cotal actor list`, then decide whether to grant or revoke again" : "restart the stack with `cotal up`, then retry");
87
+ }
88
+ if (typeof body.retired !== "boolean" || body.lifecycleUid !== row.lifecycleUid)
89
+ throw mayHaveRun(`the auth service answered lifecycle retirement for ${row.lifecycleUid} without a readable result`);
90
+ return body.retired && body.alreadyRetired !== true;
91
+ }
92
+ function canChooseAccount() {
93
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY) && !inputDisabledByFlag() && process.env.COTAL_NO_PROMPT !== "1";
94
+ }
95
+ function validateAccountArguments(args, mode) {
96
+ presentationOf(args.values);
97
+ if (args.values.idp !== undefined) {
98
+ try {
99
+ normalizeIdpUrl(String(args.values.idp));
100
+ }
101
+ catch {
102
+ throw new CommandFailure("refused", "USAGE_INVALID", "use an HTTPS provider auth URL, or an HTTP loopback IP URL, without embedded credentials, a query or a fragment");
103
+ }
104
+ }
105
+ if (args.values["client-id"] !== undefined && !String(args.values["client-id"]).trim())
106
+ throw new CommandFailure("refused", "USAGE_INVALID", "--client-id cannot be empty");
107
+ if (mode === "login" && inputDisabledByFlag())
108
+ throw new CommandFailure("refused", "UNSUPPORTED", "this provider login uses device approval and has no noninteractive grant in this client", { remedy: "sign in from an interactive terminal" });
109
+ }
110
+ async function chooseAccount(args, mode) {
111
+ if (typeof args.values.idp === "string")
112
+ return normalizeIdpUrl(args.values.idp);
113
+ let cached;
114
+ try {
115
+ cached = listIdpAccounts(homeCotalDir());
116
+ }
117
+ catch {
118
+ throw new CommandFailure("refused", "SCHEMA_INVALID", "cached account metadata could not be read; it was not reset");
119
+ }
120
+ const providers = new Map(cached.map((item) => [item.idp, item.sub]));
121
+ if (mode === "login")
122
+ for (const mesh of loadMeshes())
123
+ if (mesh.userAuth?.idp.url)
124
+ providers.set(normalizeIdpUrl(mesh.userAuth.idp.url), providers.get(normalizeIdpUrl(mesh.userAuth.idp.url)));
125
+ if (providers.size === 1)
126
+ return providers.keys().next().value;
127
+ if (mode === "logout" && providers.size === 0)
128
+ return undefined;
129
+ if (!canChooseAccount() || presentationOf(args.values) === "json")
130
+ throw new CommandFailure("refused", "USAGE_INVALID", "select the provider explicitly with --idp outside an interactive terminal");
131
+ if (providers.size) {
132
+ const chosen = await untilInputEnds((signal) => prompts.select({ message: mode === "login" ? "Choose a sign-in provider" : "Choose the account to sign out", options: [...providers].map(([idp, sub]) => ({ value: idp, label: oneLine(idp), ...(sub ? { hint: oneLine(sub) } : {}) })), signal }));
133
+ return prompts.isCancel(chosen) ? undefined : chosen;
134
+ }
135
+ const answer = await untilInputEnds((signal) => prompts.text({ message: "Sign-in provider auth URL", placeholder: "https://example.org/api/auth", signal, validate: (value) => {
136
+ try {
137
+ normalizeIdpUrl(value ?? "");
138
+ }
139
+ catch {
140
+ return "Enter the HTTPS auth base URL without a query, fragment or embedded credentials.";
141
+ }
142
+ } }));
143
+ return prompts.isCancel(answer) ? undefined : normalizeIdpUrl(answer);
55
144
  }
56
145
  async function runLogin(args) {
57
146
  const values = args.values;
58
- if (!values.idp) {
59
- console.error("usage: cotal login --idp <auth base URL> [--client-id <id>] (Better Auth: <origin>/api/auth)");
60
- process.exit(1);
147
+ validateAccountArguments(args, "login");
148
+ const presentation = presentationOf(args.values);
149
+ const idpArg = await chooseAccount(args, "login");
150
+ if (idpArg === undefined) {
151
+ console.error("Sign-in cancelled. No provider session was requested.");
152
+ return;
61
153
  }
62
- const idpArg = values.idp;
63
154
  await legibly(async () => {
64
155
  const idp = normalizeIdpUrl(idpArg);
65
156
  // establishIdpSession proves the session mints user JWTs BEFORE persisting it — a failed
66
- // proof leaves no cache entry to fool requireIdpSession later.
157
+ // proof leaves no cache entry to fool requireIdpSession later. Every provider request runs
158
+ // before that, so a provider that cannot be reached leaves nothing cached: a dependency
159
+ // refusal, never a raw transport error.
67
160
  const { session, sub, previousSub, label } = await establishIdpSession({
68
161
  dir: homeCotalDir(),
69
162
  idpUrl: idp,
70
163
  clientId: values["client-id"] ?? DEFAULT_CLIENT_ID,
71
164
  onPrompt: (p) => {
72
- console.log(`\nTo approve this sign-in, open:\n\n ${p.verificationUriComplete}\n`);
73
- console.log(`(or go to ${p.verificationUri} and enter the code ${p.userCode})\n`);
74
- console.log(`Waiting for approval - the code expires in ${Math.ceil(p.expiresInSec / 60)} min. Ctrl-C to abort.`);
165
+ console.error(renderReport({ sections: [{ title: "Approve sign-in", blocks: [
166
+ { kind: "fields", rows: [["Provider", idp], ["Open", p.verificationUriComplete], ["Or open", p.verificationUri], ["Approval code", p.userCode], ["Expires", `in ${Math.ceil(p.expiresInSec / 60)} minutes`]] },
167
+ { kind: "lines", lines: [{ text: "Waiting for the provider's approval. Ctrl-C aborts this request. Do not share the approval link or code.", tone: "hint" }] },
168
+ ] }] }, presentation === "plain" ? "plain" : "rich", terminalWidth(process.stderr)));
75
169
  },
170
+ }).catch((error) => {
171
+ if (error instanceof IdpUnreachableError)
172
+ throw new CommandFailure("refused", "DEPENDENCY_UNAVAILABLE", `${error.message}; no session was cached`, { target: { idp }, remedy: "check --idp <auth base URL>; it must serve <base>/device/code" });
173
+ throw error;
76
174
  });
77
175
  const provider = resolveAuthProvider();
78
176
  if (previousSub) {
@@ -86,53 +184,61 @@ async function runLogin(args) {
86
184
  }
87
185
  // WHO signed in must be human-readable (per-user auth exists for operator-visible identity):
88
186
  // prefer the IdP's email/name claim; the raw `sub` stays as the stable id (dim when secondary).
89
- const who = label ? `${label} (${sub})` : sub;
90
- console.log(`\nLogged in to ${idp} as ${who}. Session cached in ${homeCotalDir()} until ${new Date(session.expiresAt * 1000).toISOString()}.`);
91
- // Signing in proves WHO you are; each mesh separately lets you in. Hand the human the exact
92
- // next step — their sub is the one thing the operator needs from them.
93
- console.log(`Not yet on a mesh? Its operator lets you in with: cotal actor grant ${CLI_USER_ACTOR} --sub ${sub} --full (all channels, scope spawn,role:default; or name --scope, --allow-subscribe and --allow-publish instead of --full)`);
187
+ console.log(renderReport({ sections: [
188
+ { title: "Signed in", blocks: [{ kind: "fields", rows: [["Account", label || sub], ["Subject", sub], ["Provider", idp], ["Session expires", new Date(session.expiresAt * 1000).toISOString()]] }, { kind: "lines", lines: [{ text: "Account identity is verified. Each space's grants decide access separately. No session token is printed.", tone: "hint" }] }] },
189
+ { title: "Next", blocks: [{ kind: "commands", items: [{ command: "cotal space", description: "See registered spaces and choose one" }, { command: "cotal sync", description: "Refresh spaces the provider makes available to this account" }] }] },
190
+ ] }, presentation === "plain" ? "plain" : "rich", terminalWidth(process.stdout)));
94
191
  });
95
192
  }
96
193
  async function runLogout(args) {
97
- const values = args.values;
98
- if (!values.idp) {
99
- console.error("usage: cotal logout --idp <auth base URL>");
100
- process.exit(1);
194
+ validateAccountArguments(args, "logout");
195
+ const idp = await chooseAccount(args, "logout");
196
+ const presentation = presentationOf(args.values);
197
+ if (idp === undefined) {
198
+ writeResult(presentation, { command: "logout", target: null, result: { kind: "logout", providerSelected: false, serverRevoked: null }, noOp: true }, () => ({ sections: [{ title: "No sign-out performed", blocks: [{ kind: "lines", lines: ["No provider was selected. No server session was revoked."] }] }] }));
199
+ return;
101
200
  }
102
- const idpArg = values.idp;
103
- await legibly(async () => {
104
- const idp = normalizeIdpUrl(idpArg);
105
- const dir = homeCotalDir();
106
- const session = loadIdpSession(dir, idp);
107
- if (!session) {
108
- console.log(`not logged in to ${idp} - nothing to clear`);
109
- return;
110
- }
111
- try {
112
- await revokeIdpSession(idp, session.token);
113
- }
114
- catch (e) {
115
- // KEEP the local session on a failed revoke: the cached token is the only handle that can
116
- // retry the revoke from the CLI, and a still-live server-side session that can no longer be
117
- // revoked is worse than a lingering local cache. Fail loud with the recourse; the operator
118
- // re-runs `cotal logout`.
119
- throw new Error(`could not revoke the server-side session at ${idp} (${e instanceof Error ? e.message : String(e)}). ` +
120
- `Your local login is kept so you can retry \`cotal logout --idp ${idp}\`; if it keeps failing, revoke the session from the IdP directly.`);
121
- }
201
+ const dir = homeCotalDir();
202
+ let session;
203
+ try {
204
+ session = loadIdpSession(dir, idp);
205
+ }
206
+ catch {
207
+ throw new CommandFailure("refused", "SCHEMA_INVALID", "the cached login could not be read; nothing was cleared", { target: { idp } });
208
+ }
209
+ if (!session) {
210
+ writeResult(presentation, { command: "logout", target: { idp }, result: { kind: "logout", localLogin: "absent", serverRevoked: null }, noOp: true }, () => ({ sections: [{ title: "No cached sign-in", blocks: [{ kind: "fields", rows: [["Provider", idp]] }, { kind: "lines", lines: [{ text: "No local session was found. Other server sessions were not checked or revoked.", tone: "hint" }] }] }] }));
211
+ return;
212
+ }
213
+ try {
214
+ await revokeIdpSession(idp, session.token);
215
+ }
216
+ catch {
217
+ throw new CommandFailure("unknown", "OPERATION_UNKNOWN", "server-side session revocation was not confirmed; the local login is kept so it can be retried", { target: { idp }, remedy: "retry logout for this provider, or revoke the session at the provider" });
218
+ }
219
+ let localCleared = false;
220
+ let removed = [];
221
+ try {
122
222
  deleteIdpSession(dir, idp);
223
+ localCleared = true;
123
224
  if (session.sub)
124
- removeCatalogMeshes(deleteIdpSpaceCatalog(dir, idp, session.sub));
125
- console.log(`Logged out of ${idp} - server-side session revoked, local cache cleared.`);
126
- });
225
+ removed = removeCatalogMeshes(deleteIdpSpaceCatalog(dir, idp, session.sub));
226
+ }
227
+ catch {
228
+ throw new CommandFailure("partial", "OPERATION_PARTIAL", localCleared ? "the server session was revoked and the cached login cleared, but discovered-space cleanup was not confirmed" : "the server session was revoked, but local cleanup was not confirmed", { target: { idp }, changed: [{ kind: "idp-session", idp, revoked: true }, ...(localCleared ? [{ kind: "local-login", idp, cleared: true }] : [])] });
229
+ }
230
+ writeResult(presentation, { command: "logout", target: { idp }, result: { kind: "logout", serverRevoked: true, localLoginCleared: true, removedRegistrations: removed } }, (result) => ({ sections: [
231
+ { title: "Signed out", blocks: [{ kind: "fields", rows: [["Provider", idp], ["Provider session", { text: "revoked", tone: "ok" }], ["Local login", "cleared"], ["Discovered spaces removed", String(result.removedRegistrations.length)]] }, { kind: "lines", lines: [{ text: "This does not claim that independently running agents or other provider sessions were stopped.", tone: "hint" }] }] },
232
+ { title: "Next", blocks: [{ kind: "commands", items: [{ command: "cotal login", description: "Sign in deliberately when needed" }] }] },
233
+ ] }));
127
234
  }
128
- /** The space-scoped provider state the `actor` commands operate on: the ledger dir
129
- * (`userAuthStateDir` — the multi-space-ready layout; nothing user-auth lives flat in
130
- * `.cotal/auth/`) plus the local secret store (these are workstation commands — the ledger
131
- * machine — so the workspace FS composition IS the correct one, not a fallback). */
132
- function actorState(space) {
133
- const s = space ?? resolveSpace(process.cwd());
134
- const root = findCotalRoot();
135
- return { dir: userAuthStateDir(root, s), space: s, store: workspaceSecretStore(root) };
235
+ /** The space-scoped provider state the `actor` commands operate on, at the target
236
+ * {@link localAuthTarget} resolved: the ledger dir (`userAuthStateDir` — the multi-space-ready
237
+ * layout; nothing user-auth lives flat in `.cotal/auth/`) plus the local secret store (these are
238
+ * workstation commands — the ledger machine — so the workspace FS composition IS the correct
239
+ * one, not a fallback). */
240
+ function actorState(target) {
241
+ return { dir: userAuthStateDir(target.root, target.space), space: target.space, store: workspaceSecretStore(target.root) };
136
242
  }
137
243
  /** Resolve the operator's target (owner) for `actor grant`/`revoke`: an explicit derived `--owner`
138
244
  * token, or `--sub` (the IdP subject `cotal login` prints) derived through the SAME frozen encoding
@@ -141,20 +247,18 @@ function actorState(space) {
141
247
  * them a grant would write an INERT LOCAL row and print a misleading success while the mesh's real
142
248
  * ledger is untouched (an off-machine `--owner` cannot mutate authority), so we refuse instead. */
143
249
  async function resolveGrantOwner(st, values) {
144
- if (values.owner && values.sub)
145
- throw new Error("pass --owner OR --sub, not both");
146
- if (!values.owner && !values.sub)
147
- throw new Error("say who: --sub <IdP subject> (shown by `cotal login`) or --owner <u_…>");
148
250
  const secret = await loadOwnerSecret(st.store, st.space);
149
251
  const idp = loadPinnedIdp(st.dir);
150
252
  if (!secret || !idp)
151
- throw new Error(`user auth is not enabled for this space here (no owner secret/IdP pin for "${st.space}"). The actor ledger lives on the machine that ran \`cotal up --user-auth --idp <url>\`; run the \`actor\` commands there`);
253
+ throw new CommandFailure("refused", "UNSUPPORTED", "the selected local root has no authoritative user-auth owner secret and IdP pin; no inert grant was written", { target: { space: st.space }, remedy: "manage grants on the machine and root that host this space's user-auth ledger" });
152
254
  if (values.owner)
153
255
  return values.owner;
154
256
  return deriveOwnerForIdpSubject(secret, idp.issuer, values.sub);
155
257
  }
156
- /** The two explicit shapes of `actor grant`, printed by its usage and its refusal. */
157
- const GRANT_FORMS = "cotal actor grant <actor> --sub <IdP subject> --full (all channels, scope spawn,role:default; a named flag still overrides its field), or cotal actor grant <actor> --sub <IdP subject> --scope a,b --allow-subscribe a,b --allow-publish a,b ('' = none, e.g. --scope '' --allow-subscribe general --allow-publish ''). [--role r] [--label l]";
258
+ /** The two explicit shapes of a grant in either spelling, printed by its usage refusal. */
259
+ const grantForms = (command) => `cotal ${command} grant <actor> --sub <IdP subject> --full (all channels, scope spawn,role:default; a named flag still overrides its field), or cotal ${command} grant <actor> --sub <IdP subject> --scope a,b --allow-subscribe a,b --allow-publish a,b ('' = none, e.g. --scope '' --allow-subscribe general --allow-publish ''). [--role r] [--label l]`;
260
+ /** The grant verbs of each spelling. */
261
+ const grantUsage = (command) => command === "auth" ? "use auth, auth grants, auth grant <actor>, or auth revoke <actor>" : "use actor list, actor grant <actor>, or actor revoke <actor>";
158
262
  const csv = (s, dflt) => s === undefined ? dflt : s.split(",").map((x) => x.trim()).filter(Boolean);
159
263
  /** Close a revoked principal's LIVE window (D5 acceptance gate: removal stops live delivery
160
264
  * IMMEDIATELY, not at bearer expiry): mint an ephemeral supervisor cred from the local signer and
@@ -162,10 +266,10 @@ const csv = (s, dflt) => s === undefined ? dflt : s.split(",").map((x) => x.trim
162
266
  * verify). BEST-EFFORT with honest copy: deny-new is already committed by the ledger revoke
163
267
  * before this runs, so a missing daemon/signer/registry only widens the end back to the bearer's
164
268
  * own expiry — reported, never silent, and never a reason to fail the revoke. */
165
- async function evictRevokedPrincipal(space, principal) {
269
+ async function evictRevokedPrincipal(space, principal, rootOverride) {
166
270
  const fallback = (why) => `live-connection eviction skipped (${why}) - deny-new is committed and the revoke cannot be re-run; the already-open connection ends at its current bearer's expiry. To evict live connections at revoke time, run \`cotal actor revoke\` from the mesh root (local signer + registry).`;
167
271
  const incomplete = (why) => `live-connection eviction INCOMPLETE (${why}) - run \`cotal doctor auth\``;
168
- const root = findCotalRoot();
272
+ const root = rootOverride ?? findCotalRoot();
169
273
  const auth = await getSpaceAuth(workspaceSecretStore(root), space);
170
274
  if (!auth)
171
275
  return fallback("no local signer here");
@@ -187,46 +291,61 @@ async function evictRevokedPrincipal(space, principal) {
187
291
  return fallback(e instanceof Error ? e.message : String(e));
188
292
  }
189
293
  }
190
- async function runActor(args) {
294
+ function publicActor(row) {
295
+ return { owner: row.owner, actor: row.actor, kind: row.kind, role: row.role ?? null, label: row.label ?? null, scope: row.scope, allowSubscribe: row.allowSubscribe, allowPublish: row.allowPublish };
296
+ }
297
+ function grantView(space, rows) {
298
+ return { sections: [{ title: "Recorded actor grants", blocks: [
299
+ { kind: "fields", rows: [["Space", space], ["Source", "local authoritative actor ledger"]] },
300
+ rows.length ? { kind: "table", columns: [{ key: "principal", label: "Principal" }, { key: "kind", label: "Kind" }, { key: "scope", label: "Capabilities" }, { key: "read", label: "May read" }, { key: "post", label: "May post" }], rows: rows.map((row) => ({ principal: `${row.owner}.${row.actor}`, kind: row.kind ?? "not recorded", scope: row.scope.join(", ") || "none", read: row.allowSubscribe.join(", ") || "none", post: row.allowPublish.join(", ") || "none" })) } : { kind: "lines", lines: ["No rows in this authoritative ledger snapshot."] },
301
+ { kind: "lines", lines: [{ text: "Recorded grants are not proof of current connections, task progress or the authority of a cached account session.", tone: "hint" }] },
302
+ ] }] };
303
+ }
304
+ /** A grant read or change, `auth grants|grant|revoke` or its `actor list|grant|revoke` spelling, at
305
+ * the target {@link localAuthTarget} resolved, once {@link validateGrantUsage} has passed. */
306
+ async function runActor(args, target, command) {
191
307
  const [sub, actor] = args.positionals;
192
308
  const values = args.values;
193
- const st = actorState(values.space);
309
+ const st = actorState(target);
194
310
  const { dir, space } = st;
311
+ let mutationAttempted = false;
312
+ // A guard that fires after `retireInteractiveActorLifecycle` returned. When that call retired the
313
+ // predecessor's lifecycle and this command wrote nothing, the result is partial, never unknown.
314
+ // When it retired nothing, the same guard is a plain conflict.
315
+ const rowConflict = (row, retired, verb, what, unwritten) => {
316
+ const target = { space, owner: row.owner, actor: row.actor };
317
+ const remedy = "re-read `cotal actor list`, then decide whether to grant or revoke again";
318
+ if (!retired)
319
+ return new CommandFailure("refused", "CONFLICT", `cannot ${verb} "${row.owner}/${row.actor}": ${what}; ${unwritten}`, { target, remedy });
320
+ return new CommandFailure("partial", "OPERATION_PARTIAL", `cannot ${verb} "${row.owner}/${row.actor}": its lifecycle ${row.lifecycleUid} was retired, but ${what}; ${unwritten}`, {
321
+ target, remedy, changed: [{ kind: "actor-lifecycle-retired", owner: row.owner, actor: row.actor, lifecycleUid: row.lifecycleUid }],
322
+ });
323
+ };
195
324
  await legibly(async () => {
196
325
  if (sub === "list") {
197
- const rows = loadActorLedger(dir);
198
- if (!rows.length) {
199
- console.log("no actors granted - grant one with: cotal actor grant <actor> --sub <IdP subject> --full (all channels, scope spawn,role:default; or name --scope, --allow-subscribe and --allow-publish instead of --full)");
200
- return;
201
- }
202
- for (const r of rows.sort((a, b) => (a.owner + a.actor).localeCompare(b.owner + b.actor)))
203
- console.log(`${r.owner}.${r.actor}${r.label ? ` (${r.label})` : ""} kind=${r.kind} role=${r.role ?? "-"} scope=[${r.scope.join(",")}] read=[${r.allowSubscribe.join(",")}] post=[${r.allowPublish.join(",")}]`);
326
+ if (!(await loadOwnerSecret(st.store, space)) || !loadPinnedIdp(dir))
327
+ throw new CommandFailure("refused", "UNSUPPORTED", "this root does not hold the space's authoritative user-auth ledger; an empty grant list was not inferred", { target: { space } });
328
+ const items = loadActorLedger(dir).sort((a, b) => (a.owner + a.actor).localeCompare(b.owner + b.actor)).map(publicActor);
329
+ writeResult(presentationOf(args.values), { command, target: { space }, result: { kind: "actor-grants", items } }, (result) => grantView(space, result.items));
204
330
  return;
205
331
  }
206
332
  if (sub === "grant") {
207
333
  if (!actor)
208
- throw new Error(`usage: ${GRANT_FORMS}`);
209
- // The row is an upsert of the WHOLE row, so every field must be chosen: either all three ACL
210
- // flags are named, or `--full` opts in to the wide default for the ones left off. Omission
211
- // alone never means "everything": a dropped flag on a narrow reader grant would otherwise
212
- // mint a space-wide row with a success line. The user's agents are still attenuated from
213
- // whatever this row says (the envelope rule).
214
- const omitted = ["scope", "allow-subscribe", "allow-publish"].filter((f) => values[f] === undefined);
215
- if (omitted.length && !values.full)
216
- throw new Error(`refusing to grant "${actor}" with ${omitted.map((f) => `--${f}`).join(", ")} left off: an omitted flag would be the WIDE default (scope spawn,role:default, read '>', post '>'). Say which grant you mean: ${GRANT_FORMS}`);
334
+ throw new CommandFailure("refused", "USAGE_INVALID", grantUsage(command));
217
335
  const owner = await resolveGrantOwner(st, values);
218
336
  //
219
337
  // A re-grant rotates the lifecycle so a copied bearer cannot cross the authorization update.
220
338
  // Retire the predecessor through the auth plane first; issuance is not allowed to perform a
221
339
  // takeover as a side effect, and writing the successor first would strand the alias on failure.
222
340
  const current = findInteractiveActor(dir, owner, actor);
341
+ let retired = false;
223
342
  if (current?.lifecycleUid) {
224
- await retireInteractiveActorLifecycle(dir, current, "re-grant");
225
- const refreshed = findInteractiveActor(dir, owner, actor);
226
- if (refreshed?.lifecycleUid !== current.lifecycleUid)
227
- throw new Error(`cannot re-grant "${owner}/${actor}": the actor row changed while its predecessor retired. Nothing was overwritten; re-read \`cotal actor list\` and retry.`);
343
+ mutationAttempted = true;
344
+ // Async retirement runs before the transaction, never inside it.
345
+ retired = await retireInteractiveActorLifecycle(dir, space, current, "re-grant");
228
346
  }
229
- const row = grantActor(dir, {
347
+ mutationAttempted = true;
348
+ const grantRow = {
230
349
  owner,
231
350
  actor,
232
351
  scope: csv(values.scope, ["spawn", "role:default"]),
@@ -235,37 +354,70 @@ async function runActor(args) {
235
354
  ...(values.role ? { role: values.role } : {}),
236
355
  ...(values.label ? { label: values.label } : {}),
237
356
  ...(values.parent ? { parent: values.parent } : {}),
357
+ };
358
+ // The re-read, the decision and the write are one transaction. The row read before the lock
359
+ // must still be there: the one whose lifecycle just retired, or no row at all. A grant that
360
+ // landed while this one waited for the lock is never overwritten.
361
+ const row = withManagedGrantTransaction(dir, (tx) => {
362
+ const read = tx.read(owner, actor);
363
+ if (current === undefined) {
364
+ if (read !== undefined)
365
+ throw new CommandFailure("refused", "CONFLICT", `cannot grant "${owner}/${actor}": another grant for this actor landed while this one waited; nothing was written`, { target: { space, owner, actor }, remedy: "re-read `cotal actor list`, then retry" });
366
+ }
367
+ else {
368
+ const refreshed = read?.kind === "interactive" ? read : undefined;
369
+ if (refreshed?.lifecycleUid !== current.lifecycleUid)
370
+ throw rowConflict(current, retired, "re-grant", "the actor row changed or was removed since it was read", "the successor grant was not written and nothing was overwritten");
371
+ }
372
+ return tx.grantInteractive(grantRow);
238
373
  });
239
- console.log(`✓ granted ${row.owner}.${row.actor} - read [${row.allowSubscribe.join(", ")}], post [${row.allowPublish.join(", ")}]${row.scope.length ? `, scope [${row.scope.join(", ")}]` : ""}`);
374
+ const result = { kind: "actor-grant", grant: publicActor({ ...row, kind: "interactive" }), fullOptionExplicit: values.full === true };
375
+ writeResult(presentationOf(args.values), { command, target: { space, owner: row.owner, actor: row.actor }, result }, (data) => ({ sections: [
376
+ { title: "Actor grant stored", blocks: [{ kind: "fields", rows: [["Principal", `${data.grant.owner}.${data.grant.actor}`], ["Space", space], ["Capabilities", data.grant.scope.join(", ") || "none"], ["May read", data.grant.allowSubscribe.join(", ") || "none"], ["May post", data.grant.allowPublish.join(", ") || "none"], ["Wide defaults requested", data.fullOptionExplicit ? "explicit --full" : "no"]] }, { kind: "lines", lines: [{ text: "The local authority stored this grant. No process was launched and no cached login was treated as a grant.", tone: "hint" }] }] },
377
+ ] }));
240
378
  return;
241
379
  }
242
380
  if (sub === "revoke") {
243
381
  if (!actor)
244
- throw new Error("usage: cotal actor revoke <actor> --sub <IdP subject>|--owner <u_…>");
382
+ throw new CommandFailure("refused", "USAGE_INVALID", grantUsage(command));
245
383
  const owner = await resolveGrantOwner(st, values);
246
384
  const current = findInteractiveActor(dir, owner, actor);
247
385
  if (!current) {
248
386
  if (findManagedActor(dir, owner, actor))
249
387
  throw new Error(`${owner}.${actor} is a managed agent - its grant lives with its process: stop it if manager-owned (\`cotal stop --name ${actor}\`), or if it was a killed foreground spawn, respawn the same name (\`cotal spawn\`) to rotate the grant`);
250
- console.log(`no grant for ${owner}.${actor} - nothing to revoke`);
388
+ writeResult(presentationOf(args.values), { command, target: { space, owner, actor }, result: { kind: "actor-revoke", revoked: false, liveConnections: "not-checked" }, noOp: true }, () => ({ sections: [{ title: "No grant to revoke", blocks: [{ kind: "fields", rows: [["Principal", `${owner}.${actor}`], ["Space", space]] }, { kind: "lines", lines: ["No interactive grant was found. No live-connection result is implied."] }] }] }));
251
389
  return;
252
390
  }
253
- await retireInteractiveActorLifecycle(dir, current, "revoke");
254
- const refreshed = findInteractiveActor(dir, owner, actor);
255
- if (refreshed?.lifecycleUid !== current.lifecycleUid)
256
- throw new Error(`cannot revoke "${owner}/${actor}": the actor row changed while its lifecycle retired. Nothing was deleted; re-read \`cotal actor list\` and retry.`);
257
- if (!revokeActor(dir, owner, actor))
258
- throw new Error(`cannot revoke "${owner}/${actor}": the actor row disappeared after retirement. Re-read \`cotal actor list\`; nothing else was changed.`);
391
+ mutationAttempted = true;
392
+ const retired = await retireInteractiveActorLifecycle(dir, space, current, "revoke");
393
+ // The post-retirement re-read, the decision and the removal are one transaction.
394
+ withManagedGrantTransaction(dir, (tx) => {
395
+ const read = tx.read(owner, actor);
396
+ const refreshed = read?.kind === "interactive" ? read : undefined;
397
+ if (refreshed?.lifecycleUid !== current.lifecycleUid)
398
+ throw refreshed
399
+ ? rowConflict(current, retired, "revoke", "the actor row changed since it was read", "the changed row was not deleted")
400
+ : rowConflict(current, retired, "revoke", "the interactive actor row was removed or replaced since it was read", "nothing was deleted");
401
+ if (!tx.revokeInteractive(owner, actor))
402
+ throw rowConflict(current, retired, "revoke", "the actor row disappeared before it could be deleted", "nothing was deleted");
403
+ });
259
404
  // Deny-new is immediate at both boundaries (exchange + connect). The LIVE window no longer
260
405
  // rides the bearer's expiry by default: the flip wires revoke → the delivery daemon's
261
406
  // evictPrincipal (scan→KICK→verify on the privileged rail), best-effort with honest copy —
262
407
  // a human bearer can otherwise carry up to the IdP session cap, far beyond the agents' TTL.
263
- console.log(`✓ revoked ${owner}.${actor} - new exchanges and new connects are denied now`);
264
- console.log(` ${await evictRevokedPrincipal(space, `${owner}.${actor}`)}`);
408
+ const evictionNotice = await evictRevokedPrincipal(space, `${owner}.${actor}`, target.root);
409
+ writeResult(presentationOf(args.values), { command, target: { space, owner, actor }, result: { kind: "actor-revoke", revoked: true, denyNew: true, liveEviction: { evidence: "native notice; not independently measured by this command", notice: evictionNotice } }, warnings: [{ code: "LIVE_EVICTION_SCOPE", target: { space, owner, actor }, message: "Deny-new is committed. The separate native live-connection eviction notice describes its own evidence and limits; revocation was not repeated." }] }, (result) => ({ sections: [
410
+ { title: "Actor grant revoked", blocks: [{ kind: "fields", rows: [["Principal", `${owner}.${actor}`], ["Space", space], ["New exchanges and connections", "denied by the removed grant"]] }, { kind: "lines", lines: [result.liveEviction.notice, { text: "Existing connections and spawned agents have their own lifecycle. This is not a claim that every related process was stopped.", tone: "hint" }] }] },
411
+ ] }));
265
412
  return;
266
413
  }
267
- throw new Error("usage: cotal actor <grant <actor> | revoke <actor> | list>");
268
- });
414
+ throw new CommandFailure("refused", "USAGE_INVALID", grantUsage(command));
415
+ }, () => new CommandFailure(mutationAttempted ? "unknown" : "failed", mutationAttempted ? "OPERATION_UNKNOWN" : "OPERATION_FAILED", mutationAttempted ? "the grant operation did not return a confirmed complete result; lifecycle retirement or a ledger update may have occurred; nothing was retried" : "the local grant authority or records could not be read", { target: { space, ...(actor ? { actor } : {}) }, remedy: "read the authoritative grant ledger before another mutation" }));
416
+ }
417
+ /** Record one `agent-bearer` attempt in the manager-composed health file, 0o600 and never through a
418
+ * symlink ({@link writePrivateRecord}). */
419
+ export function writeAgentAuthHealth(path, state, reason) {
420
+ writePrivateRecord(path, JSON.stringify({ state, at: new Date().toISOString(), ...(reason ? { reason } : {}) }));
269
421
  }
270
422
  /** `cotal agent-bearer` — the ONE thing a spawned user-mode agent execs to refresh its auth: read
271
423
  * its spawn-time secret from the 0600 token file, exchange it at the space's auth service (agent
@@ -294,7 +446,7 @@ async function runAgentBearer(args) {
294
446
  if (!path)
295
447
  return;
296
448
  try {
297
- writeFileSync(path, JSON.stringify({ state, at: new Date().toISOString(), ...(reason ? { reason } : {}) }));
449
+ writeAgentAuthHealth(path, state, reason);
298
450
  }
299
451
  catch { /* the exchange outcome still stands */ }
300
452
  };
@@ -382,19 +534,13 @@ async function runAgentBearer(args) {
382
534
  const authCommands = [
383
535
  {
384
536
  kind: "command",
385
- name: "auth-service",
537
+ // The auth daemon `up` launches. `auth-service` is the same entry for persisted callers, plus the
538
+ // deprecation warning; help and completion list neither.
539
+ name: "__daemon-auth-service",
540
+ hidden: true,
386
541
  group: "Manager",
387
542
  summary: "run the user-auth service daemon - NATS auth callout + token exchange/JWKS --space <s> --server <url> [--port <n>]",
388
- flags: [
389
- { name: "space", type: "string", value: "<s>", description: "space to serve (required)" },
390
- { name: "server", type: "string", value: "<url>", description: "broker URL the callout serves (required)" },
391
- { name: "port", type: "string", value: "<n>", description: "loopback HTTP port for /exchange + /jwks (default: ephemeral)" },
392
- { name: "exchange-public-port", type: "string", value: "<n>", description: "also serve the PUBLIC exchange face on this loopback port (TLS terminates at a reverse proxy)" },
393
- { name: "exchange-public-url", type: "string", value: "<https://…>", description: "with --exchange-public-port: the advertised public base URL (the reverse proxy's address)" },
394
- { name: "exchange-trusted-proxy", type: "boolean", description: "with --exchange-public-port: attribute peers by the last X-Forwarded-For hop (opt-in; default: socket address)" },
395
- { name: "advertised-server", type: "string", value: "<url>", description: "with --exchange-public-port: the broker address the public bundle advertises - what participants dial (default: --server)" },
396
- { name: "agent-provisioning-url", type: "string", value: "<https://…>", description: "with --exchange-public-port: the deployment's remote agent-provisioning endpoint the public bundle advertises (spawn POSTs it with the login bearer)" },
397
- ],
543
+ flags: [...authServiceFlags],
398
544
  prepareMeshTarget: false,
399
545
  run: (args) => legibly(() => runAuthService(args)),
400
546
  },
@@ -406,7 +552,7 @@ const authCommands = [
406
552
  usage: "actor <grant <actor> | revoke <actor> | list> [--sub <IdP subject>|--owner <u_…>] [--space <s>] [--full | --scope a,b --allow-subscribe a,b --allow-publish a,b] [--role r] [--label l]",
407
553
  positionals: "<grant <actor> | revoke <actor> | list>",
408
554
  flags: [
409
- { name: "space", type: "string", value: "<s>", description: "space whose ledger to manage (default: the folder's)" },
555
+ { name: "space", type: "string", value: "<s>", description: "space whose ledger to manage (default: the resolved mesh, as for auth grants)" },
410
556
  { name: "sub", type: "string", value: "<subject>", description: "the IdP subject (shown by `cotal login`) the actor belongs to" },
411
557
  { name: "owner", type: "string", value: "<u_…>", description: "the derived owner token (alternative to --sub)" },
412
558
  { name: "full", type: "boolean", description: "grant: the full row - every ACL flag left off takes its wide default (all channels; scope spawn,role:default). Without it, grant needs --scope, --allow-subscribe and --allow-publish" },
@@ -417,7 +563,11 @@ const authCommands = [
417
563
  { name: "label", type: "string", value: "<l>", description: "display label for `actor list` (never the IdP subject)" },
418
564
  { name: "parent", type: "string", value: "<owner.actor>", description: "spawning principal audit link (operator grants are authority - this does not attenuate)" },
419
565
  ],
420
- run: runActor,
566
+ validate: validateActorArguments,
567
+ run: (args) => {
568
+ validateActorArguments(args);
569
+ return runActor(args, localAuthTarget(args.values), "actor");
570
+ },
421
571
  },
422
572
  {
423
573
  kind: "command",
@@ -443,8 +593,12 @@ const authCommands = [
443
593
  name: "login",
444
594
  group: "Identity",
445
595
  summary: "sign in to a space's IdP (device code) and cache the session --idp <auth base URL> [--client-id <id>]",
446
- usage: "login --idp <auth base URL> [--client-id <id>]",
596
+ usage: "cotal login [--idp <auth base URL>] [--client-id <id>] [--plain]",
597
+ prepareMeshTarget: false,
598
+ outputs: ["plain"],
599
+ validate: (args) => validateAccountArguments(args, "login"),
447
600
  flags: [
601
+ plainFlag,
448
602
  { name: "idp", type: "string", value: "<auth base URL>", description: "Auth base URL, e.g. <origin>/api/auth" },
449
603
  { name: "client-id", type: "string", value: "<id>", description: "OAuth device-flow client id" },
450
604
  ],
@@ -455,12 +609,233 @@ const authCommands = [
455
609
  name: "logout",
456
610
  group: "Identity",
457
611
  summary: "revoke the IdP session and clear the cached login --idp <auth base URL>",
458
- usage: "logout --idp <auth base URL>",
612
+ usage: "cotal logout [--idp <auth base URL>] [--json|--plain]",
613
+ prepareMeshTarget: false,
614
+ outputs: ["json", "plain"],
615
+ validate: (args) => validateAccountArguments(args, "logout"),
459
616
  flags: [
617
+ jsonFlag, plainFlag,
460
618
  { name: "idp", type: "string", value: "<auth base URL>", description: "Auth base URL, e.g. <origin>/api/auth" },
461
619
  ],
462
620
  run: runLogout,
463
621
  },
464
622
  ];
465
- registry.register(...authCommands);
623
+ const actorCommand = authCommands.find((command) => command.name === "actor");
624
+ if (!actorCommand?.flags)
625
+ throw new Error("the native actor command must declare its shared authority flags");
626
+ const authFlagSources = [...actorCommand.flags.filter((flag) => flag.name !== "space"), ...signerExportFlags, ...credentialMintFlags, { name: "yes", short: "y", type: "boolean", description: "retire: explicit intent never bypasses the native caller/serve-gate requirement" }, spaceFlag, jsonFlag, plainFlag];
627
+ const authFlags = [...new Map(authFlagSources.map((flag) => [flag.name, flag])).values()];
628
+ function validateAuthArguments(args) {
629
+ presentationOf(args.values);
630
+ const [mode, actor, ...extra] = args.positionals;
631
+ if (mode === "mint") {
632
+ validateCredentialMintArguments({ ...args, positionals: args.positionals.slice(1) });
633
+ return;
634
+ }
635
+ if (mode === "retire") {
636
+ if (!actor || extra.length || Object.keys(args.values).some((key) => !["space", "yes", "json", "plain", "no-color", "no-input"].includes(key)))
637
+ throw new CommandFailure("refused", "USAGE_INVALID", "use auth retire <full-lifecycle-uid> [--space <space>] [--yes] without actor, credential or grant selectors");
638
+ try {
639
+ assertLifecycleToken(actor);
640
+ }
641
+ catch {
642
+ throw new CommandFailure("refused", "USAGE_INVALID", "auth retire needs one exact native lifecycle UID; no alias or prefix was resolved");
643
+ }
644
+ return;
645
+ }
646
+ if (mode === "redeem") {
647
+ if (!actor || extra.length || /[\u0000-\u001f\u007f]/u.test(actor) || Object.keys(args.values).some((key) => !["space", "json", "plain", "no-color", "no-input"].includes(key)))
648
+ throw new CommandFailure("refused", "USAGE_INVALID", "use auth redeem <private-handle-file> [--space <space>] without signer, grant or credential-output flags");
649
+ return;
650
+ }
651
+ if (mode === "handle") {
652
+ if (actor !== "inspect" || extra.length !== 1 || !extra[0] || /[\u0000-\u001f\u007f]/u.test(extra[0]))
653
+ throw new CommandFailure("refused", "USAGE_INVALID", "use auth handle inspect <private-file>");
654
+ if (args.values.space !== undefined && (typeof args.values.space !== "string" || !args.values.space || args.values.space.length > 200 || /[\u0000-\u001f\u007f]/u.test(args.values.space)))
655
+ throw new CommandFailure("refused", "USAGE_INVALID", "online handle inspection needs one bounded registered space name without terminal controls");
656
+ if (Object.keys(args.values).some((key) => !["space", "json", "plain", "no-color", "no-input"].includes(key)))
657
+ throw new CommandFailure("refused", "USAGE_INVALID", "handle inspection accepts no signer, grant, caller or mutation flags");
658
+ return;
659
+ }
660
+ if (mode === "signer") {
661
+ if (actor !== undefined || extra.length || Object.keys(args.values).some((key) => !["space", "out", "force", "json", "plain", "no-color", "no-input"].includes(key)))
662
+ throw new CommandFailure("refused", "USAGE_INVALID", "use auth signer --out <private-file> [--space <space>] [--force] without actor or grant selectors");
663
+ validateSignerExportInput(args.values);
664
+ return;
665
+ }
666
+ if (mode === undefined) {
667
+ if (Object.keys(args.values).some((key) => !GRANT_COMMON_FLAGS.includes(key)))
668
+ throw new CommandFailure("refused", "USAGE_INVALID", "a flag does not belong to this auth action");
669
+ return;
670
+ }
671
+ validateGrantUsage("auth", mode, actor, extra, args.values);
672
+ }
673
+ function validateActorArguments(args) {
674
+ const [mode, actor, ...extra] = args.positionals;
675
+ validateGrantUsage("actor", mode, actor, extra, args.values);
676
+ }
677
+ const GRANT_COMMON_FLAGS = ["space", "json", "plain", "no-color", "no-input"];
678
+ /** The usage of a grant read or change, `auth grants|grant|revoke` or the same verbs spelled
679
+ * `actor list|grant|revoke`. It runs before any target or authority is read, so a usage error is
680
+ * never reported as a capability refusal or a failure, and one refusal names every missing input.
681
+ * A grant is an upsert of the WHOLE row, so every field must be chosen: either all three ACL flags
682
+ * are named, or `--full` opts in to the wide default for the ones left off. Omission alone never
683
+ * means "everything": a dropped flag on a narrow reader grant would otherwise mint a space-wide row
684
+ * with a success line. The user's agents are still attenuated from whatever the row says (the
685
+ * envelope rule). */
686
+ function validateGrantUsage(command, mode, actor, extra, flags) {
687
+ const list = command === "auth" ? "grants" : "list";
688
+ if (extra.length || (mode !== list && mode !== "grant" && mode !== "revoke") || (mode === list ? actor !== undefined : !actor))
689
+ throw new CommandFailure("refused", "USAGE_INVALID", grantUsage(command));
690
+ const allowed = [...GRANT_COMMON_FLAGS, ...(mode === list ? [] : ["sub", "owner"]), ...(mode === "grant" ? ["scope", "allow-subscribe", "allow-publish", "role", "label", "parent", "full"] : [])];
691
+ if (Object.keys(flags).some((key) => !allowed.includes(key)))
692
+ throw new CommandFailure("refused", "USAGE_INVALID", `a flag does not belong to this ${command} action`);
693
+ if (mode === list)
694
+ return;
695
+ const values = flags;
696
+ const problems = [];
697
+ const omitted = mode === "grant" && !values.full ? ["scope", "allow-subscribe", "allow-publish"].filter((flag) => values[flag] === undefined) : [];
698
+ if (omitted.length)
699
+ problems.push(`with ${omitted.map((flag) => `--${flag}`).join(", ")} left off: an omitted flag would be the WIDE default (scope spawn,role:default, read '>', post '>')`);
700
+ if (values.owner === undefined && values.sub === undefined)
701
+ problems.push("with no --sub <IdP subject> (shown by `cotal login`) or --owner <u_…> to say whose actor it is");
702
+ else if (values.owner !== undefined && values.sub !== undefined)
703
+ problems.push("with both --sub and --owner: choose one explicit authority target");
704
+ else if (values.owner === "" || values.sub === "")
705
+ problems.push("with an empty --sub or --owner");
706
+ if (problems.length)
707
+ throw new CommandFailure("refused", "USAGE_INVALID", `refusing to ${mode} "${actor}" ${problems.join("; and ")}`, {
708
+ remedy: mode === "grant" ? `say which grant you mean: ${grantForms(command)}` : `cotal ${command} revoke <actor> --sub <IdP subject>|--owner <u_…>`,
709
+ });
710
+ try {
711
+ assertValidOwnerToken(actor);
712
+ if (values.owner !== undefined)
713
+ assertDerivedOwnerToken(values.owner);
714
+ if (values.role !== undefined)
715
+ assertValidRole(values.role);
716
+ if (values.parent !== undefined) {
717
+ const parts = values.parent.split(".");
718
+ if (parts.length !== 2)
719
+ throw new Error();
720
+ assertDerivedOwnerToken(parts[0]);
721
+ assertValidOwnerToken(parts[1]);
722
+ }
723
+ for (const channel of [...csv(values["allow-subscribe"], []), ...csv(values["allow-publish"], [])])
724
+ assertValidChannel(channel);
725
+ if (csv(values.scope, []).some((scope) => !/^[A-Za-z0-9_:-]+$/.test(scope)))
726
+ throw new Error();
727
+ }
728
+ catch {
729
+ throw new CommandFailure("refused", "USAGE_INVALID", "invalid actor, owner, parent, role, scope or channel-pattern input; no grant was changed");
730
+ }
731
+ }
732
+ function localAuthTarget(values) {
733
+ try {
734
+ const target = resolveMeshTarget(process.cwd(), { ...values, readOnly: true });
735
+ return { root: target.root, space: target.space };
736
+ }
737
+ catch (error) {
738
+ if (isWorkspaceTargetError(error)) {
739
+ if (error.code === "not-started" && error.details.root && error.details.space)
740
+ return { root: error.details.root, space: error.details.space };
741
+ throw new CommandFailure("refused", targetErrorCode(error.code), "the selected space's local authority root could not be resolved; no different root was selected");
742
+ }
743
+ throw error;
744
+ }
745
+ }
746
+ async function authGroup(args) {
747
+ validateAuthArguments(args);
748
+ const mode = args.positionals[0];
749
+ if (mode === "mint") {
750
+ if (!registry.has("credential-action", "mint"))
751
+ throw new CommandFailure("refused", "UNSUPPORTED", "this composition has no registered native credential mint action; no other implementation was substituted");
752
+ const action = registry.resolve("credential-action", "mint");
753
+ const request = { ...args, positionals: args.positionals.slice(1) };
754
+ action.validate(request);
755
+ return action.run(request);
756
+ }
757
+ if (mode === "retire") {
758
+ const target = localAuthTarget(args.values);
759
+ throw new CommandFailure("refused", "UNSUPPORTED", "registered auth retirement requires the caller's own current serve identity and gate; this standalone CLI has no supported retirement instrument, so no manager identity, signer-selected alias or loopback host door was substituted", {
760
+ target: { space: target.space, lifecycleUid: args.positionals[1] },
761
+ remedy: "inspect the registered auth endpoint's contract and restore its supported caller/serve-gate context; --yes is not an authority grant and no retirement request was sent",
762
+ });
763
+ }
764
+ if (mode === "redeem") {
765
+ const target = localAuthTarget(args.values);
766
+ throw new CommandFailure("refused", "UNSUPPORTED", "the selected native auth path does not expose conferring-handle redemption; the file was not read, no one-use authority was spent and no credential was issued", {
767
+ target: { space: target.space, redemption: "conferring-capability-handle" },
768
+ remedy: "a parsed handle, session grant and agent enrollment are different authorities; none is substituted for a supported conferring-handle redemption path",
769
+ });
770
+ }
771
+ if (mode === "handle") {
772
+ const online = args.values.space === undefined ? undefined
773
+ : await (await import("./handle-verification.js")).inspectHandleFileOnline(args.positionals[2], String(args.values.space));
774
+ const result = online ?? inspectHandleFile(args.positionals[2]);
775
+ const readerCloseUnconfirmed = online !== undefined && !online.verification.readerClosed;
776
+ writeResult(presentationOf(args.values), { command: "auth handle inspect", target: { path: result.path, ...("space" in result ? { space: result.space } : {}) }, result,
777
+ ...(readerCloseUnconfirmed ? { completeness: "partial", partial: { message: "The signature/registry reads completed, but the original reader's local closure was not confirmed; no credential was persisted or handle redeemed." }, warnings: [{ code: "READER_CLOSE_UNCONFIRMED", message: "Closing the original verification connection was not confirmed.", target: { path: result.path } }] } : {}) }, (data) => {
778
+ const verified = "verification" in data;
779
+ return { sections: [
780
+ { title: verified ? "Online handle signature inspection" : "Unverified handle metadata", blocks: [
781
+ { kind: "fields", rows: [["File", data.path], ["Presented artifacts", String(data.items.length)], ["Signatures", { text: verified ? "verified against native registry" : "not checked", tone: verified ? "ok" : "waiting" }], ["Revocation", verified ? "signers and required handle records checked" : "not checked"], ["Signature links and issuer scope", verified ? "verified" : "not checked"], ["Holder/process currency", "not checked"], ["Authority to use this handle", "not established"]] },
782
+ { kind: "table", columns: [{ key: "id", label: "Declared handle" }, { key: "space", label: "Declared space" }, { key: "holder", label: "Declared holder" }, { key: "expiry", label: "Declared expiry" }], rows: data.items.map((item) => ({ id: item.id, space: item.declaredSpace, holder: `${item.holder.id} / ${item.holder.lifecycleUid}`, expiry: item.expiresAt ?? `${item.expiresAtMs} ms (outside display date range)` })) },
783
+ { kind: "lines", lines: [{ text: verified ? "Native leader-served signer and required revocation records were read on one authenticated static connection. Signatures, issuer scope and presented chain links were checked, not presenter identity, live epochs or use authorization. Expiry observations remain separate. One ephemeral operator reader was minted without persisting it; no holder credential was conferred, handle redeemed or signature printed." : "Shape and local-clock metadata only. Claims are not authenticated, parent links are not verified and no capability was conferred or redeemed. Signature bytes and the signed artifact are omitted.", tone: "hint" }] },
784
+ ] },
785
+ { title: "Declared scope", blocks: [{ kind: "table", columns: [{ key: "handle", label: "Handle" }, { key: "endpoint", label: "Endpoint" }, { key: "instance", label: "Instance pin" }, { key: "commands", label: "Commands" }, { key: "reads", label: "Read subtrees" }], rows: data.items.flatMap((item) => item.grants.map((grant) => ({ handle: item.id, endpoint: grant.endpoint, instance: grant.instanceId ?? "not pinned", commands: grant.commands.map((command) => `${command.name} (${command.declaredMode})`).join(", "), reads: grant.reads.join(", ") || "none" }))).slice(0, 50) }, { kind: "lines", lines: [...(data.scopeRowCount > 50 ? [`${data.scopeRowCount - 50} scope rows are not shown; --json retains all public metadata, never signatures.`] : []), verified ? "These non-atomic registry observations grant no command or read authority and do not make this a usable presentation context." : "No broker or signer registry was read."] }] },
786
+ ] };
787
+ });
788
+ return;
789
+ }
790
+ if (mode === "signer") {
791
+ const result = await exportSpaceSigner(args.values);
792
+ writeResult(presentationOf(args.values), { command: "auth signer", target: { space: result.space, path: result.path }, result, noOp: result.noOp }, (data) => ({ sections: [
793
+ { title: data.noOp ? "Private signer already present" : "Private signer exported", blocks: [
794
+ { kind: "fields", rows: [["Space", data.space], ["Account", data.account], ["Private output", data.path], ["Existing signer replaced", data.replaced ? "yes, after inspection" : "no"]] },
795
+ { kind: "lines", lines: [{ text: "This file grants signing authority over the data account. It is not a least-privilege caller credential. Keep it private, mount it read-only when needed, and never attach it to logs or support bundles.", tone: "waiting" }, { text: "No operator root, caller credential or service was created, and no key was rotated. Secret contents were not printed.", tone: "hint" }] },
796
+ ] },
797
+ ] }));
798
+ return;
799
+ }
800
+ if (mode !== undefined) {
801
+ const target = localAuthTarget(args.values);
802
+ return runActor({ ...args, positionals: [mode === "grants" ? "list" : mode, ...args.positionals.slice(1)] }, target, "auth");
803
+ }
804
+ let accounts;
805
+ try {
806
+ accounts = listIdpAccounts(homeCotalDir());
807
+ }
808
+ catch {
809
+ throw new CommandFailure("refused", "SCHEMA_INVALID", "cached account metadata could not be read; nothing was reset");
810
+ }
811
+ const items = accounts.map((account) => {
812
+ const expiry = new Date(account.expiresAt * 1000);
813
+ return { provider: account.idp, subject: account.sub ?? null, cachedExpiry: account.expiresAt, cachedExpiryIso: Number.isFinite(expiry.getTime()) ? expiry.toISOString() : null, cachedExpiryPassed: account.expiresAt * 1000 <= Date.now(), verification: "not-checked" };
814
+ });
815
+ writeResult(presentationOf(args.values), { command: "auth", target: { requestedSpace: args.values.space ?? null }, result: { kind: "account-cache", items, liveAuthorization: "not-checked" } }, (result) => ({ sections: [
816
+ { title: "Cached accounts", blocks: [result.items.length ? { kind: "table", columns: [{ key: "provider", label: "Provider" }, { key: "subject", label: "Account subject" }, { key: "expiry", label: "Cached expiry" }, { key: "state", label: "Cache state" }], rows: result.items.map((account) => ({ provider: account.provider, subject: account.subject ?? "not cached", expiry: account.cachedExpiryIso ?? "not representable", state: account.cachedExpiryPassed ? "expiry passed" : "not verified online" })) } : { kind: "lines", lines: ["No cached provider accounts."] }, { kind: "lines", lines: [{ text: "Cache metadata only. A sign-in is not a space grant and this view does not prove current session validity or live authorization. Tokens and grant secrets are not printed.", tone: "hint" }] }] },
817
+ { title: "Next", blocks: [{ kind: "commands", items: [{ command: "cotal whoami --space <space>", description: "Resolve the selected space's actual CLI caller" }, { command: "cotal auth grants --space <space>", description: "Read the locally authoritative actor ledger" }, { command: "cotal login", description: "Choose a provider and approve sign-in" }] }] },
818
+ ] }));
819
+ }
820
+ const authCommand = {
821
+ kind: "command", name: "auth", group: "Identity",
822
+ positionals: "[grants | grant <actor> | revoke <actor> | signer | mint <name> | handle inspect <file> | redeem <file> | retire <lifecycle-uid>]",
823
+ summary: "inspect account metadata, manage actor grants and export a protected account signer",
824
+ usage: "cotal auth [grants | grant <actor> | revoke <actor> | signer --out <private-file> | mint <name> | handle inspect <file> | redeem <file> | retire <lifecycle-uid>] [--space <s>] [--json|--plain]",
825
+ flagNames: ["space", "json", "plain"],
826
+ flags: authFlags, outputs: ["json", "plain"], reads: true, prepareMeshTarget: false,
827
+ subcommands: [
828
+ { name: "mint", flagNames: credentialMintFlags.map((flag) => flag.name), summary: "write a fixed-profile or supported endpoint caller credential privately", usage: "cotal auth mint <name> [--profile agent|observer|admin] [--out <private-file>] | <name> --endpoint <class> --out <private-file> [--channels <a,b>]", prepareMeshTarget: false },
829
+ { name: "handle", flagNames: ["space", "json", "plain"], summary: "inspect protected claims or static-registry signatures without conferring authority", usage: "cotal auth handle inspect <private-file> [--space <space>] [--json|--plain]", reads: true, prepareMeshTarget: false, subcommands: [{ name: "inspect", flagNames: ["space", "json", "plain"], summary: "offline metadata; --space checks native static signer/revocation records", usage: "cotal auth handle inspect <private-file> [--space <space>] [--json|--plain]", reads: true, prepareMeshTarget: false, outputs: ["json", "plain"] }] },
830
+ { name: "retire", flagNames: ["space", "yes", "json", "plain"], summary: "retire one exact lifecycle only through a supported registered auth caller", usage: "cotal auth retire <full-lifecycle-uid> [--space <space>] [--yes]", prepareMeshTarget: false },
831
+ { name: "redeem", flagNames: ["space", "json", "plain"], summary: "conferring-handle redemption requires a supported native auth path", usage: "cotal auth redeem <private-handle-file> [--space <space>]", prepareMeshTarget: false },
832
+ { name: "signer", flagNames: ["space", "out", "force", "json", "plain"], summary: "export existing account-signing material to a protected private file", usage: "cotal auth signer --out <private-file> [--force]", prepareMeshTarget: false },
833
+ { name: "grants", flagNames: ["space", "json", "plain"], summary: "read the authoritative local actor grants, not cached sign-in claims", reads: true, prepareMeshTarget: false },
834
+ { name: "grant", flagNames: ["space", "sub", "owner", "scope", "allow-subscribe", "allow-publish", "role", "label", "parent", "full", "json", "plain"], summary: "store a deliberate operator grant through the existing authority path", usage: "cotal auth grant <actor>", prepareMeshTarget: false },
835
+ { name: "revoke", flagNames: ["space", "sub", "owner", "json", "plain"], summary: "retire and revoke an interactive actor grant under its existing authority", usage: "cotal auth revoke <actor>", prepareMeshTarget: false },
836
+ ].map((sub) => ({ ...sub, outputs: ["json", "plain"] })),
837
+ validate: validateAuthArguments, run: authGroup,
838
+ };
839
+ const daemonAuthService = authCommands.find((command) => command.name === "__daemon-auth-service");
840
+ registry.register(...authCommands, deprecatedDaemonEntry("auth-service", daemonAuthService), authCommand);
466
841
  //# sourceMappingURL=commands.js.map