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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
292 lines · 14.4 KB Raw History
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"],
      },
    ],
  },
});