import { defineConfig } from "@hatk/hatk/config"; const isProd = process.env.NODE_ENV === "production"; // The public origin this instance serves. It becomes the OAuth issuer and the // base of every registered client_id and redirect_uri, so it has to be the real // origin rather than anything inferred — and if it is unset, the client list // below is empty and OAuth fails at login rather than at boot. const prodDomain = process.env.APP_DOMAIN; // Dev listens wherever PORT says (default 3000). The loopback client_id and // redirect URIs encode the origin, so they follow it. const devPort = Number(process.env.PORT ?? 3000); const devOrigin = `http://127.0.0.1:${devPort}`; // Dev asks for the space scopes outright (see spaceScopes). Against a network // whose PDS cannot dereference a `social.grain.privateGallery` space declaration — // the opensocial dev stack, for one — the whole login fails with // invalid_scope, so GRAIN_SPACE_SCOPES=0 leaves them out. const devSpaceScopes = process.env.GRAIN_SPACE_SCOPES !== "0"; // The pool scopes, likewise: a network whose PDS cannot dereference // `social.grain.group` fails every login if we ask for them. The opensocial dev // stack publishes that declaration and turns these on; set GRAIN_POOL_SCOPES=0 // against one that does not. const devPoolScopes = process.env.GRAIN_POOL_SCOPES !== "0"; const grainScopes = [ "atproto", "blob:image/*", "repo:social.grain.gallery", "repo:social.grain.gallery.item", "repo:social.grain.photo", "repo:social.grain.photo.exif", "repo:social.grain.actor.profile", "repo:social.grain.graph.follow", "repo:social.grain.favorite", "repo:social.grain.comment", "repo:social.grain.story", "repo:social.grain.graph.block", // Both actions, not just create: the gallery cross-post is written with // com.atproto.repo.putRecord so a resumed publish overwrites its own post // instead of posting twice, and putRecord asserts create *and* update. "repo:app.bsky.feed.post?action=create&action=update", ].join(" "); // Older iOS builds and the Android app still ask for the create-only form of // the Bluesky post scope. hatk forwards a native client's requested scope to // the PDS verbatim, and the PDS checks each token against this document // literally — not as a semantic subset — so dropping the narrow string would // fail their sign-in with `invalid_scope`. Declared, never requested. const legacyScopes = "repo:app.bsky.feed.post?action=create"; // Private galleries live in permissioned spaces (proposal 0016). `authority=*` // because a reader's session has to reach a space anchored on somebody else's // account; `manage=` because the author's session creates the space itself. // // Requested only from PDSes that serve spaces — see conditionalScopes below. // Almost no PDS does, and a permission nobody can honor has no business on // everybody's consent screen. // // Registered on every client all the same. A client metadata document's `scope` // is the set the client may ask for, not the set it asks for on any given // login, and a PDS that checks the request against it — zds does; bsky.social // does not — rejects the whole authorization with `invalid_scope` when // conditional scopes widen the request past what the document declares. const spaceScopes = [ "space:social.grain.privateGallery?authority=*&skey=*", "collection=social.grain.gallery", "collection=social.grain.gallery.item", "collection=social.grain.photo", "action=read&action=create&action=update&action=delete", "manage=create&manage=update&manage=delete", ].join("&"); // A group's pool is a space too, but not one of ours to make: the group // creates it, and a member only reads it and writes their own galleries into // it. So no `manage=` — and `authority=*` because the space is anchored on the // group, never on the member. // // Requested separately from the gallery scopes above, and separately gated, // because the two need different things of a PDS: this one only needs // `social.grain.group` to resolve as a space declaration, which is a lexicon a // group host publishes. A network that serves one may not serve the other. // Groups, as a whole. Asked for only where they can be used. // // A pool is a space, and every read of one mints a delegation token on the // member's own PDS — so on a server without spaces none of this can work, and // a permission nobody can honor has no business on their consent screen. The // same argument the space scopes above make for themselves. // // It includes joining and leaving, which technically need no spaces at all: // they are calls to the group host with service auth, and any PDS can mint // those. They are gated anyway because groups are currently only meant to work // for spaces-capable accounts, and offering someone a Join button whose pool // they could never open would be the worse shape. Revisit when a group is // worth being a member of without one. const groupScopes = [ // A member offers a gallery (submission, their repo); whoever acts as the // group answers (item, the group's repo). "repo:social.grain.group.submission", "repo:social.grain.group.item", "repo:social.grain.group.decline", // Joining and leaving a group is a call to its group host, made with // service auth the member's PDS mints for that host — any host, hence `*`. "rpc:fyi.opensocial.requestJoin?aud=*", "rpc:fyi.opensocial.leaveGroup?aud=*", // Starting a group: the viewer asks Grain's group host to found it // (provisionGroup, which also hands Grain its own session on the group) and // then, as its founding admin, to open the group's pool (createSpace). "rpc:fyi.opensocial.provisionGroup?aud=*", "rpc:fyi.opensocial.createSpace?aud=*", // Which groups the viewer is in is theirs to say, not a public roster's: // `listSpaces` on their own PDS names the members spaces they hold an // acceptance in, and a credential for each confirms the host still admits // them — which is a read of the members space, the roster a member can // already see. Writes are only ever their own acceptance, on join and leave. [ "space:fyi.opensocial.members?authority=*&skey=*", "collection=fyi.opensocial.acceptance", "action=read&action=create&action=update&action=delete", ].join("&"), ]; // Registered on every client, for the reason the space scopes are: a client // metadata document's `scope` bounds what the client may ask for, and a PDS // that checks the request against it rejects the whole authorization when a // conditional widens the request past the document. blacksky.app does, and // left out of the documents below these scopes failed its users' logins // outright — not groups, the login. const groupScopeList = groupScopes.join(" "); const poolWriteScope = [ "space:social.grain.group?authority=*&skey=*", "collection=social.grain.gallery", "collection=social.grain.gallery.item", "collection=social.grain.photo", // A favourite and a comment on a pooled gallery belong in the pool too. In // the public repo they would name a private gallery to the whole network, // which is the one thing the space is for. "collection=social.grain.favorite", "collection=social.grain.comment", "action=read&action=create&action=update&action=delete", ].join("&"); // The same pool, read only, asked for alongside. Whoever signs in as a group // gets a grant narrowed to what their roles may do as it, and a group host // grants writing into a space as the group separately from reading it — so // the read has to be a scope of its own, or a group loses its own pool with // the writes. For a member it adds nothing the scope above does not. const poolScopes = `${poolWriteScope} space:social.grain.group?authority=*&skey=*&action=read`; export default defineConfig({ // hatk answers /robots.txt itself, ahead of SvelteKit, from this directory. // Pointed at SvelteKit's static/ so the file lives with the other assets. publicDir: "./static", relay: isProd ? "wss://bsky.network" : "ws://localhost:2583", // Jetstream filters server-side, so we stop decoding the whole network to // find social.grain.*. Prod only — the local PDS has no Jetstream in front // of it. `relay` is still required: backfill resolves repos through it. // // us-west, not us-east. The original reason was Railway-specific: from around // 2026-08-19 the us-east name answered 503 ("No server is available", haproxy // with no backend) on every path for requests leaving Railway, while the same // IP answered 200 from anywhere else — healthy from a laptop, unreachable from // the one network that mattered. That constraint left with Railway, so us-east // may well work from Hetzner now. Nothing is gained by finding out: us-west is // what the Jetstream docs use in their own example, and it works. jetstream: isProd ? { url: "wss://jetstream.us-west.bsky.network" } : null, // DID_PLC_URL is what the rest of the server reads too; a dev stack on // other ports sets it, and reading 2582 regardless resolves nothing there. plc: process.env.DID_PLC_URL ?? (isProd ? "https://plc.directory" : "http://localhost:2582"), // Classifier scoring for the /admin review queue, by Clef on Workers AI. The // account and token come from CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AI_TOKEN; // with either unset, the classifiers under server/classifiers still load but // no scan can run and nothing is queued. The thresholds were tuned on `clef`, // not `clef-flash`, which scored noticeably worse on grain's moderated data. // // scoreOnIndex scores each new post as it is indexed, so a report lands within // seconds. It covers only content written after it was first switched on; the // library from before then is scored only by a manual scan from /admin. clef: { model: "clef", scoreOnIndex: true }, port: devPort, cdn: isProd ? { url: "https://cdn.grain.social", key: process.env.CDN_KEY!, salt: process.env.CDN_SALT!, } : null, databaseEngine: "sqlite", database: isProd ? "/data/grain.db" : "data/grain.db", backfill: { // What makes a repo worth tracking. A grain profile is the obvious signal // for a person; a group declaration is the equivalent for a group, // and it is the record that literally says "this account is one". // // It discovers nothing today, and that is fine: signals are resolved by // asking a PDS `listReposByCollection`, and `bsky.network` answers // `{"repos":[]}` for this collection. Groups arrive by the ordinary // route instead — the relay crawls their host and the firehose carries // both its existing records and everything after. Kept because it is the // right signal if that ever stops being true. signalCollections: ["social.grain.actor.profile", "fyi.opensocial.declaration"], fullNetwork: false, parallelism: 5, // Bridgy Fed hosts every bridged fediverse account here, and each repo // mirrors that account's whole history: one ran to 34,185 records and // 31 seconds of import. Their live posts still arrive through the // stream, where the bridged classifier hides them. skipPds: ["atproto.brid.gy"], }, push: { apns: { keyFile: isProd ? "/data/certs/AuthKey_J53A95HDW7.p8" : "./certs/AuthKey_J53A95HDW7.p8", keyId: "J53A95HDW7", teamId: "YN68LN9T7Z", bundleId: "social.grain.grain", production: isProd, }, // Android. The service account is a Firebase key with the Messaging role, // kept alongside the APNs key rather than in the repo; the project id comes // from inside it. Until the file is there, hatk logs the miss and simply // doesn't send to Android — iOS is unaffected. fcm: { keyFile: isProd ? "/data/certs/firebase-service-account.json" : "./certs/firebase-service-account.json", }, }, oauth: { issuer: isProd && prodDomain ? `https://${prodDomain}` : undefined, // Dev asks for the space scopes outright, and has to ask here too: the // server-initiated login builds its request from this list, and it must // match what the loopback client_id encodes or the PDS grants neither. scopes: [ grainScopes, ...(!isProd && devSpaceScopes ? [spaceScopes] : []), ...(!isProd && devPoolScopes ? [poolScopes, ...groupScopes] : []), ] .join(" ") .split(" "), conditionalScopes: [ { whenMethod: "com.atproto.simplespace.createSpace", scopes: [spaceScopes] }, // The reference alpha's name for the same, since 0.0.0-spaces-alpha-20261029171913. { whenMethod: "com.atproto.space.createSpace", scopes: [spaceScopes] }, // Pools, gated on reading one rather than on creating a space: a member // never creates the pool — the group does — so `getDelegationToken` // is the method that actually decides whether they can take part. // // The pool scope was previously dev-only and named in no conditional, so // production never asked for it at all. A pool write would have failed // even on a PDS that could have served it. { whenMethod: "com.atproto.space.getDelegationToken", scopes: [poolScopes, ...groupScopes], }, ], clients: [ ...(prodDomain ? [ { client_id: `https://${prodDomain}/oauth-client-metadata.json`, client_name: "grain", scope: `${grainScopes} ${legacyScopes} ${spaceScopes} ${poolScopes} ${groupScopeList}`, redirect_uris: [ `https://${prodDomain}/oauth/callback`, `https://${prodDomain}/admin`, "grain://oauth/callback", ], }, ] : []), { // Dev asks for the space scopes outright. Negotiation is skipped for // loopback clients — the scope is encoded in the client_id, which the // token exchange rebuilds from this config, so the two would disagree. client_id: `${devOrigin}/oauth-client-metadata.json`, client_name: "grain", scope: [ grainScopes, legacyScopes, ...(devSpaceScopes ? [spaceScopes] : []), ...(devPoolScopes ? [poolScopes, groupScopeList] : []), ].join(" "), redirect_uris: [`${devOrigin}/oauth/callback`, `${devOrigin}/admin`], }, { client_id: "grain-native://app", client_name: "Grain for iOS", scope: `${grainScopes} ${legacyScopes} ${spaceScopes} ${poolScopes} ${groupScopeList}`, redirect_uris: ["grain://oauth/callback"], }, ], }, });