// An existing Node server (Hono) adopting atlibs: // GET /oauth//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: 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// 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 --password-env # 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, 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: (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//; GET /oauth//client-metadata.json)`)); }