agmsg-cloud 0.1.0-rc.7 → 0.1.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.
@@ -3,15 +3,51 @@ import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readF
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  /**
6
- * A server-minted org address: `org_` followed by a canonical uuid.
6
+ * A server-minted org address, in either of the two forms the server mints.
7
7
  *
8
8
  * Exported because the shape is a contract between the two places that care —
9
9
  * the caller that receives one over the wire and the key that is built from it.
10
10
  * Stating it in a comment next to the key was not enough: the comment justified
11
11
  * a delimiter by a property nothing enforced.
12
+ *
13
+ * org_<canonical uuid> what every server has answered with so far
14
+ * agor_<26 base32> the convention (#210), stored in `orgs.public_id`
15
+ *
16
+ * BOTH, AND THIS HALF SHIPS FIRST. Until this build is what `npm install`
17
+ * fetches, the server cannot start answering with the second form: the previous
18
+ * published build — measured on the tarball the registry serves, not on a
19
+ * branch, `agmsg-cloud@0.1.0-rc.8`, `dist/src/credentials.js` line 14 — matches
20
+ * `org_<uuid>` alone, and `keyFor` below THROWS on anything else. So an
21
+ * installed copy stops being able to store a credential the moment the server
22
+ * changes, and shipping a newer CLI does not repair an installation nobody has
23
+ * updated.
24
+ *
25
+ * Which is also why this accepts both rather than switching: for the whole
26
+ * period between this publish and the server's cutover, this build is talking
27
+ * to a server that still answers with `org_<uuid>`. A build that took only the
28
+ * new form would break exactly the window it exists to bridge.
29
+ *
30
+ * The `agor_` shape is not chosen here. It is the one the database enforces —
31
+ * `orgs.public_id`'s CHECK is `^agor_[a-z2-7]{26}$` (app/migrations/0036).
32
+ *
33
+ * A hand-written fourth copy of a mapping is the defect #216 is about, so what
34
+ * makes this one safe is a test rather than this paragraph:
35
+ * `test/org-address-matches-server.test.ts` parses the prefix, the body length
36
+ * and the alphabet out of `app/src/public-id.ts` and drives THIS function with
37
+ * values built from them. It is hand-written because the two packages share no
38
+ * module — `@agmsg-cloud/sas-core` is one protocol's derivations, not a place
39
+ * for whatever two packages both need.
40
+ *
41
+ * That covers one direction: this build accepting what that server mints. The
42
+ * other — the server refusing to emit anything this build would reject —
43
+ * belongs to the cutover change, and until it lands the contract holding the
44
+ * two together is the merge order itself: the cutover does not land until
45
+ * `npm view agmsg-cloud version` resolves to a build whose `isOrgAddress`
46
+ * accepts the new form.
12
47
  */
13
48
  export function isOrgAddress(value) {
14
- return /^org_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
49
+ return (/^org_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value) ||
50
+ /^agor_[a-z2-7]{26}$/.test(value));
15
51
  }
16
52
  /**
17
53
  * The storage key: one slot per (host, org).
@@ -19,9 +55,29 @@ export function isOrgAddress(value) {
19
55
  * Both halves are derived rather than taken as typed — `https://x.test/` and
20
56
  * `https://x.test` are one host and must not become two slots holding two
21
57
  * secrets. The separator is a space because neither half can contain one: an
22
- * origin has no spaces by construction, and an org address is `org_<uuid>`.
58
+ * origin has no spaces by construction, and neither org address form admits one
59
+ * — `isOrgAddress` is anchored at both ends over alphabets that exclude it, and
60
+ * that is now true of two shapes rather than one.
23
61
  *
24
- * That second half is now checked here rather than assumed. A malformed org —
62
+ * THE KEY IS THE ADDRESS AS RECEIVED, and the two forms of one org are two
63
+ * keys. That is correct while the server emits one form at a time: a machine
64
+ * enrolled before the cutover holds `org_<uuid>` and keeps using it, because
65
+ * nothing rewrites credentials.json and nothing here can turn one form into the
66
+ * other — `agor_` is not derived from the uuid, it is a separate stored value
67
+ * only the server can map. A machine that enrolls again after the cutover gets
68
+ * a second slot for the same org: no collision and no silent overwrite, since
69
+ * distinct keys is exactly what stops the second login clobbering the first.
70
+ *
71
+ * The cost is one reachable message, and it is worth naming rather than calling
72
+ * it harmless. `readCredential(origin)` throws when an origin has more than one
73
+ * slot and none is active — after logging out of the active one, that error now
74
+ * reads "credentials for more than one org (agor_…, org_…)" about what is ONE
75
+ * org wearing both of its addresses. The instruction it gives is still the
76
+ * right one and still works. Cleaning this up means mapping one form to the
77
+ * other, which only the server can do; it belongs to the cutover change, not
78
+ * here, and is recorded on #210.
79
+ *
80
+ * The org half is checked here rather than assumed. A malformed org —
25
81
  * `x`, or anything carrying a space or a newline — would not be caught by a
26
82
  * non-empty test upstream, and it does not fail loudly either: it writes a slot
27
83
  * under a key nothing will ever look up again, which is worse than the
@@ -84,14 +140,29 @@ function readFile(path) {
84
140
  * two secrets that both fit: don't.
85
141
  *
86
142
  * - one credential for the host — return it
87
- * - several, and `active` is one of them — return that, the last login
88
- * - several, and `active` is elsewhere — THROW, naming the orgs
143
+ * - none — return null
144
+ * - several — THROW, naming the orgs
145
+ *
146
+ * SEVERAL IS NOW ONLY REACHABLE FROM A FILE WRITTEN BEFORE #399, and the branch
147
+ * stays for exactly that reason. `writeCredential` cannot produce it any more,
148
+ * so on this version's own files the throw is unreachable — but the file
149
+ * outlives the binary that wrote it, and a machine upgrading from rc.8 can be
150
+ * holding two orgs for one host right now. Deleting the branch would not make
151
+ * that machine consistent; it would make it pick one silently, which is the
152
+ * failure the org half of the key was added to stop.
89
153
  *
90
- * The last case is the one worth being careful about. Returning `null` there
91
- * would say "no credential for this host", which is false and sends the caller
92
- * down the sign-in path — the same collapse of "ambiguous" into "absent" that
93
- * hid the original defect. It throws instead, and the message names the orgs so
94
- * the person can say which one they meant.
154
+ * The `active` tie-break went with the accumulation. It used to answer "several,
155
+ * and one of them is the last login — use that", and preferring the last login
156
+ * is precisely the behaviour that was ruled out: a machine holds one credential
157
+ * per host, so an origin with two is a machine in a state no version can act in
158
+ * rather than one with a default. `active` itself stays, for the `origin === null`
159
+ * path below — a machine may still hold one credential for each of several
160
+ * hosts.
161
+ *
162
+ * Returning `null` for the several case would say "no credential for this host",
163
+ * which is false and sends the caller down the sign-in path — the same collapse
164
+ * of "ambiguous" into "absent" that hid the original defect. It throws instead,
165
+ * and the message names the orgs and the one command that resolves it.
95
166
  */
96
167
  export function readCredential(origin, env = process.env) {
97
168
  const file = readFile(credentialsPath(env));
@@ -103,15 +174,14 @@ export function readCredential(origin, env = process.env) {
103
174
  return null;
104
175
  if (matches.length === 1)
105
176
  return matches[0][1];
106
- if (file.active && file.credentials[file.active] && file.active.startsWith(`${origin} `)) {
107
- return file.credentials[file.active];
108
- }
109
177
  const orgs = matches
110
178
  .map(([, c]) => c.org)
111
179
  .sort()
112
180
  .join(', ');
113
- throw new Error(`${origin} has credentials for more than one org (${orgs}) and none of them is the active one. ` +
114
- `Run agmsg-cloud login again for the org you want, which makes it active.`);
181
+ throw new Error(`${origin} has credentials for more than one org (${orgs}), which this version does not store. ` +
182
+ `That file was written by an older agmsg-cloud. Run agmsg-cloud logout --endpoint ${origin} ` +
183
+ `to remove both, then agmsg-cloud login for the org you want — a login now keeps one ` +
184
+ `credential per service.`);
115
185
  }
116
186
  // An atomic rename prevents a half-written FILE. It does not serialize an
117
187
  // update: two logins to different origins both read the same old JSON, both
@@ -272,6 +342,34 @@ function acquireLock(path) {
272
342
  spin(LOCK_RETRY_MS);
273
343
  }
274
344
  }
345
+ /**
346
+ * Every credential stored for one host, in no particular order, never throwing.
347
+ *
348
+ * `readCredential(origin)` fails closed when a host holds more than one, and
349
+ * that contract is right for the commands that ASK it something — `connect` and
350
+ * `pull` want one secret to present, and there is no honest way to pick between
351
+ * two. It is exactly wrong for `login`.
352
+ *
353
+ * Because `login` is the command that RESOLVES the ambiguity. It calls
354
+ * `readCredential(origin)` before anything else, so on a file written by rc.8 —
355
+ * two orgs on one host, which is the state this whole change exists to end — it
356
+ * threw before reaching the grant, the warning, or the replacing write. The
357
+ * error told the person to run `logout` first, which means "login replaces what
358
+ * is stored" held only for single slots that the NEW writer had produced, and
359
+ * not for the machines already in the state. The fail-closed guard was closed
360
+ * against its own remedy.
361
+ *
362
+ * So this is the reader for the one caller that is allowed to see the whole
363
+ * pile: it returns all of them and lets `login` say how many are going. Nothing
364
+ * else should use it — a command that acts on an ambiguous host is the defect,
365
+ * and only the one that is about to collapse the ambiguity is exempt.
366
+ */
367
+ export function credentialsForOrigin(origin, env = process.env) {
368
+ const file = readFile(credentialsPath(env));
369
+ return Object.entries(file.credentials)
370
+ .filter(([key]) => key.startsWith(`${origin} `))
371
+ .map(([, credential]) => credential);
372
+ }
275
373
  export function writeCredential(credential, env = process.env) {
276
374
  const path = credentialsPath(env);
277
375
  const dir = dirname(path);
@@ -287,15 +385,49 @@ export function writeCredential(credential, env = process.env) {
287
385
  release();
288
386
  }
289
387
  }
388
+ /**
389
+ * ONE CREDENTIAL PER ORIGIN, enforced here (#399).
390
+ *
391
+ * This used to merge: `{ ...file.credentials, [key]: credential }`, so a login
392
+ * under a second org added a slot beside the first and nothing ever removed it.
393
+ * The pile-up was not a feature — the key gained its org half to stop a second
394
+ * login silently CLOBBERING the first, and accumulation was the side effect. The
395
+ * resolver has always conceded as much by throwing when an origin holds several
396
+ * and none is active: what could be stored and what could be used disagreed, and
397
+ * the gap is what someone walked into when `logout` reported signing out of two
398
+ * orgs, one of which no longer existed on the server.
399
+ *
400
+ * PER ORIGIN, NOT PER MACHINE, and the difference is deliberate. The origin half
401
+ * of the key separates deployments — staging from production — which is a
402
+ * distinction someone chose. The org half is the one that accumulated. So a
403
+ * login to one host leaves another host's credential alone, and signing into
404
+ * staging cannot delete a production secret.
405
+ *
406
+ * The replacement happens INSIDE the lock and in ONE commit, rather than as a
407
+ * remove followed by a write. Two durable writes have a window between them
408
+ * where the file holds nothing, and this file is the only copy of a secret: an
409
+ * interrupt in that window would sign a machine out of a service it was never
410
+ * asked to leave. `commit` is temp -> fsync -> rename -> fsync dir, so the file
411
+ * on disk is either entirely the old credential or entirely the new one.
412
+ *
413
+ * Entries for OTHER origins are carried across untouched.
414
+ */
290
415
  function writeLocked(path, dir, credential) {
291
416
  const key = keyFor(credential.endpoint, credential.org);
417
+ const origin = originOf(credential.endpoint);
292
418
  // Read INSIDE the lock: a copy read before acquiring it is exactly the stale
293
419
  // base that loses another entry.
294
420
  const file = readFile(path);
421
+ const otherOrigins = Object.entries(file.credentials).filter(([existing]) => !existing.startsWith(`${origin} `));
295
422
  const next = {
296
423
  version: 1,
424
+ // Still a pointer, and still needed: `readCredential(null)` answers "what
425
+ // does this machine have" for callers that were given no endpoint, and a
426
+ // machine may legitimately hold one credential per origin. What died is
427
+ // choosing between two credentials for the SAME origin, which cannot happen
428
+ // any more.
297
429
  active: key,
298
- credentials: { ...file.credentials, [key]: credential },
430
+ credentials: { ...Object.fromEntries(otherOrigins), [key]: credential },
299
431
  };
300
432
  commit(path, dir, next);
301
433
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The one thing this CLI asks the data plane directly: which server is it.
3
+ *
4
+ * Everything else that touches the sync gateway goes through the OSS scripts.
5
+ * This does not, and the reason is an ordering problem they cannot solve for
6
+ * us: `remote.sh` only reports a server instance for a team it has already
7
+ * PULLED, and a pull is exactly what must not happen until the instance is
8
+ * known.
9
+ *
10
+ * A vault entry is keyed on `(server_instance_id, team_id)`. Placing one means
11
+ * pulling it, and a pull creates a local team under a person's name and gives
12
+ * it history — which, measured on the OSS side, is what makes the NEXT pull
13
+ * refuse. So a pull performed to discover that the entry belonged to another
14
+ * server leaves a team that cannot be replaced by the right one, and this
15
+ * product has no way to free a local name (#446). Nothing may be created until
16
+ * the pair is confirmed.
17
+ *
18
+ * ## Why `/v1/health`, and not `/v1/capabilities`
19
+ *
20
+ * The first version of this asked `/v1/capabilities` with the vault entry's
21
+ * team id, and that request is **not a read on the hosted path** (review).
22
+ * `gateway/src/server.ts` gives that route `scope: 'header'`, and a scoped
23
+ * request whose team id comes back `unclaimed` runs `claimOrVerifyTeamCapped`
24
+ * — for a GET. Probing an entry belonging to another server would therefore
25
+ * CLAIM that id for this org and spend a unit of the teams cap, and could stop
26
+ * the org that actually owns it from claiming it later. A check against
27
+ * creating a local obstacle would have created a server-side one.
28
+ *
29
+ * `/v1/health` is `scope: 'none'` in the same table, and **this sends no team
30
+ * header at all**. With no header the gateway's `healthTeamId` returns before
31
+ * `verifyTeam` (`if (requested)`), so no claim path is reachable — not
32
+ * "unlikely", unreachable. The response carries `server_instance_id`, which is
33
+ * the field the gateway itself caches from it.
34
+ *
35
+ * The absence of that header is load-bearing, so it is what the test asserts.
36
+ */
37
+ /** The OSS client uses 10s connect / 15s total for its own reads; matched. */
38
+ const HEALTH_TIMEOUT_MS = 15_000;
39
+ /**
40
+ * Ask an endpoint which server instance it is, without asking about any team.
41
+ *
42
+ * MEASURED against the OSS server: `GET /v1/health` with no headers answers
43
+ * 200 with `server_instance_id`, `status`, `database`, and an empty `team_id`.
44
+ * The instance is a property of the server, so naming no team costs nothing
45
+ * here — and naming one is what would have cost something.
46
+ */
47
+ export async function dataPlaneIdentity(capabilityUrl, fetchImpl = fetch) {
48
+ const url = `${capabilityUrl.replace(/\/+$/, '')}/v1/health`;
49
+ const response = await fetchImpl(url, {
50
+ method: 'GET',
51
+ // No `Agmsg-Team-ID`. See above: the header is what reaches the claim path.
52
+ headers: { 'Agmsg-Protocol-Version': '1' },
53
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
54
+ });
55
+ if (!response.ok) {
56
+ throw new Error(`the sync server did not answer which instance it is (HTTP ${response.status})`);
57
+ }
58
+ const body = await response.json();
59
+ const instance = body.server_instance_id;
60
+ // Shape-checked rather than trusted. This value decides whether a backup is
61
+ // placed, so a wrong one is the accident the check exists to prevent, wearing
62
+ // the check's clothes.
63
+ if (typeof instance !== 'string' || instance === '') {
64
+ throw new Error('the sync server answered without naming which instance it is');
65
+ }
66
+ return { serverInstanceId: instance };
67
+ }
package/dist/src/index.js CHANGED
@@ -11,7 +11,7 @@ import { cmdPull } from './commands/pull.js';
11
11
  import { cmdRequest } from './commands/request.js';
12
12
  import { cmdVaultPut, cmdVaultRestore } from './commands/vault.js';
13
13
  import { cmdWatch } from './commands/watch.js';
14
- import { packageVersion } from './version.js';
14
+ import { packageInstall } from './version.js';
15
15
  const USAGE = `agmsg-cloud — hosted agmsg from this machine
16
16
 
17
17
  login sign this machine in; approve it in your own browser
@@ -33,14 +33,15 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
33
33
  it is matched against the snapshot your code authenticated
34
34
  approve <team> [request-id] (on a key-holding machine) approve the waiting request after the codes match
35
35
  watch [--interval-ms <n>] (on a key-holding machine) print pending requests as they arrive
36
- recovery setup [team] create this account's recovery key and back up every active
37
- team this machine's store reports; name one to back up
38
- only that team
39
- recovery restore <team> (on any approved machine) open the backup and unlock <team>
36
+ recovery setup create this account's recovery key and back up every active
37
+ team this machine's store reports
38
+ recovery restore [team] (on any approved machine) open the backup; name a team to
39
+ unlock that one, or none to unlock what the vault holds
40
40
  whoami which of this account's machines this one is —
41
41
  its name, its organization, and the prefix the
42
42
  console shows beside it
43
- version the version of this CLI (also --version, -v)
43
+ version the version of this CLI, and the directory it is
44
+ running out of (also --version, -v)
44
45
  the OSS scripts it drives are reported by
45
46
  \`connect --preflight\`, which is a separate answer
46
47
 
@@ -104,7 +105,21 @@ async function main(argv) {
104
105
  // one it must assume someone will paste. Written without naming a
105
106
  // version: an example that has to be bumped is a second place to bump,
106
107
  // which is the reason this command reads the manifest at all.
107
- process.stdout.write(`agmsg-cloud/${packageVersion()}\n`);
108
+ //
109
+ // The directory follows on its own line because the version alone does
110
+ // not answer the question people ask this command. `npm i -g agmsg-cloud`
111
+ // can succeed while a copy another package manager put earlier on PATH is
112
+ // what runs, and nothing about that is an error: both installs are valid
113
+ // and the shell picks one. That happened here, and the two defects being
114
+ // chased were already fixed in the copy that was NOT running. Choosing
115
+ // for the user would mean deciding their PATH; saying which one answered
116
+ // costs one line and leaves the choice where it was.
117
+ //
118
+ // A path, not a command: this line must not read as something to paste,
119
+ // which is why it is not shaped like an invocation.
120
+ const install = packageInstall();
121
+ process.stdout.write(`agmsg-cloud/${install.version}\n`);
122
+ process.stdout.write(`running from ${install.directory}\n`);
108
123
  return;
109
124
  }
110
125
  // Beside `version` for the same reason it sits there: both answer a
@@ -229,36 +244,57 @@ async function main(argv) {
229
244
  // the one thing that opens the backup, shown once and stored nowhere.
230
245
  // A name that hides that is a name someone runs without reading.
231
246
  case 'recovery': {
232
- const [sub, team] = rest;
247
+ const [sub, team, ...extra] = rest;
233
248
  if (sub !== 'setup' && sub !== 'restore') {
234
249
  // The two take different arguments, so one line covering both said the
235
250
  // wrong thing about each: `setup` does not need a team and `restore`
236
251
  // does. A usage message is read as the contract (raised in review).
237
- throw new Error('usage: agmsg-cloud recovery setup [team]\n' +
238
- ' agmsg-cloud recovery restore <team>');
252
+ throw new Error('usage: agmsg-cloud recovery setup\n' +
253
+ ' agmsg-cloud recovery restore [team]');
239
254
  }
240
- // `setup` takes no team in its ordinary form: the vault is the ACCOUNT's,
241
- // so setting it up covers every connected team, and naming one made it a
242
- // thing to repeat each time a team was added. A team may still be given,
243
- // to add that one on its own. `restore` still requires one — it unlocks a
244
- // specific team on this machine.
255
+ // `setup` takes no team: the vault is the ACCOUNT's, so setting it up
256
+ // covers every connected team this machine reports. The optional form
257
+ // was kept only while agmsg#650 could silently return a short enumeration;
258
+ // that producer now distinguishes unreadable teams, so retaining the
259
+ // argument would keep teaching an expired per-team recovery model (#182).
260
+ //
261
+ // `restore`'s arity is NOT this change's subject. #342a made its team
262
+ // optional -- the disaster case supplies none -- which is why the line
263
+ // above reads `[team]`. This branch predates #342a and its side of this
264
+ // hunk still said `<team>`; taking the hunk wholesale would have
265
+ // re-imposed the requirement and reverted #342a, with no conflict
266
+ // marker to show it.
267
+ //
268
+ // `restore` NO LONGER REQUIRES ONE (#342), and the reason is the disaster
269
+ // it is for. It resolved the name through this machine's LOCAL binding
270
+ // before touching the vault, so the machine the command exists to rescue
271
+ // — the one that lost everything — never got as far as opening it:
272
+ // `remote.sh status <team>` answered `team '<x>' has never been
273
+ // connected` and it stopped there. Measured against a stub endpoint that
274
+ // logs every request: the server received nothing. Knowing the name did
275
+ // not help; the name was never the missing thing.
245
276
  //
246
- // `restore`, written out. It is the only value `sub` can hold here — the
247
- // branch tests for it — so the interpolation was indirection that printed
248
- // a constant, and it carried a `printed-commands:` exemption to say so.
277
+ // The restore argument is kept rather than removed. Naming a team is still
278
+ // what someone does when this machine HAS the team and wants that one
279
+ // unlocked, and that path works today -- deleting it to fix the broken one
280
+ // would trade a defect for a regression (#342a).
249
281
  //
250
- // That exemption was the last one in this file, and #201 is about what it
251
- // rested on. Measured: nothing. The guard never reached this line, because
252
- // a command introduced by `usage: ` opened no command extent, so the
253
- // comment sat over a slot no rule was applied to. Both the comment and the
254
- // slot are gone, and the line is now inside the guard's scope.
255
- if (sub === 'restore' && !team) {
256
- throw new Error('usage: agmsg-cloud recovery restore <team>');
282
+ // The exemption that used to sit here is gone with #201: measured, the
283
+ // guard never reached this line, because a command introduced by `usage: `
284
+ // opened no command extent, so the comment sat over a slot no rule was
285
+ // applied to. The line is inside the guard's scope now.
286
+ if (sub === 'setup' && (team !== undefined || extra.length > 0)) {
287
+ throw new Error('usage: agmsg-cloud recovery setup');
257
288
  }
289
+ // NO `restore` guard. This branch had one, re-asserting that restore
290
+ // requires a team; #342a removed that requirement and the disaster case
291
+ // supplies no team. Its call below therefore has to keep main's optional
292
+ // form -- the two travel together, and taking this hunk wholesale would
293
+ // have restored both halves silently.
258
294
  const config = loadConfig();
259
295
  return sub === 'setup'
260
- ? cmdVaultPut(config, team === undefined ? {} : { team })
261
- : cmdVaultRestore(config, { team: team });
296
+ ? cmdVaultPut(config)
297
+ : cmdVaultRestore(config, team === undefined ? {} : { team });
262
298
  }
263
299
  default:
264
300
  throw new Error(`unknown command: ${cmd}\n\n${USAGE}`);
package/dist/src/oss.js CHANGED
@@ -29,7 +29,7 @@ function run(cmd, args, input) {
29
29
  });
30
30
  }
31
31
  /** A finished run, whatever its exit code. For callers where non-zero is an ANSWER. */
32
- function runAllowingFailure(cmd, args) {
32
+ export function runAllowingFailure(cmd, args) {
33
33
  return new Promise((resolve, reject) => {
34
34
  const child = spawnOssPiped(cmd, args);
35
35
  const out = [];
@@ -211,10 +211,39 @@ export async function connectedTeams(scriptsDir) {
211
211
  teamId: status.remote_team_id,
212
212
  serverInstanceId: status.server_instance_id,
213
213
  state: status.state,
214
+ // NOT refused when absent, unlike the ids above. A binding without the
215
+ // ids cannot be filed into a vault entry, which is why those refuse; a
216
+ // binding whose address this version cannot read is still a team, and the
217
+ // only caller that wants the address can say "could not tell" for it. An
218
+ // empty string is that, and `teamsBoundTo` treats it as not matching any
219
+ // host rather than as matching the one being asked about.
220
+ endpoint: typeof status.endpoint === 'string' ? status.endpoint : '',
214
221
  });
215
222
  }
216
223
  return teams;
217
224
  }
225
+ export async function teamsBoundTo(scriptsDir, origin) {
226
+ const teams = await connectedTeams(scriptsDir);
227
+ const matched = [];
228
+ const unreadable = [];
229
+ for (const t of teams) {
230
+ if (t.endpoint === '') {
231
+ unreadable.push(t.team);
232
+ continue;
233
+ }
234
+ let bound;
235
+ try {
236
+ bound = new URL(t.endpoint).origin;
237
+ }
238
+ catch {
239
+ unreadable.push(t.team);
240
+ continue;
241
+ }
242
+ if (bound === origin)
243
+ matched.push(t.team);
244
+ }
245
+ return { matched: matched.sort(), unreadable: unreadable.sort() };
246
+ }
218
247
  export async function remoteTeamId(scriptsDir, team) {
219
248
  const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
220
249
  const text = out.toString().trim();