agmsg-cloud 0.1.0-rc.4 → 0.1.0-rc.6

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.
@@ -1,3 +1,4 @@
1
+ import { setupCommand } from './recovery-key.js';
1
2
  import { shellArg } from './shell-arg.js';
2
3
  /**
3
4
  * Explain a slot that did not open, given the command that will now ask for the
@@ -65,6 +66,90 @@ export function adviseOnSlot(result, ctx) {
65
66
  };
66
67
  }
67
68
  }
69
+ /**
70
+ * Explain a slot that did not open, given a command that will NOT ask for the
71
+ * recovery key — `connect`, which files the team's key into the vault silently
72
+ * or not at all.
73
+ *
74
+ * A second mapping rather than a second wording of the first: the sentences
75
+ * above all end in "the recovery key is typed and this run continues", and
76
+ * every one of them is false here. Nothing continues; the team is not in the
77
+ * vault, and what the person needs is the route that puts it there.
78
+ *
79
+ * The two live in one file, side by side, because they are one judgement read
80
+ * twice. `OpenResult['reason']` is a union and both switches are exhaustive, so
81
+ * a fifth reason cannot be answered in one of them and forgotten in the other —
82
+ * it fails to compile until both have looked at it.
83
+ *
84
+ * The `no-store` branch is why this exists at all. The prompting caller can say
85
+ * "the key is needed for each backup" and be done; here, the honest sentence is
86
+ * that this machine will never file silently, and a remedy that said "run
87
+ * `recovery setup` once and this stops happening" would be a route that does
88
+ * not exist on Windows or Linux.
89
+ */
90
+ export function adviseOnSlotForSilentFiling(result, ctx) {
91
+ if (result.ok)
92
+ return { tone: 'silent', lines: [] };
93
+ // NAMED, and the argument is the point rather than a detail.
94
+ //
95
+ // `recovery setup` takes the team optionally now, and the no-argument form is
96
+ // the one its own documentation calls ordinary — which is why this printed it
97
+ // at first. But the broad form's success depends on every OTHER team the
98
+ // machine reports: one pass, no per-team isolation, so a `key.sh handoff`
99
+ // that fails for an unrelated team throws out of the loop and nothing is
100
+ // written. And if `remote.sh status --json` comes back short (agmsg#650) the
101
+ // target list can be empty, which refuses outright.
102
+ //
103
+ // Either way the person is sent to a command that can fail for a reason that
104
+ // has nothing to do with the team they just connected — and this is the one
105
+ // route they are given. Naming the team makes the remedy reach exactly the
106
+ // thing the sentence above it is about (raised in review).
107
+ const setup = `\`${setupCommand(ctx.team)}\``;
108
+ switch (result.reason) {
109
+ case 'no-slot':
110
+ return {
111
+ tone: 'note',
112
+ lines: [
113
+ 'this machine has no key slot for this vault, so the team was not backed up.',
114
+ `Run ${setup} and type the recovery key once; after that this machine`,
115
+ 'files new teams itself.',
116
+ ],
117
+ };
118
+ case 'no-store':
119
+ // The one that must not promise a fix. There is no secure store to keep a
120
+ // slot in, so every future `connect` on this machine lands here too.
121
+ return {
122
+ tone: 'note',
123
+ lines: [
124
+ 'this machine has no secure store, so it cannot back up a team without the recovery',
125
+ `key: the team was not backed up. Run ${setup} to back it up now.`,
126
+ 'This machine will need the key each time; a machine with a secure store will not.',
127
+ ],
128
+ };
129
+ case 'store-locked':
130
+ // Order matters here and only here: running setup against a locked store
131
+ // backs the team up but keeps no slot, so the next connect asks again.
132
+ return {
133
+ tone: 'note',
134
+ lines: [
135
+ 'this machine has a secure store but it refused: ' + result.detail,
136
+ `The team was not backed up. Unlock the store, then run ${setup} —`,
137
+ 'in that order, so a key slot is kept and later teams are filed without asking.',
138
+ ],
139
+ };
140
+ case 'unusable':
141
+ return {
142
+ tone: 'warning',
143
+ lines: [
144
+ 'this machine has a key slot for this vault and its contents did not hold up: ' +
145
+ result.detail,
146
+ `The team was not backed up. Run ${setup}, which asks for the recovery`,
147
+ 'key and replaces the slot. Until then this team exists only on the machines',
148
+ 'holding it.',
149
+ ],
150
+ };
151
+ }
152
+ }
68
153
  /** Render advice for a terminal. Empty string when there is nothing to say. */
69
154
  export function renderSlotAdvice(advice) {
70
155
  if (advice.lines.length === 0)
@@ -87,6 +87,45 @@ export function upsertTeam(container, entry) {
87
87
  const teams = [...others, entry].sort((a, b) => (identityOf(a) < identityOf(b) ? -1 : 1));
88
88
  return { format: CONTAINER_FORMAT, version: CONTAINER_VERSION, teams };
89
89
  }
90
+ /**
91
+ * The same two key epochs, however they are ordered.
92
+ *
93
+ * A set comparison, not a sequence one: the epochs come out of a bundle the OSS
94
+ * side produced, and nothing promises the order is stable between two runs.
95
+ * Comparing them in order would report a change on a re-ordering and append a
96
+ * version identical to the one before it.
97
+ */
98
+ export function sameEpochs(a, b) {
99
+ if (a.length !== b.length)
100
+ return false;
101
+ const left = [...a].sort();
102
+ const right = [...b].sort();
103
+ return left.every((v, i) => v === right[i]);
104
+ }
105
+ /**
106
+ * Put one team into the container, unless its epochs are already the ones in
107
+ * there.
108
+ *
109
+ * The `written` half is the point. `recovery setup` documents itself as
110
+ * idempotent — "a run where no team's epochs have moved writes nothing and
111
+ * leaves the revision where it was" — and that promise belongs to the container,
112
+ * not to the command that happened to make it first. `connect` files a team the
113
+ * moment it is created and is re-runnable, so without this a second `connect`
114
+ * on a team already in the vault appends a version identical to the one before
115
+ * it, and the revision history stops meaning "something changed here".
116
+ *
117
+ * Kept beside `upsertTeam` rather than in either caller, because two copies of
118
+ * "has this actually moved" that drifted would disagree about whether a write
119
+ * was needed — and the one that said yes would silently win.
120
+ */
121
+ export function upsertTeamIfMoved(container, entry) {
122
+ const key = identityOf(entry);
123
+ const already = container.teams.find((t) => identityOf(t) === key);
124
+ if (already && sameEpochs(already.key_ids, entry.key_ids)) {
125
+ return { container, written: false };
126
+ }
127
+ return { container: upsertTeam(container, entry), written: true };
128
+ }
90
129
  export function serializeContainer(container) {
91
130
  return Buffer.from(JSON.stringify(container), 'utf8');
92
131
  }
@@ -0,0 +1,62 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { openDeviceSlot } from './device-slot.js';
5
+ import { keyHandoff, remoteBinding } from './oss.js';
6
+ import { parseContainer, serializeContainer, upsertTeamIfMoved } from './vault-container.js';
7
+ import { appendVaultVersionWithVdk, openVaultWithVdk, readAccountVault } from './vault-protocol.js';
8
+ import { bundleKeyIds, slotAddress } from './commands/vault.js';
9
+ /**
10
+ * File one team's handoff bundle into this account's vault, if that can be done
11
+ * silently.
12
+ *
13
+ * Fails closed in the sense that matters: it never invents a vault, never asks
14
+ * for a key, and never reports success for a write it did not make.
15
+ */
16
+ export async function fileTeamInVaultSilently(config, client, team) {
17
+ const { identity, version } = await readAccountVault(client);
18
+ // No vault means there is no recovery key either — creating one here would
19
+ // mint a key and show it during a command nobody ran for that purpose.
20
+ if (!version)
21
+ return { filed: false, reason: 'no-vault' };
22
+ const opened = await openDeviceSlot(slotAddress(identity, version.vault_id, version.recovery_generation));
23
+ // Every way of not having a slot lands here, and none of them is an error:
24
+ // a machine that has never run `recovery setup`, a platform with no secure
25
+ // store, a locked keychain. The caller names the route instead.
26
+ if (!opened.ok)
27
+ return { filed: false, reason: 'no-slot', slot: opened };
28
+ const binding = await remoteBinding(config.scriptsDir, team);
29
+ const current = openVaultWithVdk(identity, version, opened.vdk);
30
+ const container = parseContainer(current.content);
31
+ const scratch = mkdtempSync(join(tmpdir(), 'agmsg-cloud-filing-'));
32
+ try {
33
+ const bundleFile = join(scratch, 'handoff.bundle');
34
+ await keyHandoff(config.scriptsDir, team, bundleFile);
35
+ const bundle = readFileSync(bundleFile);
36
+ // The same decision `recovery setup` makes, from the same function.
37
+ //
38
+ // `connect` is re-runnable — a second run on a team that is already
39
+ // connected continues to the registration rather than refusing — so
40
+ // without this it would append a version identical to the one before it
41
+ // every time. `recovery setup` documents itself as idempotent; a second
42
+ // writer that is not makes that sentence false about the account.
43
+ const { container: next, written } = upsertTeamIfMoved(container, {
44
+ server_instance_id: binding.serverInstanceId,
45
+ team_id: binding.teamId,
46
+ key_ids: bundleKeyIds(bundle),
47
+ bundle: bundle.toString('base64'),
48
+ });
49
+ if (!written) {
50
+ // Backed up, and this run is not what did it. Told apart from a write
51
+ // because the caller says "revision N" and there is no new N to say.
52
+ return { filed: true, wrote: false, revision: version.revision, teamCount: next.teams.length };
53
+ }
54
+ const result = await appendVaultVersionWithVdk(client, identity, version, opened.vdk, serializeContainer(next));
55
+ return { filed: true, wrote: true, revision: result.revision, teamCount: next.teams.length };
56
+ }
57
+ finally {
58
+ // The bundle is key material. Removed on every path this process controls,
59
+ // the same way the ceremony's snapshot is.
60
+ rmSync(scratch, { recursive: true, force: true });
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agmsg-cloud",
3
- "version": "0.1.0-rc.4",
3
+ "version": "0.1.0-rc.6",
4
4
  "description": "Companion CLI for the agmsg cloud service: connect a team, join from another machine, and back up its keys.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",