@tomato414941/foundation 0.13.0 → 0.15.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 +16 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomato414941/foundation",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Foundation CLI: make a key, 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
@@ -105,7 +105,7 @@ Commands:
105
105
  Send one request to the Foundation API with the key attached.
106
106
  exec <ENV>=<name> [...] -- <command> [args...]
107
107
  Run a command with saved values in its environment.
108
- exec --inputs '<json>' -- <command> The same, with files, structured inputs, or a credential for a service by id.
108
+ exec --inputs '<json>' -- <command> The same, with files, structured inputs, or a connection for a service by id.
109
109
  exec --output '<json>' -- <command> Also save a file the command writes.
110
110
  version Print the version.
111
111
 
@@ -132,15 +132,22 @@ async function main() {
132
132
  const parsed = parseArgs({ args: args.slice(0, separatorAt), options: { inputs: { type: 'string' }, output: { type: 'string' } }, strict: true, allowPositionals: true });
133
133
  if (parsed.values.inputs !== undefined && parsed.positionals.length) throw new Error('--inputs and ENV=name are alternatives.');
134
134
  if (parsed.values.inputs !== undefined) {
135
- try { names = JSON.parse(parsed.values.inputs); } catch { throw new Error('--inputs must be a JSON array of {name, as, filename?}.'); }
135
+ try { names = JSON.parse(parsed.values.inputs); } catch { throw new Error('--inputs must be a JSON array of {name, as, filename?} or {id, output?, as?, filename?}.'); }
136
136
  } else names = parsed.positionals.map(value => {
137
137
  const at = value.indexOf('=');
138
138
  if (at < 1) throw new Error('Specify the environment variable explicitly: ENV=name');
139
139
  return { name: value.slice(at + 1), as: value.slice(0, at) };
140
140
  });
141
- // A credential for a service names its own variables, so an input may leave `as` out; a secret must say where it goes.
142
- if (!Array.isArray(names) || names.length > 16 || names.some(item => !item || typeof item.name !== 'string' || !item.name || (item.as !== undefined && !validEnvName(item.as)))) throw new Error('Each input needs a name and, when given, a non-reserved environment variable in as.');
143
- const chosen = names.map(item => item.as).filter(value => value !== undefined);
141
+ // A connection for a service names its own variables, so an input may leave `as` out; a secret must say where it goes.
142
+ if (!Array.isArray(names) || names.length > 16 || names.some(item => {
143
+ if (!item || typeof item !== 'object' || Array.isArray(item) || Object.keys(item).some(key => !['name', 'id', 'output', 'as', 'filename'].includes(key))) return true;
144
+ if (Object.hasOwn(item, 'name') === Object.hasOwn(item, 'id')) return true;
145
+ if (Object.hasOwn(item, 'name') && (typeof item.name !== 'string' || !item.name || Object.hasOwn(item, 'output'))) return true;
146
+ if (Object.hasOwn(item, 'id') && (typeof item.id !== 'string' || !/^[0-9a-f-]{36}$/.test(item.id))) return true;
147
+ if (Object.hasOwn(item, 'output') && (typeof item.output !== 'string' || !item.output || item.output.length > 200)) return true;
148
+ return item.as !== undefined && item.as !== null && !validEnvName(item.as);
149
+ })) throw new Error('Each input needs exactly one of name or id. Use output with a connection id; as must be a non-reserved environment variable.');
150
+ const chosen = names.map(item => item.as).filter(value => typeof value === 'string');
144
151
  if (new Set(chosen).size !== chosen.length) throw new Error('Each input needs a different environment variable.');
145
152
  if (parsed.values.output !== undefined) {
146
153
  try { output = JSON.parse(parsed.values.output); } catch { throw new Error('--output must be a JSON object {name, as, filename}.'); }
@@ -210,7 +217,7 @@ async function main() {
210
217
  await writeKey(keyPath, made.token, !process.env.FOUNDATION_RUNTIME_KEY_FILE);
211
218
  token = made.token; me = null;
212
219
  }
213
- const answer = me?.acts_for?.length ? null : await send('/v1/requests', { kind: 'actor', input: { name: wanted } });
220
+ const answer = me?.acts_for?.length ? null : await send('/v1/requests', { authorization_details: [{ type: 'relation', relation: 'actor' }] });
214
221
  if (connectTo !== undefined) await saveUrl(url.origin);
215
222
  console.log(answer === null ? 'Already approved on ' + url.origin + '.' : JSON.stringify(answer, null, 2));
216
223
  console.log('\nKey file: ' + keyPath + '\nServer: ' + url.origin + (connectTo !== undefined ? ' (saved to ' + configPath() + ')' : '') + '\nEverything else is HTTP: Authorization: Bearer <the contents of that file>');
@@ -228,13 +235,6 @@ async function main() {
228
235
  const holder = process.env.FOUNDATION_AS || (acting.length === 1 ? acting[0] : null);
229
236
  if (!holder && acting.length > 1) throw new Error('This key acts for several principals. Set FOUNDATION_AS=<principal id> to say which one this run is for.');
230
237
  const forHolder = target => holder ? target + (target.includes('?') ? '&' : '?') + 'as=' + encodeURIComponent(holder) : target;
231
- // A client can be updated before its server. Discover the storage kind before the command creates an output.
232
- let outputKind = 'secret';
233
- if (output) {
234
- const probe = await send(forHolder('/v1/resources?kind=secret&prefix=' + encodeURIComponent(output.name)), undefined,
235
- { method: 'GET', accept: data => data.error?.code === 'invalid_kind' });
236
- if (probe.error?.code === 'invalid_kind') outputKind = 'credential';
237
- }
238
238
  let injection;
239
239
  if (names.length) ({ injection } = await send(forHolder('/v1/injections'), { names }));
240
240
  else injection = { environment: {}, files: [] };
@@ -254,7 +254,7 @@ async function main() {
254
254
  fileNames.add(file.filename); variables.add(file.env);
255
255
  }
256
256
  if (output && variables.has(output.as)) throw new Error('Output needs a different environment variable from every input.');
257
- environment.FOUNDATION_NAMES = JSON.stringify(names.map(item => item.name));
257
+ environment.FOUNDATION_NAMES = JSON.stringify(names.map(item => item.name ?? item.id));
258
258
  // Injected inputs are always cleaned up. A completed output survives only an unconfirmed upload.
259
259
  let secretDir, outputDir, outputPath, child, interrupted = false, retainOutput = false;
260
260
  const cleanup = () => {
@@ -266,7 +266,7 @@ async function main() {
266
266
  }
267
267
  }
268
268
  };
269
- const recovery = () => 'Foundation could not confirm the output was saved. The private output file is retained for recovery: ' + outputPath + '\nRetry with foundation api PUT "/v1/resources?kind=' + outputKind + '&name=<URL-encoded-name>" --from <file>, then remove that recovery file.';
269
+ const recovery = () => 'Foundation could not confirm the output was saved. The private output file is retained for recovery: ' + outputPath + '\nRetry with foundation api PUT "/v1/resources?kind=secret&name=<URL-encoded-name>" --from <file>, then remove that recovery file.';
270
270
  process.once('exit', cleanup);
271
271
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, () => {
272
272
  interrupted = true;
@@ -305,7 +305,7 @@ async function main() {
305
305
  retainOutput = true;
306
306
  // The command wrote it; the agent never saw it, and keeps it that way: the line drawn for the one who kept it is declined.
307
307
  let saved;
308
- try { saved = await send(forHolder('/v1/resources?kind=' + outputKind + '&name=' + encodeURIComponent(output.name)), bytes, { method: 'PUT', type: 'application/octet-stream' }); }
308
+ try { saved = await send(forHolder('/v1/resources?kind=secret&name=' + encodeURIComponent(output.name)), bytes, { method: 'PUT', type: 'application/octet-stream' }); }
309
309
  catch { throw new Error(recovery()); }
310
310
  try { await send('/v1/relations', { relation: 'editor', object_type: 'resource', object_id: saved.resource.id }, { method: 'DELETE' }); } catch {}
311
311
  retainOutput = false;