@tomato414941/foundation 0.25.0 → 0.27.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 +47 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomato414941/foundation",
3
- "version": "0.25.0",
3
+ "version": "0.27.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');
@@ -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 }) },
@@ -296,7 +337,10 @@ async function main() {
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
339
  if (me.principal?.acts_for?.length) { console.log('Already approved on ' + url.origin + '.'); return; }
299
- const answer = await send('/v1/requests', { authorization_details: [{ type: 'relation', relation: 'agent' }] });
340
+ // The one call it may ask for: a line from itself, agent, onto whoever answers (me, to them).
341
+ // One someone already owns asks them; one nobody knows asks whoever opens the page it shows.
342
+ const self = me.principal?.id ?? (await send('/v1/session', undefined, { method: 'GET' })).current?.principal_id, owner = me.principal?.owners?.[0];
343
+ const answer = await send('/v1/requests', { ...(owner ? { to: owner } : {}), operations: [{ method: 'POST', path: '/v1/principals/' + encodeURIComponent(self) + '/relations', body: { relation: 'agent', object_type: 'principal', object_id: 'me' } }] });
300
344
  console.log(JSON.stringify(answer, null, 2));
301
345
  return;
302
346
  }