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

johnandersen777.bsky.social/atlibs

0
0
main
120 lines · 8.0 KB Raw History
// An existing Node server (Hono) adopting atlibs:
//   GET  /oauth/<session>/client-metadata.json   clientMetadataHandler: each integration's OAuth client,
//                                                its client_id on ATLIBS_ORIGIN (unset: loopback requests only)
//   GET  /.well-known/did.json, atproto-did,     appRepoHandler, with ATLIBS_ORIGIN and a session record declaring `app`:
//        /xrpc/com.atproto.repo.getRecord, ...   the app's own identity did:web:<origin host> and read-only repo
//   GET  /oauth/login?handle=, /oauth/callback,  sign-in (javascript/src/oauth-server, optional: `npm install` there):
//   POST /oauth/logout                           the server's own OAuth client on ATLIBS_ORIGIN (or the loopback one)
//   POST /run/<module>/<entry>                   a staged module run through the JS host (javascript/src/server),
//                                                as the signed-in user (atlibs_session cookie or bearer) with
//                                                their OAuth session; without one, as --app-password if given,
//                                                else signed out: content-type application/json
//                                                only (a cross-site form or no-cors fetch cannot send it without
//                                                a preflight, which this server never answers), no other site's
//                                                Origin, and a Host that is loopback or ATLIBS_ORIGIN's (DNS rebinding)
//
//   cd examples/node-hono && npm install
//   atlibs add bobvmbuilder.bsky.social/fedproxy
//   atlibs stage --target server --out staged
//   node server.mjs        # PORT (8788), ATLIBS_SERVER_STATE, ATLIBS_ORIGIN (https://public/)
//   node server.mjs --app-password <handle> --password-env <NAME>   # also an operator account for runs with no user
//                                                                   # (simple / testing; never implied by the environment)
//   It listens on 127.0.0.1 only: put a tunnel or reverse proxy in front for ATLIBS_ORIGIN.
//
// Hono comes from this example's package.json only; javascript/src stays dependency-free (its handlers
// are fetch-style Request -> Response, which Hono mounts as they are).
import { readFile, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";

import { Hono } from "hono";

import { appRepo, appRepoHandler, clientMetadataHandler } from "../../javascript/src/oauth/index.js";
import { localModule, runModule } from "../../javascript/src/run-module/index.js";
import { contextOptions, createNodeContext, didkeyOf, fileFetch, hostCall, openSession, sessionCall, sessionFlags } from "../../javascript/src/server/index.js";

const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]);

/**
 * Why a POST /run request is refused (the same checks as `atlibs serve`), or undefined.
 * @param {Request} request  its URL's host is the Host header the server was reached by
 * @param {string} [origin]  the public origin (ATLIBS_ORIGIN)
 */
export function runRefusal(request, origin, { json = true } = {}) {
	const url = new URL(request.url);
	if (!LOOPBACK.has(url.hostname) && url.host !== (origin && new URL(origin).host)) return [403, `Host ${url.host} is not this server`];
	const from = request.headers.get("origin");
	if (from !== null && from !== url.origin) return [403, `Origin ${from} may not run modules here`];
	if (json && request.headers.get("content-type")?.split(";")[0].trim().toLowerCase() !== "application/json") return [415, "content-type must be application/json"];
}

/**
 * The app for a staged directory (`atlibs stage --target server` stage.json).
 * @param {string} stagePath
 * @param {{ ctx: object, call: (nsid: string, request: object) => Promise<unknown>, did?: string, origin?: string, oauth?: object }} host
 *   call/did: runs with no signed-in user (an operator account, or signed out); oauth: javascript/src/oauth-server's
 *   oauthServer, whose signed-in users' runs use their own session
 */
export async function createApp(stagePath, { ctx, call, did, origin, oauth }) {
	const stage = JSON.parse(await readFile(stagePath, "utf8"));
	const dir = dirname(stagePath);
	const metadata = clientMetadataHandler(stage, { origin });
	const app = new Hono();
	// the app's identity: its key kept in the state dir under app:<origin> (losing it is a new identity in practice)
	const declared = Object.values(stage.sessions ?? {}).some((s) => s.record?.app);
	if (origin && !LOOPBACK.has(new URL(origin).hostname) && declared) {
		const site = new URL(origin).origin;
		const key = await didkeyOf(ctx, `app:${site}`);
		const repo = appRepoHandler(await appRepo(stage, { origin: site, publicKeyMultibase: key.did.slice("did:key:".length) }));
		app.use("*", async (c, next) => repo(c.req.raw) ?? next());
	}
	app.all("/oauth/*", async (c) => {
		const doc = c.req.method === "GET" && metadata(c.req.raw);
		if (doc) return doc;
		if (!oauth) return c.notFound();
		const docs = /\/oauth\/(client-metadata|jwks)\.json$/.test(c.req.path);
		const refused = !docs && runRefusal(c.req.raw, origin, { json: false });
		if (refused) return c.json({ error: "Forbidden", message: refused[1] }, refused[0]);
		return (await oauth.handle(c.req.raw)) ?? c.notFound();
	});
	app.post("/run/:module/:entry", async (c) => {
		const refused = runRefusal(c.req.raw, origin);
		if (refused) return c.json({ error: "Forbidden", message: refused[1] }, refused[0]);
		const staged = stage.modules[c.req.param("module")];
		if (!staged) return c.json({ error: "NotFound", message: `no module ${c.req.param("module")}` }, 404);
		const input = await c.req.json().catch(() => null);
		if (input === null || typeof input !== "object" || Array.isArray(input)) return c.json({ error: "InvalidRequest", message: "the body must be a JSON object" }, 400);
		// who this run acts as: the request's user with their own session, else the server's default
		let session;
		try {
			session = await oauth?.sessionOf(c.req.raw);
		} catch (error) {
			return c.json({ error: "AuthRequired", message: error.message }, error.status ?? 401);
		}
		const wasm = await realpath(join(dir, staged.wasm));
		// id: the wasm's path, as cli.mjs gives a local module (the same ledger owner in a shared state dir)
		const module = { ...(await localModule(pathToFileURL(wasm), pathToFileURL(join(dir, staged.spec)), fileFetch)), id: wasm };
		const runDid = session?.did ?? did;
		// signed in (a user, or the operator account): only what the module's own session allows (stage.json is the policy)
		const runCall = runDid ? sessionCall(stage, c.req.param("module"), session ? hostCall({ ctx, xrpc: session.xrpc, pds: session.pds }) : call) : call;
		return c.json(await runModule(ctx, module, c.req.param("entry"), { ...(runDid && { did: runDid }), ...input }, runCall, { provides: [] }));
	});
	return app;
}

if (import.meta.main) {
	const { serve } = await import("@hono/node-server");
	const ctx = createNodeContext(contextOptions(process.env.ATLIBS_SERVER_STATE || join(homedir(), ".atlibs-server")));
	const { config } = sessionFlags(process.argv.slice(2));
	const session = await openSession(ctx, config);
	const port = Number(process.env.PORT || 8788);
	const stagePath = process.env.ATLIBS_STAGE || "staged/stage.json";
	const scope = JSON.parse(await readFile(stagePath, "utf8")).scope ?? "atproto";
	const oauth = await import("../../javascript/src/oauth-server/index.js").then(
		({ oauthServer }) => oauthServer(ctx, { origin: process.env.ATLIBS_ORIGIN || `http://127.0.0.1:${port}`, scope }),
		(error) => console.error(`no sign-in (npm install in javascript/src/oauth-server): ${error.message}`),
	);
	const app = await createApp(stagePath, { ctx, call: hostCall({ ctx, xrpc: session?.xrpc, pds: session?.pds }), did: session?.did, origin: process.env.ATLIBS_ORIGIN, oauth });
	serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, () => console.error(`http://127.0.0.1:${port}/  (POST /run/<module>/<entry>; GET /oauth/<session>/client-metadata.json)`));
}