0
2
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"],
},
],
},
});