1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// 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)`));
}