@tinycloud/courier 0.5.1-beta.2 → 0.5.1-beta.4

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.
@@ -0,0 +1,125 @@
1
+ import type { LinkRecord, OpenKeyApprovalRequestV1 } from '@tinycloud/share-envelope/links';
2
+ /**
3
+ * The one module that reads, writes and classifies the saved OpenKey approval request, `approval-pending.json`.
4
+ *
5
+ * `readApprovalEntry` is the only reader. `approvalPhase` is the only classifier: every other module asks it (or `stageOf`, the same rule
6
+ * on one record) what the request is, and never looks at the optional fields itself. The record's schema is unchanged and so is the
7
+ * meaning of every phase: this names what the code already did.
8
+ *
9
+ * Phases: `none` (no file), `open` (a request nobody has answered; `lapsed` is the request's own expiry having passed), `held` (the
10
+ * owner approved: stage `ref` = OpenKey holds a result we have only a reference to, `result` = the encrypted result is saved here,
11
+ * `committed` = the grant is installed and only the acknowledgement may remain), `unreadable` (the file or its directory has the wrong
12
+ * mode, or cannot be read: the approval in it may be intact), `corrupt` (readable, not a request) and `orphaned` (a live request whose
13
+ * profile or agent key is missing: it is never dropped on its own).
14
+ */
15
+ export declare const APPROVAL_FILE = "approval-pending.json";
16
+ /** The durable record of one approval request. The result stays encrypted at rest under `relayPrivateKey`. */
17
+ export interface PendingGrantRequest {
18
+ version: 1;
19
+ request: OpenKeyApprovalRequestV1;
20
+ requestId: string;
21
+ bodyCid: string;
22
+ approvalUrl: string;
23
+ requestExpiresAt: string;
24
+ record: LinkRecord;
25
+ apiOrigin: string;
26
+ linkApiOrigin: string;
27
+ openkeyOrigin: string;
28
+ relayPrivateKey: string;
29
+ /** Sent with the acknowledgement; stable across retries so OpenKey can recognise the same ack. */
30
+ ackId: string;
31
+ resultRef?: string;
32
+ resultExpiresAt?: string;
33
+ resultEnvelope?: unknown;
34
+ /** The grant and profile are saved; only the acknowledgement may remain. */
35
+ committed?: true;
36
+ }
37
+ export type ApprovalEntry = {
38
+ kind: 'none';
39
+ } | {
40
+ kind: 'ok';
41
+ pending: PendingGrantRequest;
42
+ }
43
+ /** The file or its directory has the wrong mode, or the file cannot be read (EACCES, EIO): the record may well be intact. */
44
+ | {
45
+ kind: 'unreadable';
46
+ reason: string;
47
+ fix: string;
48
+ }
49
+ /** The file is readable and is not a saved request. */
50
+ | {
51
+ kind: 'corrupt';
52
+ reason: string;
53
+ };
54
+ export type HeldStage = 'ref' | 'result' | 'committed';
55
+ export type LivePhase = {
56
+ phase: 'open';
57
+ lapsed: boolean;
58
+ } | {
59
+ phase: 'held';
60
+ stage: HeldStage;
61
+ } | {
62
+ phase: 'unreadable';
63
+ reason: string;
64
+ fix: string;
65
+ };
66
+ export type ApprovalPhase = {
67
+ phase: 'none';
68
+ } | {
69
+ phase: 'corrupt';
70
+ reason: string;
71
+ } | LivePhase | {
72
+ phase: 'orphaned';
73
+ was: LivePhase;
74
+ };
75
+ /** The reader. Reads only: it never removes, repairs or rewrites the file. */
76
+ export declare function readApprovalEntry(): ApprovalEntry;
77
+ /** How far a held request has got: committed outranks a collected result, which outranks a reference. Undefined: nothing held. */
78
+ export declare function stageOf(pending: PendingGrantRequest): HeldStage | undefined;
79
+ /** The request already holds something that cannot be made again: a result reference, the result itself, or an installed grant. */
80
+ export declare const holdsApproval: (pending: PendingGrantRequest) => boolean;
81
+ /** A saved request has no owner to belong to when the profile or the agent key is missing. */
82
+ export declare const lacksOwner: (presence: {
83
+ profile: boolean;
84
+ key: boolean;
85
+ }) => boolean;
86
+ /**
87
+ * The classifier. `presence` says whether the profile and the agent key exist (default: both do). The request's own expiry only matters
88
+ * while nothing is held, and is judged against `now`, exactly as before.
89
+ */
90
+ export declare function approvalPhase(entry: ApprovalEntry, presence?: {
91
+ profile: boolean;
92
+ key: boolean;
93
+ }, now?: number): ApprovalPhase;
94
+ /** The saved request when it can be read, else undefined (with a warning when a file is there but unusable). For read-only and cleanup callers. */
95
+ export declare function loadPending(): PendingGrantRequest | undefined;
96
+ /**
97
+ * Stops (without changing anything) when a saved approval request exists but cannot be read. Call it before a writer changes any
98
+ * other state, so a key, grant or profile is never replaced while the approval it belongs to is unreachable.
99
+ */
100
+ export declare function assertPendingReadable(): void;
101
+ /**
102
+ * The saved request for a caller that is about to write one. A record that exists but cannot be read stops the command with the file
103
+ * named and how to fix it: the approval in it may be intact (it comes back after the fix). Only a record that is readable and corrupt
104
+ * is set aside, as `approval-pending.json.corrupt-<time>` (0600), and the command goes on without it.
105
+ */
106
+ export declare function loadPendingForWrite(): PendingGrantRequest | undefined;
107
+ export declare function savePending(pending: PendingGrantRequest): void;
108
+ export declare function clearPending(): void;
109
+ /** OpenKey holds a result for this request: remember where, and until when. */
110
+ export declare function recordResultRef(pending: PendingGrantRequest, resultRef: string, expiresAt: string): void;
111
+ /** The encrypted result was collected and is about to be saved. */
112
+ export declare function recordResultEnvelope(pending: PendingGrantRequest, relay: unknown): void;
113
+ /** The grant and profile are saved; only the acknowledgement may remain. */
114
+ export declare function markCommitted(pending: PendingGrantRequest): void;
115
+ /** The reference and expiry of a result already discovered, for a collection that should not ask again. */
116
+ export declare function knownResult(pending: PendingGrantRequest): {
117
+ resultRef: string;
118
+ expiresAt: string;
119
+ } | undefined;
120
+ /** The reference an acknowledgement is sent for; undefined when there is none to acknowledge. */
121
+ export declare function ackRef(pending: PendingGrantRequest): string | undefined;
122
+ /** The collected, still encrypted result. */
123
+ export declare function resultEnvelopeOf(pending: PendingGrantRequest): unknown;
124
+ /** Whether a failing collection already had its result reference saved (so the failing call was the fetch). */
125
+ export declare function resultSeen(pending: PendingGrantRequest): boolean;
@@ -0,0 +1,175 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { cmd } from './program.js';
4
+ import { CourierError } from './errors.js';
5
+ import { profileDir, writeSecure } from './profile.js';
6
+ /**
7
+ * The one module that reads, writes and classifies the saved OpenKey approval request, `approval-pending.json`.
8
+ *
9
+ * `readApprovalEntry` is the only reader. `approvalPhase` is the only classifier: every other module asks it (or `stageOf`, the same rule
10
+ * on one record) what the request is, and never looks at the optional fields itself. The record's schema is unchanged and so is the
11
+ * meaning of every phase: this names what the code already did.
12
+ *
13
+ * Phases: `none` (no file), `open` (a request nobody has answered; `lapsed` is the request's own expiry having passed), `held` (the
14
+ * owner approved: stage `ref` = OpenKey holds a result we have only a reference to, `result` = the encrypted result is saved here,
15
+ * `committed` = the grant is installed and only the acknowledgement may remain), `unreadable` (the file or its directory has the wrong
16
+ * mode, or cannot be read: the approval in it may be intact), `corrupt` (readable, not a request) and `orphaned` (a live request whose
17
+ * profile or agent key is missing: it is never dropped on its own).
18
+ */
19
+ export const APPROVAL_FILE = 'approval-pending.json';
20
+ /** The reader. Reads only: it never removes, repairs or rewrites the file. */
21
+ export function readApprovalEntry() {
22
+ const file = path.join(profileDir(), APPROVAL_FILE);
23
+ if (!fs.existsSync(file))
24
+ return { kind: 'none' };
25
+ const dirMode = fs.statSync(profileDir()).mode & 0o777;
26
+ if ((dirMode & 0o077) !== 0) {
27
+ return { kind: 'unreadable', reason: `${profileDir()} has mode ${dirMode.toString(8)}`, fix: `chmod 700 ${profileDir()}` };
28
+ }
29
+ const mode = fs.statSync(file).mode & 0o777;
30
+ if ((mode & 0o077) !== 0)
31
+ return { kind: 'unreadable', reason: `${file} has mode ${mode.toString(8)}, not 600`, fix: `chmod 600 ${file}` };
32
+ let text;
33
+ try {
34
+ text = fs.readFileSync(file, 'utf8');
35
+ }
36
+ catch (error) {
37
+ return { kind: 'unreadable', reason: `${file} cannot be read (${error.code ?? 'read error'})`, fix: `chmod 600 ${file}` };
38
+ }
39
+ let pending;
40
+ // The parser's message quotes the text around the failure, and this file holds `relayPrivateKey`: the reason is fixed, never the message.
41
+ try {
42
+ pending = JSON.parse(text);
43
+ }
44
+ catch {
45
+ return { kind: 'corrupt', reason: 'the file is not valid JSON' };
46
+ }
47
+ if (pending && typeof pending.version === 'number' && pending.version > 1) {
48
+ // Written by a newer Courier: not corrupt, and not ours to set aside. Use that Courier, or move the file away by hand.
49
+ return { kind: 'unreadable', reason: `${file} was written by a newer Courier (record version ${pending.version})`, fix: `mv ${file} ${file}.newer` };
50
+ }
51
+ if (!pending || pending.version !== 1 || !pending.request || typeof pending.requestId !== 'string' || typeof pending.bodyCid !== 'string'
52
+ || typeof pending.approvalUrl !== 'string' || typeof pending.relayPrivateKey !== 'string' || typeof pending.ackId !== 'string') {
53
+ return { kind: 'corrupt', reason: 'unexpected contents' };
54
+ }
55
+ return { kind: 'ok', pending };
56
+ }
57
+ /** How far a held request has got: committed outranks a collected result, which outranks a reference. Undefined: nothing held. */
58
+ export function stageOf(pending) {
59
+ if (pending.committed === true)
60
+ return 'committed';
61
+ if (pending.resultEnvelope !== undefined)
62
+ return 'result';
63
+ if (pending.resultRef !== undefined)
64
+ return 'ref';
65
+ return undefined;
66
+ }
67
+ /** The request already holds something that cannot be made again: a result reference, the result itself, or an installed grant. */
68
+ export const holdsApproval = (pending) => stageOf(pending) !== undefined;
69
+ /** A saved request has no owner to belong to when the profile or the agent key is missing. */
70
+ export const lacksOwner = (presence) => !presence.profile || !presence.key;
71
+ /**
72
+ * The classifier. `presence` says whether the profile and the agent key exist (default: both do). The request's own expiry only matters
73
+ * while nothing is held, and is judged against `now`, exactly as before.
74
+ */
75
+ export function approvalPhase(entry, presence = { profile: true, key: true }, now = Date.now()) {
76
+ if (entry.kind === 'none')
77
+ return { phase: 'none' };
78
+ if (entry.kind === 'corrupt')
79
+ return { phase: 'corrupt', reason: entry.reason };
80
+ let live;
81
+ if (entry.kind === 'unreadable') {
82
+ live = { phase: 'unreadable', reason: entry.reason, fix: entry.fix };
83
+ }
84
+ else {
85
+ const stage = stageOf(entry.pending);
86
+ live = stage ? { phase: 'held', stage } : { phase: 'open', lapsed: Date.parse(entry.pending.requestExpiresAt) <= now };
87
+ }
88
+ return lacksOwner(presence) ? { phase: 'orphaned', was: live } : live;
89
+ }
90
+ /** The saved request when it can be read, else undefined (with a warning when a file is there but unusable). For read-only and cleanup callers. */
91
+ export function loadPending() {
92
+ const entry = readApprovalEntry();
93
+ if (entry.kind === 'ok')
94
+ return entry.pending;
95
+ if (entry.kind !== 'none') {
96
+ console.error(`warning: ignoring the saved approval request ${APPROVAL_FILE} in ${profileDir()} (${entry.reason}).`);
97
+ }
98
+ return undefined;
99
+ }
100
+ function unreadableError(entry) {
101
+ return new CourierError('approval-unreadable', `The saved approval request cannot be used: ${entry.reason}. It may hold an approval the owner already gave, so it was not touched and nothing else was changed. Fix it with \`${entry.fix}\`, then run \`${cmd('init --wait')}\` again.`);
102
+ }
103
+ /**
104
+ * Stops (without changing anything) when a saved approval request exists but cannot be read. Call it before a writer changes any
105
+ * other state, so a key, grant or profile is never replaced while the approval it belongs to is unreachable.
106
+ */
107
+ export function assertPendingReadable() {
108
+ const entry = readApprovalEntry();
109
+ if (entry.kind === 'unreadable')
110
+ throw unreadableError(entry);
111
+ }
112
+ /**
113
+ * The saved request for a caller that is about to write one. A record that exists but cannot be read stops the command with the file
114
+ * named and how to fix it: the approval in it may be intact (it comes back after the fix). Only a record that is readable and corrupt
115
+ * is set aside, as `approval-pending.json.corrupt-<time>` (0600), and the command goes on without it.
116
+ */
117
+ export function loadPendingForWrite() {
118
+ const entry = readApprovalEntry();
119
+ if (entry.kind === 'ok')
120
+ return entry.pending;
121
+ if (entry.kind === 'unreadable') {
122
+ throw unreadableError(entry);
123
+ }
124
+ if (entry.kind === 'corrupt') {
125
+ const file = path.join(profileDir(), APPROVAL_FILE);
126
+ const aside = `${file}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}`;
127
+ fs.renameSync(file, aside);
128
+ fs.chmodSync(aside, 0o600);
129
+ console.error(`warning: ${APPROVAL_FILE} is corrupt (${entry.reason}); it was moved to ${aside} and will be replaced by a new request.`);
130
+ }
131
+ return undefined;
132
+ }
133
+ export function savePending(pending) {
134
+ writeSecure(path.join(profileDir(), APPROVAL_FILE), pending);
135
+ }
136
+ export function clearPending() {
137
+ try {
138
+ fs.unlinkSync(path.join(profileDir(), APPROVAL_FILE));
139
+ }
140
+ catch (error) {
141
+ if (error.code !== 'ENOENT')
142
+ throw error;
143
+ }
144
+ }
145
+ // ---- the record's optional fields: only this module reads or sets them ----
146
+ /** OpenKey holds a result for this request: remember where, and until when. */
147
+ export function recordResultRef(pending, resultRef, expiresAt) {
148
+ pending.resultRef = resultRef;
149
+ pending.resultExpiresAt = expiresAt;
150
+ }
151
+ /** The encrypted result was collected and is about to be saved. */
152
+ export function recordResultEnvelope(pending, relay) {
153
+ pending.resultEnvelope = relay;
154
+ }
155
+ /** The grant and profile are saved; only the acknowledgement may remain. */
156
+ export function markCommitted(pending) {
157
+ pending.committed = true;
158
+ }
159
+ /** The reference and expiry of a result already discovered, for a collection that should not ask again. */
160
+ export function knownResult(pending) {
161
+ return pending.resultRef && pending.resultExpiresAt ? { resultRef: pending.resultRef, expiresAt: pending.resultExpiresAt } : undefined;
162
+ }
163
+ /** The reference an acknowledgement is sent for; undefined when there is none to acknowledge. */
164
+ export function ackRef(pending) {
165
+ return pending.resultRef || undefined;
166
+ }
167
+ /** The collected, still encrypted result. */
168
+ export function resultEnvelopeOf(pending) {
169
+ return pending.resultEnvelope;
170
+ }
171
+ /** Whether a failing collection already had its result reference saved (so the failing call was the fetch). */
172
+ export function resultSeen(pending) {
173
+ return pending.resultRef !== undefined;
174
+ }
175
+ //# sourceMappingURL=approval-phase.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-phase.js","sourceRoot":"","sources":["../src/approval-phase.ts"],"names":[],"mappings":"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"}
package/dist/commands.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { adviceFor, deriveState, heldApproval, recoverInterrupted, rotateKey } from './state.js';
1
2
  import { recentNotices } from './notify.js';
2
3
  import fs from 'node:fs';
3
4
  import os from 'node:os';
@@ -7,10 +8,12 @@ import { NodeWasmBindings, activateValidatedRuntimeDelegation, deserializeDelega
7
8
  import { cmd } from './program.js';
8
9
  import { CourierError, CourierPending, errInfo } from './errors.js';
9
10
  import { AGENT_GRANT, AGENT_GRANT_EXPIRY, COURIER_SPACE, DEFAULT_HOST, EXT, k, peerReadPermissions, PREFIX } from './keys.js';
10
- import { loadProfile, saveProfile, getPeer, profileDir, profileExists, loadKey, saveKey, } from './profile.js';
11
+ import { loadProfile, saveProfile, assertNotPaired, getPeer, profileDir, profileExists, loadKey, saveKey, } from './profile.js';
11
12
  import { courierNode, peerKv } from './node.js';
13
+ import { REVOKE_URL } from './grant-refusal.js';
12
14
  import { courierGrantRequest, grantFromInput, installRefusing, isShortCode, noSpaceError, pendingSpaceError, receiveGrant, resumeAwaitingGrant, resumeGrant } from './grant.js';
13
- import { assertPendingReadable, clearPending, completeApproval, DEFAULT_WAIT_MS, holdsApproval, loadPending, MAX_WAIT_MS, readPending, requestApproval, waitApproval } from './grant-request.js';
15
+ import { completeApproval, DEFAULT_WAIT_MS, MAX_WAIT_MS, requestApproval, waitApproval } from './grant-request.js';
16
+ import { approvalPhase, assertPendingReadable, clearPending, holdsApproval, loadPending, readApprovalEntry, stageOf } from './approval-phase.js';
14
17
  import { artifactA, artifactWithDelegation, parseArtifact, peerRecordBody, PEER_DELEGATION_EXPIRY, validateArtifactIdentity, } from './pairing.js';
15
18
  import { authRequiredTask, buildMessage, buildTask, COUNTERPARTY_NOTICE, isRecord, messageView, oneLine, safeText, messageRole, parseTaskState, taskView, INTENTS, } from './a2a.js';
16
19
  import { commitReceipts, collectInbound, renderInboxText, writeOut } from './inbox.js';
@@ -96,12 +99,41 @@ export async function cmdInit(args) {
96
99
  const grantRaw = grantFile === undefined || args.has('request')
97
100
  ? undefined
98
101
  : grantFile === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(grantFile, 'utf8');
102
+ // Explicit recovery before any mode acts: finish an interrupted rotation, remove stray key-bearing temp files; then derive once.
103
+ recoverInterrupted();
104
+ const state = deriveState();
105
+ for (const note of state.notes)
106
+ console.error(`warning: ${note}`);
107
+ // A profile.json that exists and cannot be read is never treated as absent: `loadProfile` refuses with its own message (an unreadable
108
+ // profile is replaced only by an explicit `--rotate-key`).
109
+ if (state.action === 'fix-profile' && !args.has('rotate-key')) {
110
+ loadProfile();
111
+ fail('bad-profile', adviceFor(state));
112
+ }
113
+ // A saved approval without its profile or key is left as it is; `--rotate-key` starts over with a new key.
114
+ if (state.action === 'refuse' && !args.has('rotate-key'))
115
+ fail('approval-orphaned', adviceFor(state));
116
+ // A grant.json that is not owner-only is a saved grant that cannot be used yet: it is neither replaced nor counted as missing. A rotation
117
+ // discards the grant by design, so it goes on.
118
+ if (state.grantMode && !args.has('rotate-key')) {
119
+ const file = path.join(profileDir(), 'grant.json');
120
+ fail('profile-permissions', `${file} must be owner-only (mode 0600); found mode ${state.grantMode}. It holds a saved grant, so nothing was changed. Run \`chmod 600 ${file}\`, then run the command again.`);
121
+ }
122
+ const HELD_APPROVAL_KEPT = `The saved approval request holds an approval from the owner and was kept: run \`${cmd('init --wait')}\` to collect or acknowledge it.`;
99
123
  if (args.has('resume')) {
100
124
  if (!profileExists())
101
125
  fail('no-grant', `No Courier profile to resume. Run \`${cmd('init')}\` and approve a grant first.`);
102
126
  const profile = loadProfile();
127
+ // An approval the owner gave, with no grant saved to install first, is collected by `--wait`, never discarded or shadowed by a cleanup.
128
+ if (state.action === 'wait' && heldApproval(state.inputs.approval) && profile.status !== 'ready') {
129
+ console.error(`${adviceFor(state)} Continuing as \`${cmd('init --wait')}\`.`);
130
+ await collectApproval(profile, loadKey(), { expiry: args.get('expiry'), openkeyApiHost: apiHostFlag, waitMs });
131
+ return;
132
+ }
103
133
  if (profile.status === 'awaiting-grant' && await resumeAwaitingGrant(profile)) {
104
134
  dropOpenRequest();
135
+ if (heldApproval(state.inputs.approval))
136
+ console.error(HELD_APPROVAL_KEPT);
105
137
  await finishInit(profile, profile.host);
106
138
  return;
107
139
  }
@@ -109,22 +141,20 @@ export async function cmdInit(args) {
109
141
  fail('usage', `\`${cmd('init --resume')}\` requires a profile in awaiting-space state.`);
110
142
  await resumeGrant(profile, loadKey());
111
143
  dropOpenRequest();
144
+ if (heldApproval(state.inputs.approval))
145
+ console.error(HELD_APPROVAL_KEPT);
112
146
  await finishInit(profile, profile.host);
113
147
  return;
114
148
  }
115
149
  const rotate = args.has('rotate-key');
116
150
  // A writer must learn that the saved approval is unreadable BEFORE the key, grant or profile is changed.
117
- if (args.has('request') || args.has('wait'))
151
+ // Also for a plain `--rotate-key`: an approval that cannot be read may be bound to the old key, and a rotation would leave it there.
152
+ if (args.has('request') || args.has('wait') || rotate)
118
153
  assertPendingReadable();
119
- // Read before the key is replaced: an approval the owner already gave is bound to the old key and is lost by a rotation.
120
- const heldApproval = rotate && (() => { const saved = loadPending(); return saved !== undefined && holdsApproval(saved); })();
121
154
  const prior = profileExists() && !rotate ? loadProfile() : undefined;
122
- // A renewal is asked for with `--request`; `--wait` only collects. Starting a request here would hide its link on stderr.
123
- if (args.has('wait') && prior && prior.status !== 'awaiting-grant' && !loadPending()) {
124
- fail('usage', prior.status === 'awaiting-space'
125
- ? `The grant is saved and the "${COURIER_SPACE}" space is still being set up: run \`${cmd('init --resume')}\`. \`${cmd('init --wait')}\` collects an approval that \`${cmd('init --request')}\` asked for.`
126
- : `This profile already has a grant and no approval request is pending. To renew it, run \`${cmd('init --request')}\`, send the printed openkeyUrl to the owner, then run \`${cmd('init --wait')}\`.`);
127
- }
155
+ // A renewal is asked for with `--request`; `--wait` only collects. The derived state says when `--wait` goes on.
156
+ if (args.has('wait') && !rotate && !state.wait)
157
+ fail('usage', adviceFor(state));
128
158
  const key = prior ? loadKey() : newAgentKey();
129
159
  const host = hostFlag ?? prior?.host ?? process.env.COURIER_HOST ?? DEFAULT_HOST;
130
160
  const openkeyHost = openkeyHostFlag ?? prior?.openkeyHost ?? process.env.COURIER_OPENKEY_HOST ?? 'https://openkey.so';
@@ -143,25 +173,23 @@ export async function cmdInit(args) {
143
173
  profile.label = labelFlag ?? profile.label;
144
174
  if (handleFlag)
145
175
  profile.selfHandle = handleFlag;
176
+ profile.courierDid = bareDid(key.did);
146
177
  if (rotate) {
147
178
  profile.peers = {};
148
179
  profile.ownerDid = undefined;
149
180
  profile.spaceId = undefined;
150
181
  profile.grantExpiresAt = undefined;
151
182
  profile.status = 'awaiting-grant';
152
- try {
153
- fs.unlinkSync(path.join(profileDir(), 'grant.json'));
154
- }
155
- catch { /* no prior grant */ }
183
+ // One journaled step: key, profile, grant and a discarded approval change together, and an interruption is finished by the next init.
184
+ await rotateKey(key, profile);
185
+ }
186
+ else {
187
+ saveKey(key);
188
+ saveProfile(profile);
156
189
  }
157
- profile.courierDid = bareDid(key.did);
158
- saveKey(key);
159
- saveProfile(profile);
160
190
  const request = courierGrantRequest(profile, key, expiry, { openkeyApiHost: apiHostFlag });
161
191
  if (rotate)
162
192
  console.error('Agent key rotated. Existing peer pairings must be re-established; the old grant remains valid until expiry.');
163
- if (heldApproval)
164
- console.error('The saved approval request already held an approval from the owner. It is bound to the old agent key, so this key rotation discards that approval: the owner must approve a new request.');
165
193
  if (args.has('request')) {
166
194
  const pending = await requestApproval(profile, key, args.get('expiry'), { openkeyApiHost: apiHostFlag });
167
195
  console.error(`Send the owner this link to approve (it expires ${pending.requestExpiresAt}). Copy it exactly, never retype it:\n${pending.approvalUrl}\nThen run \`${cmd('init --wait --timeout 60s')}\` to collect the approval.`);
@@ -171,18 +199,7 @@ export async function cmdInit(args) {
171
199
  return;
172
200
  }
173
201
  if (args.has('wait')) {
174
- const deadline = Date.now() + waitMs;
175
- const { pending, resumed } = await waitApproval(profile, key, args.get('expiry'), { openkeyApiHost: apiHostFlag });
176
- if (!pending.committed) {
177
- console.error(`${resumed ? 'Resuming the saved approval request.' : 'Created an approval request.'} Owner: approve it in OpenKey (copy the link exactly, never retype it):\n${pending.approvalUrl}\nWaiting for approval (up to ${Math.round(waitMs / 1000)} s). Run \`${cmd('init --wait')}\` again after an interruption; no new approval is needed.`);
178
- }
179
- if (await completeApproval(profile, key, pending, { deadline }) === 'deadline') {
180
- throw new CourierPending({ step: 1, of: 1, approvalUrl: pending.approvalUrl, expiresAt: pending.requestExpiresAt });
181
- }
182
- const active = loadProfile();
183
- if (active.status !== 'ready')
184
- throw active.spacePending ? pendingSpaceError(active) : noSpaceError(active);
185
- await finishInit(active, active.host);
202
+ await collectApproval(profile, key, { expiry: args.get('expiry'), openkeyApiHost: apiHostFlag, waitMs });
186
203
  return;
187
204
  }
188
205
  let payload;
@@ -211,14 +228,14 @@ export async function cmdInit(args) {
211
228
  */
212
229
  function clearPendingQuietly() {
213
230
  try {
214
- const read = readPending();
215
- if (read.kind === 'ok' && read.pending.committed) {
231
+ const phase = approvalPhase(readApprovalEntry());
232
+ if (phase.phase === 'held' && phase.stage === 'committed') {
216
233
  console.error('This grant replaces a saved short-link approval that was already installed: its acknowledgement to OpenKey is never sent (harmless; OpenKey discards the result).');
217
234
  }
218
- else if (read.kind === 'ok' && holdsApproval(read.pending)) {
235
+ else if (phase.phase === 'held') {
219
236
  console.error('This grant replaces the saved short-link approval: discarded a saved approval that was not installed.');
220
237
  }
221
- else if (read.kind === 'unreadable' || read.kind === 'corrupt') {
238
+ else if (phase.phase === 'unreadable' || phase.phase === 'corrupt') {
222
239
  console.error('This grant replaces the saved short-link request: discarded an unreadable saved approval request (it may have held an approval that was not installed).');
223
240
  }
224
241
  clearPending();
@@ -237,6 +254,21 @@ function dropOpenRequest() {
237
254
  if (saved && !holdsApproval(saved))
238
255
  clearPendingQuietly();
239
256
  }
257
+ /** `init --wait`: create or resume the approval request, wait for the owner, install the result and acknowledge it. `--resume` hands off here while an approval is saved. */
258
+ async function collectApproval(profile, key, options) {
259
+ const deadline = Date.now() + options.waitMs;
260
+ const { pending, resumed } = await waitApproval(profile, key, options.expiry, { openkeyApiHost: options.openkeyApiHost });
261
+ if (stageOf(pending) !== 'committed') {
262
+ console.error(`${resumed ? 'Resuming the saved approval request.' : 'Created an approval request.'} Owner: approve it in OpenKey (copy the link exactly, never retype it):\n${pending.approvalUrl}\nWaiting for approval (up to ${Math.round(options.waitMs / 1000)} s). Run \`${cmd('init --wait')}\` again after an interruption; no new approval is needed.`);
263
+ }
264
+ if (await completeApproval(profile, key, pending, { deadline }) === 'deadline') {
265
+ throw new CourierPending({ step: 1, of: 1, approvalUrl: pending.approvalUrl, expiresAt: pending.requestExpiresAt });
266
+ }
267
+ const active = loadProfile();
268
+ if (active.status !== 'ready')
269
+ throw active.spacePending ? pendingSpaceError(active) : noSpaceError(active);
270
+ await finishInit(active, active.host);
271
+ }
240
272
  async function finishInit(profile, host) {
241
273
  const active = await ctx();
242
274
  for (const peer of Object.values(active.profile.peers)) {
@@ -326,6 +358,7 @@ async function pairAccept(args) {
326
358
  fail('bad-artifact', 'Refusing to pair with this agent identity.');
327
359
  if (art.step === 'A') {
328
360
  const handle = art.callMe;
361
+ assertNotPaired(profile, handle); // before delegateTo: a refusal after it would leave a live delegation nothing records
329
362
  const outbound = await node.delegateTo(art.from.courier, peerReadPermissions(handle, profile.spaceId), { expiry: PEER_DELEGATION_EXPIRY });
330
363
  const peer = {
331
364
  handle, ownerDid: art.from.owner, courierDid: art.from.courier, label: art.from.label,
@@ -333,7 +366,7 @@ async function pairAccept(args) {
333
366
  };
334
367
  recordOutbound(peer, outbound.delegation);
335
368
  profile.peers[handle] = peer;
336
- saveProfile(profile);
369
+ saveAfterDelegation(profile, outbound.delegation);
337
370
  await writePeerRecord(node, peer);
338
371
  printJson(artifactWithDelegation('B', profile, outbound.delegation, handle, art.peer));
339
372
  return;
@@ -355,7 +388,7 @@ async function pairAccept(args) {
355
388
  const outgoing = await node.delegateTo(peer.courierDid, peerReadPermissions(peer.handle, profile.spaceId), { expiry: PEER_DELEGATION_EXPIRY });
356
389
  recordOutbound(peer, outgoing.delegation);
357
390
  peer.status = 'paired';
358
- saveProfile(profile);
391
+ saveAfterDelegation(profile, outgoing.delegation);
359
392
  await writePeerRecord(node, peer);
360
393
  printJson(artifactWithDelegation('C', profile, outgoing.delegation, peer.handle, art.peer));
361
394
  return;
@@ -388,6 +421,19 @@ async function pairAccept(args) {
388
421
  await writePeerRecord(node, peer);
389
422
  printJson({ ok: true, paired: peer.handle, peerDid: peer.courierDid, peerSpace: peer.spaceId, delegationExpiresAt: peer.inbound.expiresAt });
390
423
  }
424
+ /**
425
+ * Saves after `delegateTo` made a delegation that is live from that moment. A save that refuses (the pairing completed, or the peer was
426
+ * revoked, meanwhile) leaves a delegation no profile records: say so, with its CID and where the owner can revoke it, and never publish it.
427
+ */
428
+ function saveAfterDelegation(profile, delegation) {
429
+ try {
430
+ saveProfile(profile);
431
+ }
432
+ catch (error) {
433
+ console.error(`warning: the delegation ${delegationCid(delegation)} created for this pairing is live and was not saved. Nothing was published. The owner can revoke it at ${REVOKE_URL}.`);
434
+ throw error;
435
+ }
436
+ }
391
437
  function recordOutbound(peer, delegation) {
392
438
  const expiresAt = delegation.expiry instanceof Date ? delegation.expiry.toISOString() : new Date(delegation.expiry).toISOString();
393
439
  peer.outbound.push({ cid: delegationCid(delegation), issuedAt: new Date().toISOString(), expiresAt });