@tomato414941/foundation 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/runtime.mjs +52 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomato414941/foundation",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Foundation CLI: give this machine a WebAuthn credential, and hand what is kept to a command without it passing through the agent.",
5
5
  "type": "module",
6
6
  "engines": {
package/runtime.mjs CHANGED
@@ -5,7 +5,7 @@ import { rmSync, readdirSync, constants } from 'node:fs';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { homedir, hostname, tmpdir } from 'node:os';
8
- import { dirname, join } from 'node:path';
8
+ import { dirname, join, resolve, sep } from 'node:path';
9
9
  import { createRequire } from 'node:module';
10
10
  import { validEnvName } from './env-name.mjs';
11
11
  import { createCredential, answer } from './webauthn.mjs';
@@ -106,6 +106,39 @@ async function outputBytes(path) {
106
106
  } finally { await handle?.close(); }
107
107
  }
108
108
 
109
+ // Taking a principal's data out: what the server's export holds, and each stored object's bytes, fetched straight from
110
+ // where they are kept by a time-limited URL so that they never pass through the server. The directory is new, private,
111
+ // and written into only.
112
+ async function exportTo({ directory, principal }, send) {
113
+ try { await mkdir(directory, { mode: 0o700 }); }
114
+ catch (error) { throw error.code === 'EEXIST' ? new Error('Choose a directory that does not exist yet: ' + directory) : error; }
115
+ const base = '/v1/principals/' + encodeURIComponent(principal);
116
+ await writeFile(join(directory, 'export.json'), JSON.stringify(await send(base + '/export', undefined, { method: 'GET' }), null, 2) + '\n', { mode: 0o600, flag: 'wx' });
117
+ const { resources = [] } = await send(base + '/resources?kind=object', undefined, { method: 'GET', accept: data => data.error?.code === 'space_unavailable' });
118
+ const kept = [];
119
+ for (const object of resources) {
120
+ // The server answers a burst of links with rate_limit; its window is a minute.
121
+ let link;
122
+ for (let attempt = 0; !link; attempt++) {
123
+ const answered = await send('/v1/resources/' + object.id + '/link', {}, { accept: data => data.error?.code === 'rate_limit' && attempt < 5 });
124
+ if (answered.error) await new Promise(done => setTimeout(done, Number(process.env.FOUNDATION_EXPORT_WAIT_MS ?? 15_000)));
125
+ else link = answered;
126
+ }
127
+ const response = await fetch(link.url, { redirect: 'error', signal: AbortSignal.timeout(300_000) });
128
+ if (!response.ok) throw new Error('Could not download ' + object.name + ' (' + response.status + ').');
129
+ const bytes = Buffer.from(await response.arrayBuffer());
130
+ // A name becomes a path under objects/ when every part of it is a plain name; otherwise, or where that path is
131
+ // taken, the object is saved under its id.
132
+ const parts = object.name.split('/');
133
+ let file = parts.every(part => part && part !== '.' && part !== '..' && !part.includes(sep) && !/[\u0000-\u001f]/.test(part)) ? join(directory, 'objects', ...parts) : null;
134
+ try { if (!file) throw new Error('unsafe'); await mkdir(dirname(file), { recursive: true, mode: 0o700 }); await writeFile(file, bytes, { mode: 0o600, flag: 'wx' }); }
135
+ catch { file = join(directory, 'objects', object.id); await mkdir(dirname(file), { recursive: true, mode: 0o700 }); await writeFile(file, bytes, { mode: 0o600, flag: 'wx' }); }
136
+ kept.push({ id: object.id, name: object.name, type: object.type ?? null, size: bytes.length, file: file.slice(directory.length + 1) });
137
+ }
138
+ await writeFile(join(directory, 'objects.json'), JSON.stringify(kept, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
139
+ console.log(JSON.stringify({ directory, objects: kept.length }));
140
+ }
141
+
109
142
  // --help describes the CLI. The server publishes its API contract at /openapi.json.
110
143
  const HELP = `Usage: foundation <command> [options]
111
144
 
@@ -122,6 +155,9 @@ Commands:
122
155
  exec --output '<json>' -- <command> Also save a file the command writes.
123
156
  keep <name> --from <file> Save a file as a secret, sealed here for whoever may open it.
124
157
  read <name> Print a secret this machine was handed an envelope for.
158
+ export <directory> [--principal <id>]
159
+ Save a principal's data (this machine's by default) into a new directory:
160
+ export.json as the server gives it, and every stored object under objects/.
125
161
  version Print the version.
126
162
 
127
163
  API specification:
@@ -202,8 +238,12 @@ async function main() {
202
238
  call = { name: args[0] };
203
239
  } else if (action === 'token') {
204
240
  if (args.length) throw new Error('Usage: token');
241
+ } else if (action === 'export') {
242
+ const parsed = parseArgs({ args, options: { principal: { type: 'string' } }, strict: true, allowPositionals: true });
243
+ if (parsed.positionals.length !== 1 || (parsed.values.principal !== undefined && !/^[0-9a-f-]{36}$/.test(parsed.values.principal))) throw new Error('Usage: export <directory> [--principal <id>]');
244
+ call = { directory: resolve(parsed.positionals[0]), principal: parsed.values.principal ?? 'me' };
205
245
  } else if (!(action === 'exec' && (names.length || output) && command.length)) {
206
- throw new Error('Usage: init [<url>] [--name <name>] | join | token | exec [<ENV>=<name> ... | --inputs <json>] [--output <json>] -- <command> [args...] | keep <name> --from <file> | read <name> | api <method> </path> [--json <body>] [--from <file>]');
246
+ throw new Error('Usage: init [<url>] [--name <name>] | join | token | exec [<ENV>=<name> ... | --inputs <json>] [--output <json>] -- <command> [args...] | keep <name> --from <file> | read <name> | export <directory> [--principal <id>] | api <method> </path> [--json <body>] [--from <file>]');
207
247
  }
208
248
  const url = serverUrl(serverGiven ?? configured);
209
249
  const keyPath = process.env.FOUNDATION_RUNTIME_KEY_FILE || join(homedir(), '.local', 'state', 'foundation', createHash('sha256').update(url.origin).digest('hex').slice(0, 24) + (agentName ? '-' + agentName.toLowerCase().replace(/[^a-z0-9]+/g, '-') : '') + '.key');
@@ -230,7 +270,7 @@ async function main() {
230
270
  // A lent machine's key stays as it is; it ends with the machine.
231
271
  async function upgrade(label) {
232
272
  const me = await send('/v1/principals/me', undefined, { method: 'GET' });
233
- if (me.key?.environment) return;
273
+ if ((await send('/v1/session', undefined, { method: 'GET' })).current?.via.environment) return;
234
274
  const { options } = await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn' });
235
275
  const made = createCredential(options, url.origin);
236
276
  await send('/v1/principals/' + encodeURIComponent(me.principal.id) + '/credentials', { kind: 'webauthn', name: label, credential: made.response }, { method: 'PUT' });
@@ -254,6 +294,7 @@ async function main() {
254
294
  catch (error) { if (action !== 'init') throw error; key = null; token = null; }
255
295
  }
256
296
  if (action === 'token') { console.log(token); return; }
297
+ if (action === 'export') { await exportTo(call, send); return; }
257
298
  // One request, as this machine, and the answer printed as it came. Nothing here knows the endpoints.
258
299
  if (action === 'api') {
259
300
  const response = await fetch(url.origin + call.target, { method: call.method, headers: { ...(token ? { authorization: 'Bearer ' + token } : {}), ...(call.body === undefined ? {} : { 'content-type': call.type }) },
@@ -288,14 +329,14 @@ async function main() {
288
329
  } else if (key?.token) { await upgrade(wanted); token = await prove(); await publishKey(); }
289
330
  if (serverGiven !== undefined) await saveUrl(url.origin);
290
331
  if (!me) me = await send('/v1/principals/me', undefined, { method: 'GET' });
291
- console.log(JSON.stringify({ principal: me.principal, acts_for: me.acts_for ?? [] }, null, 2));
332
+ console.log(JSON.stringify({ principal: { id: me.principal.id, name: me.principal.name, created_at: me.principal.created_at }, acts_for: me.principal.acts_for ?? [] }, null, 2));
292
333
  console.log('\nKey file: ' + keyPath + '\nServer: ' + url.origin + (serverGiven !== undefined ? ' (saved to ' + configPath() + ')' : '') + '\nTo act for someone: foundation join\nEverything else is HTTP: Authorization: Bearer $(foundation token)');
293
334
  return;
294
335
  }
295
336
  // Asking a person to make this machine their agent. One they already approved has nothing to ask.
296
337
  if (action === 'join') {
297
338
  const me = await send('/v1/principals/me', undefined, { method: 'GET', accept: data => data.error?.code === 'not_approved' });
298
- if (me.acts_for?.length) { console.log('Already approved on ' + url.origin + '.'); return; }
339
+ if (me.principal?.acts_for?.length) { console.log('Already approved on ' + url.origin + '.'); return; }
299
340
  const answer = await send('/v1/requests', { authorization_details: [{ type: 'relation', relation: 'agent' }] });
300
341
  console.log(JSON.stringify(answer, null, 2));
301
342
  return;
@@ -323,11 +364,14 @@ async function main() {
323
364
  }
324
365
  // Nothing runs before someone has accepted this key: a key that acts for nobody reaches only its own empty resources,
325
366
  // and the person it asked has yet to answer.
326
- const current = await send('/v1/principals/me', undefined, { method: 'GET' });
367
+ const current = (await send('/v1/principals/me', undefined, { method: 'GET' })).principal;
327
368
  // A key given to a lent machine acts as its principal's own self. Any other key acts for someone once approved;
328
369
  // until then, whether waiting or refused, it has nothing to run with.
329
- const own = Boolean(current.key?.environment);
330
- if (!own && !current.acts_for?.length) throw new Error('Foundation request failed (401, not_approved). This key acts for nobody yet' + (current.requests?.[0] ? '; it is waiting for approval at ' + current.requests[0].verification_uri : '') + '.');
370
+ const own = Boolean((await send('/v1/session', undefined, { method: 'GET' })).current?.via.environment);
371
+ if (!own && !current.acts_for?.length) {
372
+ const waiting = (await send('/v1/requests?status=pending', undefined, { method: 'GET', accept: () => true })).requests?.[0];
373
+ throw new Error('Foundation request failed (401, not_approved). This key acts for nobody yet' + (waiting ? '; it is waiting for approval at ' + waiting.verification_uri : '') + '.');
374
+ }
331
375
  // Whose resources a run reaches: the one this key acts for, the one named when it acts for several, or its own.
332
376
  const acting = current.acts_for ?? [];
333
377
  const owner = process.env.FOUNDATION_AS || (acting.length === 1 ? acting[0] : null);