Skip to content

Latest commit

 

History

History
167 lines (133 loc) · 8.65 KB

File metadata and controls

167 lines (133 loc) · 8.65 KB

Discord membership, capabilities, and public authors

Pixel Index talks directly to Discord's OAuth2 API. It never asks Pico and does not need a bot token, so the index keeps working when the Discord bot is offline.

System overview

Four deployables (see docs/ARCHITECTURE.md for the full breakdown), a pinned upstream, and two things entirely outside this repo: Discord, which owns authentication, and Pico, a third-party consumer of the public API — not a dependency of it.

flowchart TB
    user["Discord User"]

    subgraph pixelIndex["Pixel Index (this repo)"]
        web["web"]
        api["api"]
        renderer["renderer"]
        postgres[("postgres")]
    end

    agents["Pixel Agents<br/>(vendor/, pinned git submodule)"]
    discord["Discord<br/>(OAuth2)"]

    subgraph thirdParty["Third party"]
        pico["Pico<br/>(Discord Bot)"]
    end

    user -- "browses" --> web
    user -- "OAuth2 login" --> discord
    discord -- "authenticated identity" --> api
    user -- "submits layouts<br/>(authenticated)" --> api

    api --> postgres
    api --> renderer

    renderer -- "spawns a Vite dev server,<br/>drives it with Playwright<br/>(runtime)" --> agents
    api -- "furniture catalog, validation,<br/>pinned version (via layout-core,<br/>runtime)" --> agents
    web -. "compiles select modules<br/>+ decoded sprites<br/>(build time only)" .-> agents

    pico -. "GET /api/v1/layouts<br/>GET /api/v1/layouts/{slug}/preview.png" .-> api
Loading

Pixel Agents is a dependency of three of the four services, not just renderer and web. renderer drives it live — a real Vite dev server plus Playwright, at request time, for pixel-perfect PNGs. web only touches it at build time, compiling selected modules and decoded sprites into the static bundle for the live-office viewer, with no runtime dependency once built (the dotted edge). api reads it too, at runtime, via packages/layout-core's PIXEL_AGENTS_DIR resolution — the furniture catalog and layoutRevision checks a submission is validated against, and the pinned version/commit GET /api/v1/meta and GET / report, all come from the same vendored tree.

Discord's only role here is identity. It authenticates a user and hands api back who they are (see below for exactly which scopes and why); it has no idea Pixel Index's web, renderer or Postgres exist, and never talks to any of them directly.

Pico depends on Pixel Index; Pixel Index does not depend on Pico. Pico is an ordinary caller of the public, unauthenticated read routes anyone can hit — GET /api/v1/layouts, GET /api/v1/layouts/:slug/preview.png, and the rest of the public API (see services/api/README.md). Nothing in Pixel Index calls Pico, waits on it, or knows it exists at runtime; every route answers identically whether Pico is online, crashed, or was never written — which is why the arrow above runs from Pico to api, dotted, and never the other way.

Discord roles → dashboard capabilities

flowchart LR
    U["Discord User"] --> D["Discord Guild"]
    D -->|"Roles"| API["Pixel Index API"]
    API --> DB["Postgres"]
    API --> P["Dashboard"]
    R1["Discord Role: Member"] --> PR1["Dashboard privilege: Basic"]
    R2["Discord Role: Moderator"] --> PR2["Dashboard privilege: Moderator"]
    R3["Configured Admin user"] --> PR3["Dashboard privilege: Admin"]
Loading

Why the retained OAuth grant exists

Discord's guilds.members.read OAuth2 scope authorizes GET /users/@me/guilds/{guild.id}/member. The returned guild member contains roles (role IDs) and nick. In contrast, guilds and GET /users/@me/guilds enumerate guilds but do not return member roles. Pixel Index therefore requests identify guilds.members.read, retains the user grant, and calls the member endpoint for the one configured guild. See Discord's OAuth2 scopes and Get Current User Guild Member.

Access and refresh tokens are encrypted before they enter Postgres with AES-256-GCM. DISCORD_OAUTH_TOKEN_ENCRYPTION_KEY is the base64 encoding of exactly 32 random bytes:

openssl rand -base64 32

Only deployment operators/the secret manager and the API process know this key. It is needed only in the api Docker service. Do not expose it to the frontend, renderer, Postgres, Pico, Discord, logs, or source control. Keep it stable and backed up: losing or rotating it without re-encrypting rows makes retained grants unreadable and users must reconnect Discord.

Discord observations are cached for DISCORD_MEMBERSHIP_CACHE_TTL_MS. The recommended value is 60000 ms: a demotion takes at most about one minute to remove dashboard power, while normal navigation does not call Discord on every request. A protected API action revalidates once the cache is stale; access JWTs contain only the Pixel Index user ID and never contain an authorizing role claim. A revoked/unreadable OAuth grant cannot use a previously cached privileged role.

Accounts created before this integration authorized only identify; those users must complete Discord login once more to grant guilds.members.read and create a retained grant. Until then the UI offers Reconnect Discord rather than treating missing authorization as confirmed nonmembership.

Configuration

Variable Meaning
DISCORD_ADMIN_IDS Comma-separated Discord user IDs that receive Admin. This is usable with or without a guild.
DISCORD_GUILD_ID Optional official community guild. Leaving it blank preserves a fully functional self-hosted index.
DISCORD_MODERATOR_ROLE_IDS Comma-separated Discord role IDs that receive Moderator in the configured guild.
DISCORD_INVITE_URL HTTPS invite shown to authenticated outsiders; required with a guild.
DISCORD_OAUTH_TOKEN_ENCRYPTION_KEY API-only AES-256-GCM key; required with a guild.
DISCORD_MEMBERSHIP_CACHE_TTL_MS Maximum age of membership/role observations; default and recommendation: 60000.

The official instance uses guild 1478428628709802166, admin user IDs 1528094749993599038,77488778255540224, and moderator role ID 1528065925264445622. Supply the actual invite URL and a freshly generated encryption key in deployment secrets.

There is no DISCORD_SUBMISSION_ROLE_IDS: every member of the configured guild, including a member whose Discord pending flag is true, may submit. An authenticated outsider sees DISCORD_INVITE_URL instead. An instance with no DISCORD_GUILD_ID continues to let every authenticated user submit, so community configuration does not break self-hosting.

Dashboard rights

Capability Source Rights
Basic Member of the configured guild (or any logged-in user on an unguilded instance) Submit/preview and manage their own layouts, including toggling their own layout between public and hidden.
Moderator Any configured moderator Discord role Everything in Basic; list all layout visibilities, edit another author's metadata with a reason, hide/unhide, and permanently delete, anyone's layout.
Admin Discord user ID in DISCORD_ADMIN_IDS, after membership verification when a guild is configured Everything in Moderator; read the user directory.

The Admin directory contains only non-system users already present in Pixel Index — it does not fetch or enumerate the guild. It shows the cached capability, when it was last checked, and layout count across every visibility. Role assignment and account bans are handled in Discord; stale Pixel Index grant/revoke/block controls and endpoints do not exist.

Author identity and privacy

Pixel Index still needs a users row to own layouts and sessions. users.role is only the last verified capability cache; it is not an independent source of authority. Profile display priority is guild nickname, then Discord global display name, then username. These values refresh during Discord login and membership revalidation.

Public layout and author pages expose the stored display name, username, avatar, and Pixel Index author ID. They intentionally do not expose Discord user IDs, guild membership, role IDs, or capability. An author's name links to /authors/:id, where the public API returns that identity and their public-layout count; the ordinary public layout listing supplies the layouts. Non-public layouts never appear there.

The bundled seed layouts are migrated from the synthetic system user to the known Discord author 1528094749993599038 (pablodelucca). Their legacy authorDisplay is cleared. authorDisplay remains only as a compatibility fallback for other old system-owned seed data.