~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
142 lines · 9.8 KB Raw History
#!/usr/bin/env node
// Run an atlibs wasm module server side (javascript/src/server, no emdash, no npm deps):
//   node javascript/src/server/cli.mjs run <handle>/<name>[@pin] <entry> ['<json>']   (or <did>/<name>, an at:// URI)
//   node javascript/src/server/cli.mjs run <module.wasm> <module.json> <entry> ['<json>']
// The input JSON: an argument, or - (read from stdin) or @file, so secrets (a stdin input) stay out of ps.
// Exits 1 when the run failed (its error is in the printed result).
// Who it acts as, explicit only (no flag: signed out, DID-service reads only; the environment is never read for it):
//      --app-password <handle> (--password-file <path|-> | --password-env <NAME>) [--pds https://bsky.social]
//      (an app password: the simple / testing case; a user's OAuth session is a server's, javascript/src/oauth-server)
// env: ATLIBS_SERVER_STATE (default ~/.atlibs-server:
//      store.db and secret.key, which seals the attestation keys and the saved session; keep it
//      private and reuse it so bind_key and later signatures share a key, and so runs refresh
//      the saved session instead of calling the rate-limited createSession).
//      ATLIBS_BUDGET (JSON merged over runModule's BUDGET, for example
//      '{"wallMs":300000,"events":5000}'; "unlimited" for calls, callsPerMinute, events, wallMs; ioBytes up to 16 MiB);
//      Ctrl-C ends a run and still prints it.
//      ATLIBS_TRANSPORTS (comma-separated module paths, each exporting `transport`: the host pseudo-calls
//      this command line serves, e.g. atlibs-iroh's js/index.js; unset: none, so iroh.connect is refused;
//      and/or `fetchers({ env, stateDir })`: { "iroh:": fn }, runModule's fetchers for http.fetch to those URLs).
//      ATLIBS_IROH_KEY (the iroh transport's ssh private key; default <state>/iroh_ed25519).
//      ATLIBS_ALLOW_PRIVATE=1 lets DID lookups reach http and private addresses (local dev only).
//      ATLIBS_HTTP (JSON, or @file of it: the origins com.fedfork.exec.http.fetch may reach, each with the
//      headers this host adds, e.g. '{"https://api.anthropic.com":{"headers":{"x-api-key":"..."}}}'; runModule's http).
//      ATLIBS_HELD (JSON, or @file of it): { name: value }, the held values the run starts with (runModule's held).
// A module.json (or module record) with "didkey": { "services": [https origins] } gets a P-256
// did:key, sealed in the state dir per module identity: its at:// URI, or the wasm's absolute path
// (same did, same ingress host name, every run; never the name a module gives itself), and the
// com.fedfork.exec.didkey.* pseudo-calls (javascript/src/didkey).
// Prints runModule's { entry, result, error?, events, calls } as JSON on stdout.
import { readFileSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";

import { attestHost } from "../attest/index.js";
import { didkeyCall, ingressHost } from "../didkey/index.js";
import { loadModule, localModule, runModule } from "../run-module/index.js";
import { contextOptions, createNodeContext, didkeyOf, fileFetch, hostCall, modulePolicy, openSession, secp256k1, sessionCall, sessionFlags } from "./index.js";

const USAGE = `usage: cli.mjs run <handle>/<name>[@pin] | <did>/<name> | <at://...> <entry> ['<json>' | - | @file] [--app-password <handle> (--password-file <path|-> | --password-env <NAME>) [--pds <url>]]
       cli.mjs run <module.wasm> <module.json> <entry> ['<json>' | - | @file] [...]`;

// read: the input source (- is stdin, @path a file); swappable for tests.
export function parse(argv, read = (source) => readFileSync(source === "-" ? 0 : source.slice(1), "utf8")) {
	const [command, ...rest] = argv;
	if (command !== "run") throw new Error(USAGE);
	// published: at://, or <handle|did>/<name> (Rust registry::is_remote); else <module.wasm> <module.json>
	const head = /^([^/@\s]+)\/[^/@\s]+(@[A-Za-z0-9]+)?$/.exec(rest[0] ?? "")?.[1];
	const remote = rest[0]?.startsWith("at://") || (head !== undefined && !rest[0].endsWith(".wasm") && (head.startsWith("did:") || (head.includes(".") && !head.startsWith("."))));
	const [entry, inputJson = "{}", ...extra] = rest.slice(remote ? 1 : 2);
	if (!entry || extra.length || (!remote && !rest[1])) throw new Error(USAGE);
	const text = inputJson === "-" || inputJson.startsWith("@") ? read(inputJson) : inputJson;
	let input;
	try {
		input = JSON.parse(text);
	} catch {
		throw new Error("input is not JSON"); // never echoed: it may hold a secret
	}
	if (typeof input !== "object" || input === null || Array.isArray(input)) throw new Error("input must be a JSON object");
	return { ...(remote ? { uri: rest[0] } : { wasm: rest[0], manifest: rest[1] }), entry, input };
}

// transports: the host pseudo-calls this CLI was given, by NSID -> ({ env, stateDir }) => handler (hostCall's
// calls; a transport module's `transport`, ATLIBS_TRANSPORTS on the command line). Their NSIDs are what this host provides, checked when hooks load.
// A module that lists no calls may call anything, so it gets no transport (the host's keys stay out of it).
// One not given is refused: "<nsid>: not available on this host (built without <name>)".
// fetchers: those modules' fetchers exports, each ({ env, stateDir }) => { "<scheme>:": fn } (runModule's fetchers).
export async function main(argv, env = process.env, signal, transports = {}, fetchers = []) {
	const { config, rest } = sessionFlags(argv, { env });
	const { uri, wasm, manifest, entry, input } = parse(rest);
	const stateDir = env.ATLIBS_SERVER_STATE || join(homedir(), ".atlibs-server");
	const ctx = createNodeContext(contextOptions(stateDir));
	ctx.allowPrivate = env.ATLIBS_ALLOW_PRIVATE === "1";
	try {
		// key: its rkey, or the wasm's file stem (rust/cli pair_lib); dir: where a local module.json's session record is looked up
		const module = uri
			? await loadModule(ctx, uri).then((mod) => ({ ...mod, key: mod.id.split("/").pop() }))
			: { ...(await localModule(pathToFileURL(wasm), pathToFileURL(manifest), fileFetch)), id: await realpath(wasm), key: basename(wasm, ".wasm"), dir: dirname(manifest) };
		const session = await openSession(ctx, config);
		// JSON has no Infinity: "unlimited" is how ATLIBS_BUDGET says it (budgetValue keeps it only for UNLIMITED keys).
		const budget = env.ATLIBS_BUDGET ? JSON.parse(env.ATLIBS_BUDGET, (key, value) => (value === "unlimited" ? Infinity : value)) : undefined;
		const envJson = (name) => env[name] && JSON.parse(env[name].startsWith("@") ? readFileSync(env[name].slice(1), "utf8") : env[name]);
		const http = envJson("ATLIBS_HTTP");
		const held = envJson("ATLIBS_HELD");
		const schemes = Object.assign({}, ...(await Promise.all(fetchers.map((make) => make({ env, stateDir })))));
		const refuse = (nsid) => async () => Promise.reject(new Error(`${nsid} needs a module record that lists its calls`));
		// The call one module runs on: the run's, and each hook default's own, never another's.
		// A module that lists no calls may call anything, so it gets no signing key and no transport.
		const callOf = async (module) => {
			const attest = session && module.calls?.length ? attestHost(ctx, secp256k1, session.did) : undefined;
			const calls = Object.fromEntries(Object.entries(transports).map(([nsid, make]) => [nsid, module.calls?.length ? make({ env, stateDir }) : refuse(nsid)]));
			let call = hostCall({ ctx, xrpc: session?.xrpc, attest, pds: session?.pds, calls, fetchers: module.calls?.length ? schemes : {} });
			// signed in: only what the module's own session record's scope allows
			const key = module.key ?? basename(module.id ?? "", ".wasm");
			if (session) call = sessionCall(await modulePolicy(ctx, key, module, module.dir), key, call);
			if (module.didkey && module.calls?.length) {
				const key = await didkeyOf(ctx, module.id);
				for (const service of module.didkey.services ?? []) console.error(`${key.did}: https://${ingressHost(key.did, service)}/`);
				call = didkeyCall(key, call, { services: module.didkey.services, lease: (name) => ctx.lease(name) });
			}
			return call;
		};
		const call = await callOf(module);
		// A hook default of a local build: examples/rust/<key>/module.json and its wasm beside this one's.
		const local = async (key) => {
			const file = join(dirname(wasm), `${key.replaceAll("-", "_")}.wasm`);
			const dir = join(dirname(manifest), "..", key);
			return { ...(await localModule(pathToFileURL(file), pathToFileURL(join(dir, "module.json")), fileFetch)), id: await realpath(file), key, dir };
		};
		return await runModule(ctx, module, entry, { ...(session && { did: session.did }), ...input }, call, { budget, signal, callFor: callOf, provides: Object.keys(transports), http, fetchers: schemes, held, ...(!uri && { load: local }) });
	} finally {
		ctx.close();
	}
}

if (import.meta.main) {
	const stop = new AbortController();
	process.once("SIGINT", () => stop.abort());
	// ATLIBS_TRANSPORTS: the transport modules this command line loads (paths from the cwd); no core path imports one.
	const transports = async () => {
		const all = {};
		const fetchers = [];
		for (const path of (process.env.ATLIBS_TRANSPORTS ?? "").split(",").map((p) => p.trim()).filter(Boolean)) {
			const plugin = await import(pathToFileURL(resolve(path)).href);
			if (!plugin.transport && typeof plugin.fetchers !== "function") throw new Error(`ATLIBS_TRANSPORTS: ${path} exports no transport`);
			Object.assign(all, plugin.transport);
			if (typeof plugin.fetchers === "function") fetchers.push(plugin.fetchers);
		}
		return [all, fetchers];
	};
	transports().then(([given, fetchers]) => main(process.argv.slice(2), process.env, stop.signal, given, fetchers)).then(
		(ran) => {
			console.log(JSON.stringify(ran, null, "\t"));
			if (ran.error !== undefined) process.exitCode = 1;
		},
		(error) => {
			console.error(error.message);
			process.exitCode = 1;
		},
	);
}