@tinycloud/courier 0.5.1-beta.1 → 0.5.1-beta.3

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';
@@ -10,7 +11,8 @@ import { AGENT_GRANT, AGENT_GRANT_EXPIRY, COURIER_SPACE, DEFAULT_HOST, EXT, k, p
10
11
  import { loadProfile, saveProfile, getPeer, profileDir, profileExists, loadKey, saveKey, } from './profile.js';
11
12
  import { courierNode, peerKv } from './node.js';
12
13
  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';
14
+ import { completeApproval, DEFAULT_WAIT_MS, MAX_WAIT_MS, requestApproval, waitApproval } from './grant-request.js';
15
+ import { approvalPhase, assertPendingReadable, clearPending, holdsApproval, loadPending, readApprovalEntry, stageOf } from './approval-phase.js';
14
16
  import { artifactA, artifactWithDelegation, parseArtifact, peerRecordBody, PEER_DELEGATION_EXPIRY, validateArtifactIdentity, } from './pairing.js';
15
17
  import { authRequiredTask, buildMessage, buildTask, COUNTERPARTY_NOTICE, isRecord, messageView, oneLine, safeText, messageRole, parseTaskState, taskView, INTENTS, } from './a2a.js';
16
18
  import { commitReceipts, collectInbound, renderInboxText, writeOut } from './inbox.js';
@@ -96,12 +98,41 @@ export async function cmdInit(args) {
96
98
  const grantRaw = grantFile === undefined || args.has('request')
97
99
  ? undefined
98
100
  : grantFile === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(grantFile, 'utf8');
101
+ // Explicit recovery before any mode acts: finish an interrupted rotation, remove stray key-bearing temp files; then derive once.
102
+ recoverInterrupted();
103
+ const state = deriveState();
104
+ for (const note of state.notes)
105
+ console.error(`warning: ${note}`);
106
+ // A profile.json that exists and cannot be read is never treated as absent: `loadProfile` refuses with its own message (an unreadable
107
+ // profile is replaced only by an explicit `--rotate-key`).
108
+ if (state.action === 'fix-profile' && !args.has('rotate-key')) {
109
+ loadProfile();
110
+ fail('bad-profile', adviceFor(state));
111
+ }
112
+ // A saved approval without its profile or key is left as it is; `--rotate-key` starts over with a new key.
113
+ if (state.action === 'refuse' && !args.has('rotate-key'))
114
+ fail('approval-orphaned', adviceFor(state));
115
+ // 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
116
+ // discards the grant by design, so it goes on.
117
+ if (state.grantMode && !args.has('rotate-key')) {
118
+ const file = path.join(profileDir(), 'grant.json');
119
+ 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.`);
120
+ }
121
+ 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
122
  if (args.has('resume')) {
100
123
  if (!profileExists())
101
124
  fail('no-grant', `No Courier profile to resume. Run \`${cmd('init')}\` and approve a grant first.`);
102
125
  const profile = loadProfile();
126
+ // An approval the owner gave, with no grant saved to install first, is collected by `--wait`, never discarded or shadowed by a cleanup.
127
+ if (state.action === 'wait' && heldApproval(state.inputs.approval) && profile.status !== 'ready') {
128
+ console.error(`${adviceFor(state)} Continuing as \`${cmd('init --wait')}\`.`);
129
+ await collectApproval(profile, loadKey(), { expiry: args.get('expiry'), openkeyApiHost: apiHostFlag, waitMs });
130
+ return;
131
+ }
103
132
  if (profile.status === 'awaiting-grant' && await resumeAwaitingGrant(profile)) {
104
133
  dropOpenRequest();
134
+ if (heldApproval(state.inputs.approval))
135
+ console.error(HELD_APPROVAL_KEPT);
105
136
  await finishInit(profile, profile.host);
106
137
  return;
107
138
  }
@@ -109,22 +140,20 @@ export async function cmdInit(args) {
109
140
  fail('usage', `\`${cmd('init --resume')}\` requires a profile in awaiting-space state.`);
110
141
  await resumeGrant(profile, loadKey());
111
142
  dropOpenRequest();
143
+ if (heldApproval(state.inputs.approval))
144
+ console.error(HELD_APPROVAL_KEPT);
112
145
  await finishInit(profile, profile.host);
113
146
  return;
114
147
  }
115
148
  const rotate = args.has('rotate-key');
116
149
  // 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'))
150
+ // 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.
151
+ if (args.has('request') || args.has('wait') || rotate)
118
152
  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
153
  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
- }
154
+ // A renewal is asked for with `--request`; `--wait` only collects. The derived state says when `--wait` goes on.
155
+ if (args.has('wait') && !rotate && !state.wait)
156
+ fail('usage', adviceFor(state));
128
157
  const key = prior ? loadKey() : newAgentKey();
129
158
  const host = hostFlag ?? prior?.host ?? process.env.COURIER_HOST ?? DEFAULT_HOST;
130
159
  const openkeyHost = openkeyHostFlag ?? prior?.openkeyHost ?? process.env.COURIER_OPENKEY_HOST ?? 'https://openkey.so';
@@ -143,25 +172,23 @@ export async function cmdInit(args) {
143
172
  profile.label = labelFlag ?? profile.label;
144
173
  if (handleFlag)
145
174
  profile.selfHandle = handleFlag;
175
+ profile.courierDid = bareDid(key.did);
146
176
  if (rotate) {
147
177
  profile.peers = {};
148
178
  profile.ownerDid = undefined;
149
179
  profile.spaceId = undefined;
150
180
  profile.grantExpiresAt = undefined;
151
181
  profile.status = 'awaiting-grant';
152
- try {
153
- fs.unlinkSync(path.join(profileDir(), 'grant.json'));
154
- }
155
- catch { /* no prior grant */ }
182
+ // One journaled step: key, profile, grant and a discarded approval change together, and an interruption is finished by the next init.
183
+ await rotateKey(key, profile);
184
+ }
185
+ else {
186
+ saveKey(key);
187
+ saveProfile(profile);
156
188
  }
157
- profile.courierDid = bareDid(key.did);
158
- saveKey(key);
159
- saveProfile(profile);
160
189
  const request = courierGrantRequest(profile, key, expiry, { openkeyApiHost: apiHostFlag });
161
190
  if (rotate)
162
191
  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
192
  if (args.has('request')) {
166
193
  const pending = await requestApproval(profile, key, args.get('expiry'), { openkeyApiHost: apiHostFlag });
167
194
  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 +198,7 @@ export async function cmdInit(args) {
171
198
  return;
172
199
  }
173
200
  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);
201
+ await collectApproval(profile, key, { expiry: args.get('expiry'), openkeyApiHost: apiHostFlag, waitMs });
186
202
  return;
187
203
  }
188
204
  let payload;
@@ -211,14 +227,14 @@ export async function cmdInit(args) {
211
227
  */
212
228
  function clearPendingQuietly() {
213
229
  try {
214
- const read = readPending();
215
- if (read.kind === 'ok' && read.pending.committed) {
230
+ const phase = approvalPhase(readApprovalEntry());
231
+ if (phase.phase === 'held' && phase.stage === 'committed') {
216
232
  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
233
  }
218
- else if (read.kind === 'ok' && holdsApproval(read.pending)) {
234
+ else if (phase.phase === 'held') {
219
235
  console.error('This grant replaces the saved short-link approval: discarded a saved approval that was not installed.');
220
236
  }
221
- else if (read.kind === 'unreadable' || read.kind === 'corrupt') {
237
+ else if (phase.phase === 'unreadable' || phase.phase === 'corrupt') {
222
238
  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
239
  }
224
240
  clearPending();
@@ -237,6 +253,21 @@ function dropOpenRequest() {
237
253
  if (saved && !holdsApproval(saved))
238
254
  clearPendingQuietly();
239
255
  }
256
+ /** `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. */
257
+ async function collectApproval(profile, key, options) {
258
+ const deadline = Date.now() + options.waitMs;
259
+ const { pending, resumed } = await waitApproval(profile, key, options.expiry, { openkeyApiHost: options.openkeyApiHost });
260
+ if (stageOf(pending) !== 'committed') {
261
+ 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.`);
262
+ }
263
+ if (await completeApproval(profile, key, pending, { deadline }) === 'deadline') {
264
+ throw new CourierPending({ step: 1, of: 1, approvalUrl: pending.approvalUrl, expiresAt: pending.requestExpiresAt });
265
+ }
266
+ const active = loadProfile();
267
+ if (active.status !== 'ready')
268
+ throw active.spacePending ? pendingSpaceError(active) : noSpaceError(active);
269
+ await finishInit(active, active.host);
270
+ }
240
271
  async function finishInit(profile, host) {
241
272
  const active = await ctx();
242
273
  for (const peer of Object.values(active.profile.peers)) {