Skip to content

Pixel architecture ​

Status: living draft. The bot is built; Azure bootstrap is applied; Container App pixel-dev is live. Identity and access control have their own document: identity-and-access.md. What Pixel stores about people: privacy.md. Home Assistant setup and threat model: home-assistant.md (ADR 0011).

Goals ​

  1. Useful to Pixelbar members and visitors. Show space status, surface events, answer common questions.
  2. Platform-independent core. Features are written once. Discord comes first. Later platforms come in two kinds: interactive ones (for example Telegram) and outbound-only ones (for example Mastodon). Each kind is a separate adapter.
  3. Secure tiers. guest < friend < member < admin, enforced in one place. See the identity doc.
  4. Simple to operate. One container per environment (dev and prod), one command to run locally, errors visible in Sentry.
  5. Easy to contribute to. A new feature is one folder and needs no platform plumbing.

Phase 1 scope ​

In scopeNot yet
Core: commands, access, dispatcher, registry, announcerTelegram, other interactive adapters
Discord adapter (interactive + publisher + calendar)Account linking across platforms
Mastodon publisher (outbound toots; no input)Bluesky and other outbound publishers
Tiers from config/admins.yaml and config/members.yamlReading Discord roles (never: roles are only mirrored to)
SpaceAPI status, Discord events, info, help, whoamiA database (none: access, schedules and planned linking are files)
Sentry, pino, just, CI, Terraform bootstrap + dev + prod stacksStop-then-start overlap (#11)

Non-goals: horizontal scaling, input from broadcast platforms, LLM chat, languages other than English.

High-level design ​

Pixel follows a ports-and-adapters design. Adapters translate between a platform and the core. Features only know about the core.

                  Discord                    Mastodon
                     │  ▲                       │
                     ▼  │                       │
   ┌─────────────────────┴────┐   ┌─────────────┴────────┐        later:
   │ adapters/discord         │   │ adapters/mastodon    │   ┌───────────────────┐
   │  interactions → actor    │   │  publisher only      │   │ adapters/telegram │
   │  Reply → Discord         │   │  (toots; no input)   │   └───────────────────┘
   │  Publishers + calendar   │   └──────────▲───────────┘
   └───────┬──────────────────┘              │
           │                    Announcement │
           ▼                                 │
   ┌─────────────────────────────────────────┴┐
   │ core                                     │
   │  identity (TierSources) → dispatcher     │
   │  (rate limit → authorize → handler)      │
   │  registry · announcer                    │
   │  errors · ports                          │
   └───────────────────┬──────────────────────┘
                       ▼
   ┌──────────────────────────────────────────┐
   │ features/*  help · ping · status ·       │
   │             events · info · whoami       │
   └───────────────────┬──────────────────────┘
                       ▼
   ┌──────────────────────────────────────────┐
   │ services/*  spaceapi · access config ·   │
   │             info content                 │
   └──────────────────────────────────────────┘

   observability/ (Sentry + pino) wraps every layer

Core concepts ​

ts
// core/access.ts
type Tier = "guest" | "friend" | "member" | "admin";
type ChatContext = "dm" | "group";
type Access = { minTier: Tier; contexts?: ChatContext[] };   // minTier is required

type PlatformActor = {
  platform: Platform;            // "discord" for now
  userId: string;                // immutable platform ID: the only thing used for auth
  displayName: string;           // logs and display only
  handle?: string;               // unique username (Discord handle), logs only
  chat: ChatContext;
};

type Principal = PlatformActor & { tier: Tier };

// core/command.ts
type CommandDefinition = {
  name: string;
  description: string;
  access: Access;
  options?: CommandOption[];     // string | integer | boolean | user; string and integer can have `suggest`
  private?: boolean;             // default reply visibility
  placeholder?: Reply;           // shown at once (e.g. "Checking…"), then replaced by the result
  handler: (ctx: CommandContext) => Promise<Reply>;
};

// Or a group, e.g. /admin: `subcommands` instead of a handler. Each subcommand has
// its own name, access, options, private, placeholder and handler. A group can also hold
// subgroups (/admin → People → **Grant capabilities…**): { name, description, access, subcommands }.
// Access is a floor at every level: the registry rejects a looser child, and the dispatcher
// checks every level.
type GroupCommand = { name; description; access: Access; subcommands: (SubcommandDefinition | SubgroupDefinition)[] };

// access: { minTier, contexts?, capability? } — a capability is a named permission that must
// hold as well as the tier (core/capabilities.ts). Principal.capabilities is always empty for guests.

type CommandContext = {
  args: Record<string, string | number | boolean | undefined>;   // validated against options
  users: Record<string, ResolvedUser>;   // people picked through `user` options (id, displayName, handle?, isBot)
  principal: Principal;
  logger: Logger;
  availableCommands: CommandSummary[];   // only what this principal may run (for /help)
};
// Features receive their dependencies through their factory function
// (constructor injection), not through the context.

// core/reply.ts: adapters render this however their platform allows
type Reply = {
  text?: string;
  embeds?: Embed[];              // Embed.accent: brand | positive | negative | warning | neutral
  private?: boolean;             // Discord: ephemeral
  controls?: ControlRow[];       // buttons and selects (core/ui.ts), each running an action
};

// core/ui.ts: what controls and forms run (ADR 0023)
type ActionCall = { action: string; args?: Record<string, string | number | boolean> };
type ActionDefinition = {
  name: string;                  // "schedule.pause": not a slash command, only reached through controls
  access: Access;                // required, like a command's
  options?: CommandOption[];     // what the dispatcher validates
  handler: (ctx: ActionContext) => Promise<{ reply: Reply } | { form: Form }>;
};
type Form = { title: string; submit: ActionCall; fields: FormInput[] };   // text, radio, checkboxes, channel

// core/announcement.ts: what features announce and publishers receive
type Announcement = SpaceStatusAnnouncement | BotStatusAnnouncement | ClosingTimeAnnouncement;
type SpaceStatusAnnouncement = {
  kind: "space.status";
  state: "open" | "closed";
  at: Date;                      // when Pixel saw the change
  openedAt: Date | null;         // for "closed": when this stretch of being open began, if known
  text: string;                  // short plain text for platforms without rich formatting
};
type ClosingTimeAnnouncement = {
  kind: "closing.time";
  text: string;                  // escaped plain text for platforms without rich formatting
  body: string;                  // operator-authored markdown; Discord posts this with mentions off
  at: Date;
};
type Publisher = {
  id: string;                    // "discord:live", "discord:timeline", "mastodon", …
  publish(a: Announcement): Promise<void>;
  reconcile?(s: SpaceSnapshot): Promise<void>;   // at startup: fix stale posts, never post new ones
};

// core/feature.ts
type Feature = {
  name: string;
  commands?: CommandDefinition[];
  actions?: ActionDefinition[];  // what this feature's controls and forms run
  start?: () => Stop;            // background work, started once the adapters are ready
};

Pieces of the core ​

  • Registry collects features. It refuses to register a command or an action without access.minTier and refuses duplicate names.

  • IdentityService asks each TierSource for a tier and keeps the highest. Phase 1 has only the config source.

  • Dispatcher handles each command in order:

    1. rate-limit, which is cheap and protects the tier sources
    2. resolve identity
    3. check access, logging denials
    4. validate the arguments against the declared options
    5. send the command's placeholder, if it has one, through the adapter's onPending hook. This only happens once the checks above pass, so denied callers never see it
    6. call the handler
    7. map errors to replies, reporting unexpected ones to Sentry with the command's tags
    8. log every executed command as an action (command.executed), recording who did it (ID, display name, handle), their tier, the outcome and the duration

    Dispatcher.act does the same for a button, select or form: rate limit, identity, the action's access, its declared options, the handler, error mapping and action.executed. The actor is whoever used the control (ADR 0023).

  • Interpolator (core/interpolate.ts) fills {{tokens}} in user-set posts at post time. Features call it right before send. The date family is built in ({{date}}, {{day}}, {{month}}, {{year}}, {{dateWithTime}}, in PIXEL_TIMEZONE). A known date token may take a whole-day offset ({{date+2}}, {{day-1}}), from the scheduled occurrence for /schedule (so a Monday post can talk about Wednesday, even if Pixel sends a bit late). A later token (space state, a Home Assistant device, …) is a named provider registered on the same interpolator — not an if/else in each feature. Unknown tokens and bad offsets are left as written. No expressions, no eval. This is not the /info load-time placeholders, which fail startup on a typo. Never log the template or the filled body.

  • Announcer sends each announcement to every registered publisher. Adapters register their publishers once they're ready. One publisher failing never blocks the others and never throws: the failure is logged and reported to Sentry, tagged announcer:<publisher id>. Calls run one at a time, in order, so a startup reconcile can't interleave with an announcement. Discord publishers ignore kinds they don't handle (channel IDs decide which are on). Mastodon posts only the kinds in MASTODON_ANNOUNCE_KINDS (default space.status).

  • Background work: a feature can declare start(). The bot calls every feature's start() once Discord is ready, so the publishers already exist, and calls the returned stop function on shutdown. There's deliberately no generic scheduler yet: the status service already polls on its own, and one will make sense when a second job appears (such as the nightly role check in #14).

  • Rate limiter keeps an in-memory token bucket for each user. That is enough because there is a single replica.

  • Errors: UserFacingError is shown to the user and not reported. Anything else is reported to Sentry, and the user gets a generic reply. Errors outside commands, from background work or platform clients, go through ErrorReporter.captureBackground and are tagged with their source.

Discord adapter ​

  • discord.js v14, slash commands only. Intents: Guilds only, which isn't privileged. The role sync (DISCORD_ROLE_SYNC=on) lists members over REST, which needs the Server Members Intent switched on in the Developer Portal, but the gateway never requests it. No MessageContent. Scheduled events are read over REST, which needs no intent.
  • Guild allow-list: interactions from any guild other than DISCORD_GUILD_ID are refused, and the bot leaves other guilds. Slash commands are guild-only (they aren't available in DMs). Pixel does send DMs when a capability is granted or revoked (see below).
  • Autocomplete: a string or integer option can have a suggest function (never together with fixed choices). It runs while someone types and returns up to 25 { name, value } suggestions from live data, and it can see the options already filled in, so the actions offered can depend on the device chosen. Dispatcher.suggest runs it through the same access gates as the command, so people who can't run a command get an empty list (logged as command.suggest_denied), and it has its own, more generous rate limit so typing can't use up the budget for commands. A slow (over 2.5 s, Discord's limit is 3) or failing function gives an empty list. What was typed is never logged. Suggestions are not validation: Discord doesn't check that a submitted value came from them, so the command validates what it receives as usual.
  • Feature DMs: the same messenger is also plugged into the core DirectMessages (core/direct-messages.ts), which features use for private messages (parking reminders). Until Discord is connected, a send fails, and features log that and carry on.
  • Capability DMs: when AccessStore.apply actually grants or revokes capabilities, Pixel DMs that Discord user ID through a DirectMessenger port (core/ports/direct-message.ts, implemented here). Mentions stay off. A closed inbox or other send failure is logged (capability.dm_failed, IDs only) and never fails the /admin command or tells the target why.
  • Mapping: CommandDefinition.options become Discord slash command options. A group becomes native subcommands (/admin), and a user option becomes Discord's user picker. The handler gets the picked user's immutable ID as the arg, and the adapter resolves their name and whether they are a bot. Bots are refused unless the option sets allowBots. Targets are identified by ID only, never by name. Reply becomes the message content plus embeds, truncated to Discord's limits. private becomes the ephemeral flag. Mentions are always disabled (allowedMentions: { parse: [] }), so no reply can ping @everyone.
  • Buttons, select menus and forms (guide, ADR 0023): Reply.controls become action rows under the message (legacy components, not Components V2), and a Form becomes a modal with every input in a Label. controls.ts holds what each control and form runs under a random token in its custom_id, for the person the reply was for, for 15 minutes; a restart drops them. ui-handlers.ts checks the guild, the token, the owner, and that chosen values and submitted fields match what was shown, then calls Dispatcher.act as the person who used it. An action's reply replaces the message (update), a form opens a modal, and a refusal is a private reply that leaves the message alone. If an action takes over 1.5 s the update is deferred; a form can't open after that, so the person is asked to try again. The one exception to holding controls is an attendance poll's buttons (attendance-buttons.ts, ADR 0025): public, and lasting as long as the poll, so their custom ID is pixel-attend:<poll>:<answer>, parsed exactly. A click always runs schedule.attend for the clicker and gets a private answer; the message itself is edited by the feature.
  • Acknowledging within 3 seconds: Discord requires a response within 3 s. Pixel acknowledges with whichever comes first:
    • the command's placeholder, which is posted straight away
    • deferReply(), if the handler is still running after 1.5 s (this uses the command's default visibility)
    • the final reply
  • Placeholders and deferrals are edited into the result. Edits always set both content and embeds, because Discord keeps any field an edit leaves out.
  • Visibility can't change after the first response. If the first response was public but the result is private, Pixel deletes it and sends the result as an ephemeral follow-up. A private result is never shown publicly.
  • Accents: Embed.accent sets the colour of the embed's side bar.
  • Registration: just register dev or just register prod runs a script that builds the command list from the registry and sends it with a REST PUT to the guild. A Release does prod itself after rolling pixel-prod (ADR 0014). Commands are registered to the guild only, because Pixel serves just one, so updates show up instantly. .env.dev and .env.prod stay separate so the tokens cannot mix.
  • Publishers: three styles of space announcement. Admins pair styles with channels at runtime with /admin → Announcements → Add a channel… (a channel has one style, any number of channels can be set, and the pairings are saved in data/announcement-routes.yaml). There is no environment setting for them. Each pairing is its own publisher, discord:<style>:<channel id>, started and stopped without a restart. All posts are embeds with mentions disabled, and times use Discord's timestamp markup, so everyone sees them in their own time zone.
    • Timeline: a new post for every open and every close, never edited, so a status-only channel reads as a log of exactly when the space opened and closed. "🟢 Pixelbar opened" and "🔴 Pixelbar closed", with how long it was open if known.
    • Live: opening makes a new "🟢 Pixelbar is open" post; closing edits that same post to "🔴 Pixelbar is closed: was open from … to …". Opening again makes another new post. A closed post is never turned back into an open one, so nobody is confused by a message that flips back.
    • Single: the channel only holds the current status. A change posts the new "🟢 Pixelbar is open" or "🔴 Pixelbar is closed" (closed shows when, and how long it was open, like the other styles) and then deletes the previous status post, so a failed delete never leaves the channel empty and the next change tidies up. Pixel finds the posts to delete like the live style does (remembered ID in announcement-posts/single-<channel>.state, plus its recent messages), by a footer only single posts carry. At startup it corrects a status post that went stale while Pixel was down by editing it, and removes extras, without posting anything new. Deleting its own messages needs no extra permission.
    • Finding the open post to edit. Pixel uses the message ID it remembered for that channel in announcement-posts/live-<channel>.state (so it works even if the post is buried in a busy channel), and also looks through its own recent messages in the channel (so it still works if the remembered ID was lost, for example after a deploy, and a deleted post simply isn't found). Only posts carrying the live footer count, so a /status reply can't be mistaken for one. Pixel keeps the invariant that at most one post, the newest, says "open".
    • Closing time (DISCORD_CLOSING_TIME_CHANNEL_ID): a new "Closing time" embed with the configured body (or a built-in default). Mentions stay disabled. Unset, off or none turns it off. The same post is used for /closing-time and for the automatic hook after a space-closed announcement.
    • Startup checks. For each channel, Pixel checks it exists, is a text channel in the Pixelbar server, and that the bot has the permissions it needs: View Channel, Send Messages and Embed Links, plus Read Message History for the live style. If not, that publisher stays off with a clear log and a Sentry report (discord.announce_disabled, or announcements.route_off for an /admin → Announcements channel), and everything else keeps working. The reason says whether there's no such channel (Discord's Unknown Channel), the bot can't see it (Missing Access, or no View Channel there), or a permission is missing. No new Discord intents are needed.
    • Trying again (adapters/discord/announce-recheck.ts). A channel that couldn't be opened is tried again 5 seconds after Discord says a channel or role in the server changed, and every 5 minutes anyway (giving the bot a role sends no event without the privileged GuildMembers intent, so the bot's own roles are always fetched fresh). Once it works, its publisher starts (discord.announce_enabled / announcements.route_on) with no restart and no /admin → Announcements → Add a channel…. A retry that fails the same way logs nothing; a different problem is logged and reported once. A bot status channel that comes on late has missed this run's online post, so it posts again on the next shutdown or start.
  • Bot status (features/bot-status, adapters/discord/bot-status.ts): Pixel says when it comes online and goes offline, in the announcements channel (DISCORD_ANNOUNCEMENTS_CHANNEL_ID, or DISCORD_ANNOUNCE_BOT_CHANNEL_ID to post elsewhere; neither set means off).
    • Online is a new post each time ("🟢 Pixel is online"), so every start, deploy and restart is visible and the channel reads as a history of runs. It shows the version (package.json), the git commit, the branch when it isn't main, the environment when it isn't prod, Where (local or cloud, from PIXEL_RUNTIME), and a short public status: Discord connected, Home Assistant (when set up: connected, not connected yet or off, never the reason) and whether SpaceAPI answered. It's posted once Discord is ready and Home Assistant has connected, or after 10 seconds, whichever comes first.
    • Going offline edits that run's post to "🔴 Pixel is offline: was online from … to … (uptime), reason", so each run is one message and the newest one always tells the truth. It's sent on SIGTERM or SIGINT, before disconnecting, with at most 5 seconds' wait so it never holds up a shutdown. A run with no online post of its own (stopped before it said online, or its online post failed) says offline in a new post and never edits another run's post, so a deploy's new online post is left alone. Once shutdown starts, a run that hasn't said online yet never does.
    • A crash can't say goodbye. On the next start, any of Pixel's posts still saying online (found by the remembered ID in bot-status.state, and by title and footer among its recent messages, like the live space post) are edited to "⚠️ Pixel stopped unexpectedly" before the new online post goes up.
    • Where the commit and branch come from: PIXEL_GIT_SHA and PIXEL_GIT_BRANCH, passed in by CI and just docker-build as build arguments (an image has no .git). From a checkout without them, Pixel asks git; anything it can't find is left out.
    • It goes through the Announcer as a bot.status announcement; the space publishers ignore it, and it needs the same permissions as the live style (including Read Message History).
  • Presence (adapters/discord/presence.ts): Pixel's own status in the member list says whether Pixelbar is open: "Watching Pixelbar is Open" with the green dot, or "Watching Pixelbar is Closed" with the yellow (idle) dot. It's a publisher like the others, so it hears the space state at startup (reconcile) and on each confirmed change, never on a flicker, and needs no channel, permission or intent. It's set only when the space is known to be open or closed: before SpaceAPI has answered, or after Pixel has been unable to ask for a long time, it shows whatever it last set. Discord clears it when Pixel goes offline. After a reconnect that cannot resume the session, Pixel sets the last known presence again.
  • Calendar source: once connected, the adapter plugs the server's Discord scheduled events into the core Calendar. It reads them over REST on every call, needing no extra intents or permissions. (discord.js only skips the request when asked for one event by ID, which this never does.) Voice and stage channel names come from discord.js's channel cache, which Discord keeps current through gateway updates, so a renamed channel is right immediately. calendar-map.ts (pure, fully tested) maps each Discord event to a neutral CalendarEvent: external events use their location text, voice and stage events use the channel's name, finished and cancelled events are dropped, and recurrence rules become plain words ("weekly on Tuesday"). Discord returns a recurring event once, showing its next occurrence.

Mastodon adapter (publisher only) ​

Mastodon is outbound only: Pixel toots announcements and never reads mentions, replies or DMs. Features still hand a typed announcement to the Announcer and never import Mastodon. The HTTP client lives only in src/adapters/mastodon/.

  • Account agnostic. MASTODON_INSTANCE (the instance origin) and MASTODON_ACCESS_TOKEN (a secret, scope write:statuses) come from config. MASTODON_ACCOUNT is an optional public handle for logs; the token decides which account posts. No instance or account is hard-coded.
  • Both unset means off. Discord keeps working. Setting only the instance or only the token is a mistake and stops startup, like Home Assistant. off / none in MASTODON_ANNOUNCE_KINDS leaves the publisher unregistered even with credentials.
  • Kinds. Default space.status (open/closed). Operators can add closing.time or bot.status via MASTODON_ANNOUNCE_KINDS. Each toot uses the portable text field, never Discord embeds or markdown.
  • Mentions and length. @ in the text is broken so it cannot ping anyone. Statuses are clipped to 500 characters (Mastodon's default). Visibility is unlisted by default, or public (MASTODON_VISIBILITY); never private or direct. Unlisted reaches followers without putting every open/close on the instance's local timeline.
  • Outage. A rejected or unreachable instance throws from the publisher; the announcer reports it (announcer:mastodon) and the other publishers still run. Redirects are refused so the bearer token is never sent to another host. Reconcile is a no-op: Mastodon is a log of toots, not a live card.
  • Flood. Open/closed toggles are already confirmed by the status feature (two polls) before anything is announced. Mastodon does not drop a confirmed change.
  • Tests map kinds to toot text with plain objects and mock fetch at the client boundary, never a live instance.

Home Assistant (a client, not an adapter) ​

Pixel can read and control a Home Assistant (HA) instance: lights, switches, doors. Unlike Discord this is not a place where commands come from. Pixel is the client, and features reach HA only through the core Home (core/home.ts), the same way they use the calendar. The adapter in adapters/home-assistant/ is the only place that imports the official home-assistant-js-websocket library (WebSocket API, not REST), and it plugs a backend into Home at startup. Setup on a test instance and the threat model: home-assistant.md. Why WebSocket, a non-admin token, no inbound webhooks, and an allow-list: ADR 0011.

  • Connection: HOME_ASSISTANT_URL and HOME_ASSISTANT_TOKEN, a long-lived token. Both unset means off, and only one set stops startup. It connects in the background and reconnects forever, so a slow or absent HA never holds up the bot, and it doesn't count towards /healthz: HA being down shouldn't restart Pixel. On Azure, prefer a Tailscale sidecar so the container reaches HA on the LAN (#9, #43). A Nabu Casa URL still works and is what local/dev use today.
  • A non-admin token: HA can't scope a token, so a long-lived token can do whatever its user can. Use a non-admin HA user: that blocks the admin-only commands. Pixel asks HA whose token it is (auth/current_user) and warns, in the logs and Sentry, if it belongs to an admin. /admin shows the warning too. The real fence is Pixel's own allow-list of devices (below).
  • Fails closed: while HA isn't connected, or the token was refused, a call fails at once with a plain message. Nothing is queued, nothing is retried (a late unlock must never happen), and every call has a time limit. An outage is reported once, not on every command.
  • No cache: every read asks HA, so a change in HA shows on the next read.
  • Errors are translated: the library throws numbers (connection problems) and plain { code, message } objects (HA's own errors). The adapter turns them into HomeUnavailableError and HomeRequestError with fixed, safe messages. The token and the address never appear in a message, a log line or Sentry (the token is scrubbed there too).
  • After a refused token: the connection is off until /admin → Reload the access lists re-checks it, so a replaced token takes effect without a restart.
  • Tests run against src/testing/fake-home-assistant.ts, a stand-in server that speaks HA's WebSocket protocol, never a real HA.

Devices and kinds ​

Pixel never offers "any entity". The devices it may touch are listed in config/home-assistant/devices.yaml (gitignored; devices.example.yaml is committed and documented). Each device has:

  • a name people type (front-door), unique, lowercase words joined by -;
  • an entity (lock.front_door), unique, whose domain must match the kind;
  • a kind, defined in code, which decides what can be done and how;
  • the actions allowed, a subset of the kind's. Leaving them out means read-only: nothing is allowed unless it's listed;
  • an optional description, and minTier (friend, member or admin, default member; never guest).

Kinds live in src/core/home-kinds/: light, switch, door (lock, unlock) and sensor (read-only). A kind is plain data: its HA domains, its actions (the HA service each calls and the states that mean it worked) and the capability that allows acting on it (ha-lights, ha-doors and so on, which the kind registers with the others). Who may do what is one rule in core/home-access.ts: see "Home Assistant devices" in identity-and-access.md. To add a kind, write a file like light.ts and add it to HOME_KINDS. The devices file, commands and autocomplete all read that list. defineKinds rejects a malformed kind when the code starts.

The file fails closed like the access files: when HOME_ASSISTANT_URL is set and the file is missing or invalid, Pixel doesn't start. Errors name the file, the position and the field, never a value, because the file describes the building. /admin → Reload the access lists re-reads it, and an invalid edit keeps the old list. At startup and on reload, Pixel warns about devices whose entity HA doesn't know (a typo), and /admin shows how many devices are allowed. just validate-config checks it too.

Adding devices (just ha-devices) ​

Pixel itself never writes the devices file, and it keeps no list of everything Home Assistant has. People change the allow-list on Azure with just ha-devices dev|prod, a laptop script (scripts/ha-devices.ts): it pulls the env's devices.yaml from its file share, reads the entities Home Assistant has that Pixel has a kind for (live, with the same non-admin token, leaving out those filed as config or diagnostic and those someone hid), and lets a person tick the ones to add or untick the ones to remove. It validates the result with the same loader Pixel uses, shows a diff, and uploads only on a yes and only if the remote didn't change meanwhile. The remote file is the truth; a local mirror lives under config/home-assistant/<env>/. What Home Assistant has is only ever something to pick from: it carries no tier and no actions, and a device is usable only once it is in devices.yaml (then /admin → Reload the access lists).

OpenR (the building's doors) ​

The building's doors, and the parking gate, are run by OpenR. Pixel opens them the way OpenR's own web dashboard does, as one OpenR account of its own: OpenR only ever sees that account, and Pixel decides who may open what (ADR 0022). Like Home Assistant, Pixel is the client: features reach OpenR only through the core DoorSystem port (core/ports/door-system.ts), and src/adapters/openr/ is the only place that talks HTTP to it.

  • Config: OPENR_BUILDING_ID, OPENR_EMAIL and OPENR_PASSWORD together or not at all (only some stops startup), and then OPENR_DOORS (the doors /openr open offers, Name=ID pairs), OPENR_GATE_DOOR (the parking gate, which only /park opens) or both. The gate can't be one of the doors. The email and password are secrets. Unset means off: /openr says it isn't set up and /park says to open the gate the usual way.
  • Logging in needs a person. OpenR emails a one-time code to the account at every login. /admin → OpenR → Log in posts the email and password (POST /me/current_session) and keeps the short-lived login token in memory for 10 minutes; /admin → OpenR → Enter the code… passes the code back (POST /me/current_session/mfa). The session is two cookies: _openr_access_sig, which opens doors and lasts about 15 minutes, and _openr_refresh_sig, which lasts about 6 months from the login.
  • Keeping it alive: every 30 seconds Pixel checks the session and refreshes it when its access has under 2 minutes left, or when a door needs it sooner. A refresh is POST /me/current_session/refresh with an empty body and only the refresh cookie; the reply sets both cookies again and gives their expiry as Unix times (access_exp, refresh_exp), which time the next refresh. A reply without the times falls back to the cookies' own Max-Age / Expires; with neither, the access counts as expired, so the next check refreshes and learns them. Refreshing doesn't extend the session: about 6 months after the login an admin logs in again, and /admin → OpenR warns in the last two weeks. One refresh at a time; everyone else waits for it. A refused refresh (401) or an expired session ends it: Pixel forgets it, /admin says so, and Sentry gets an error saying an admin must log in again. An outage keeps the session and keeps trying, reported to Sentry once.
  • Across restarts: the session is saved to PIXEL_DATA_DIR/openr-session.json (atomic, deleted when the session ends, and owner-only on a normal filesystem; the Azure Files mount sets its own modes, so there the share's access control is the fence) so a deploy doesn't need a new code. It holds the cookies, never the email or password. An unreadable file means logged out. The share mirrors (just pull) never copy it.
  • Opening fails closed: one call per opening, with one 10-second deadline that covers a refresh too. No retries, no queueing, one opening at a time per door, and anything but a 2xx counts as not opened. A refresh that outlives the deadline still finishes in the background, because OpenR may already have replaced the refresh cookie. /admin → Doors → Switch door control off… stops /openr open and the gate as well as Home Assistant's doors.
  • Door status: /admin → OpenR reads each configured door and the gate live (GET /buildings/<building>/doors/<door>/status, under the same deadline): online or offline, and up to three of OpenR's error messages, cleaned and escaped. Everything else in the answer is dropped. An expired access answers 401 ("Bearer Token Expired"), which triggers a refresh for next time. openr.status_failed logs a status that couldn't be read.
  • What OpenR sees: Pixel's account and which door, never who asked. Who opened what is in Pixel's own logs (openr.door_opened, parking.gate_opened).
  • Nothing secret is logged: not the email, password, code, login token or cookies, and no response body. Failures log their kind and HTTP status only. Every call has a time limit, a size cap on answers and refuses redirects, so the cookies never go to another host.
  • Speaking like the dashboard: requests carry the dashboard's yy-app-version header (APP_VERSION in client.ts) and its fixed x-csrf-token. If OpenR starts refusing Pixel after a dashboard update, check the version first.
  • Finding the IDs: just openr-doors dev|prod (scripts/openr-doors.ts) logs in with that env's account, lists GET /buildings, each building's GET /buildings/<id>/doors and the account's own GET /me/doors (keeping only IDs, names and buildings, and cleaning names before printing), suggests the settings and logs out. A list the account may not see (401, 403, 404) is noted and the rest carries on. Pixel itself never lists anything.
  • Tests mock fetch at the client boundary and stand in for the client in the connection's tests, never a live OpenR.

Services and ports ​

Service / portPurposeImplementation
accessConfigAdmin and member listsLoads and validates the two YAML files at startup (ConfigTierSource). Capability grant/revoke also DMs the person via DirectMessenger (core/ports/direct-message.ts)
spaceStatusIs the space open?services/space-status.ts. checkNow() asks SPACEAPI_URL (SpaceAPI v0.13) live, with a 5 s timeout, a size cap and validation. Overlapping checks share one request. Background polling every 30 s tracks when the state changed, and onChange fires on open↔closed flips, with how long the previous state lasted. The last state and its time are saved to space.state and restored on startup (below). After 10 consecutive failures (about 5 minutes) it reports once, and it logs when SpaceAPI recovers
calendarUpcoming eventscore/calendar.ts. A neutral CalendarEvent and a CalendarSource that an adapter plugs in once it's ready (like announcement publishers), so before that /events says the calendar isn't available. Nothing is cached: every call asks the source, so a renamed or rescheduled event shows up straight away. If the source fails that's an error (CalendarUnavailableError), never an out-of-date list; it's logged, and reported to Sentry once per outage rather than on every command
wiki/wiki search and top pagesservices/wiki.ts. Reads Pixelbar's MediaWiki (WIKI_API_URL) anonymously: full-text search (generator=search, articles only), title prefixes for autocomplete (list=prefixsearch, 2 s limit), and the pages the wiki's Main Page links to as the "top" pages (the wiki has no view counts), cached for 15 minutes and kept while the wiki is down. Every request has a 5 s limit, refuses redirects, caps the answer at 256 KB and is validated; only titles and URLs on the wiki's own origin are kept. Errors carry MediaWiki's error code, never what was searched for. Nothing is fetched until someone uses /wiki
infoContent/info topicsservices/info-content.ts. Loads and validates the markdown files in content/info/ at startup (see below). Invalid content stops startup, and CI loads the real content too

Pixelbar's SpaceAPI response has state.open but no lastchange, so Pixel records when it saw each change and remembers it in space.state (YAML, in PIXEL_DATA_DIR, default data/). It's written only when the state changes, atomically (temp file and rename).

On startup the saved state is trusted only once a live reading agrees with it:

  • Same state: the saved "since" is kept, so a restart doesn't lose it.
  • Different state: the space changed while Pixel was down, so when is unknowable. "Since" is cleared and no change event fires, so a restart never announces a stale change. A live post that still says "open" is corrected at startup (see Announcing changes).
  • Missing, malformed or unreadable file: Pixel starts fresh and logs a warning. Unlike the access lists this is not fail-closed: the file only affects the "open for 2h" text, so it must never stop the bot. A saved time in the future (clock change or hand-edit) is ignored.
  • Can't write: checks carry on, and the failure is logged, and reported to Sentry once.

The spaceapi.io directory was considered as a source of "last changed", but its lastSeen is when the directory last reached the endpoint (about every minute), not when the state changed. It also keeps no history.

Phase 1 features ​

FeatureCommands / jobsTierBuiltNotes
help/helpguest✅Lists only the commands the caller can use, and links the privacy notice
ping/pingguest✅Version
whoami/whoamiguest✅Private reply: your ID and tier
admin/adminadmin✅Private. A panel (ADR 0023, the components guide): Pixel's status (version, where, uptime, access-list counts with no IDs, Discord roles, Home Assistant, OpenR, doors), Reload the access lists, and a menu of sections: People (look someone up by ID, change their level, grant and revoke capabilities with a reason, sync their roles; revoking a capability their level gives records it in their entry's revoked: list, for them alone, until granting it lifts it, ADR 0024), Capabilities (holder counts, and what the friend and member levels get), Discord roles (sync everyone), Announcements, Closing time, Doors (the emergency switch, which asks first), OpenR (log in, the emailed code, log out) and Parking. Every change is an admin.* or parking-admin.* action with the admin floor, and runs the operation the old subcommand ran (features/admin/panel.ts).
status/statusguest✅Public. A "Checking…" box, then a live answer: open (green) or closed (red), and how long (if Pixel saw the change)
statusbackground: announce changesn/a✅Posts to the live and/or timeline channels (see below)
closing-time/closing-timemember✅Private. Posts the closing-time reminder. Off when no channel is configured
closing-timeafter space-closedn/a✅Same send path as the command, after a confirmed close
events/eventsguest✅Public. What's on now, then the next events (5 at most), with when, how soon, where and how often it repeats
info/info [topic]guest✅Public. Short answers about Pixelbar from content/info/. With no topic it lists them in a private reply
home/ha [device:]friend✅Private. A panel (the components guide): the devices you may see (by each device's tier floor), grouped by kind with their live state, and a menu to pick one; a device shows its live state, when it last changed, a few details for its kind, and a button for each action you may take (ha.list, ha.device, ha.act). device: autocompletes with only what you may see and goes straight to it. Doors are member-only and only lock and unlock. See "Changing devices"
openr/openr open door:member + openr✅Private. Opens one of the doors in OPENR_DOORS through OpenR (never the parking gate). door autocompletes with them, and the name is checked again. Refused while /admin → Doors is off. See "OpenR"
parking/park [for:]member + parking✅Private. A panel: how long (a spot free for the whole time, then the gate, then where to park), who's where, in Free, Taken and Closed groups (names escaped), and for someone parked: open the gate, leave, stay longer (never past the spot's close or the reset) and a spot someone else is in. Its actions (park.*) have the same access. for: books straight away. See "Parking"
schedules/schedulemember + schedule-posts✅Private. Opens a panel listing every scheduled post; buttons, menus and forms make, show, edit, pause, resume and delete them (only the creator changes one). Its actions (schedule.*) have the same access, except schedule.attend, the attendance poll buttons, which anyone (guest) may use. See "Scheduled posts"
spacestate/spacestate set state:member + spacestate✅Private. state is open or close. Tells the space state API that Pixelbar is open or closed (close is sent as closed). Says so when it isn't set up, and that nothing changed when the API doesn't answer SUCCESS. See "Setting the space state"
feedback/feedback message:guest✅Private. Sends a message (3–1000 characters) to Sentry as user feedback, with the sender's Discord name and ID. The reply shows the message back as a code span. At most a few per person, then one every ten minutes. Says so if Sentry isn't set up

The Home Assistant commands ​

The list and a device's panel only read. /ha has a floor of friend (the lowest any device can have, and a guest never passes), and each device's own floor is checked by the shared rule in core/home-access.ts (see identity-and-access.md), both when listing and when completing, so people see only what they may use. The commands don't need a capability: reading takes the tier floor alone.

  • Live, no cache: every list and every status asks Home Assistant fresh, and only about the devices it will show. If Home Assistant can't be reached the reply says so plainly. With Home Assistant not set up, /ha says so, and there is nothing to autocomplete.
  • Everything from Home Assistant is untrusted text: states, units and attributes are shown as code spans (so no formatting, links or mentions), with control characters removed and length limited. Device names come from the devices file, not from Home Assistant. Long readings are rounded to two decimals.
  • States that aren't readings show as themselves, never as "off": unavailable (⚠️), unknown (❔), a device Home Assistant doesn't have (❔), and a kind's warning states such as a jammed lock (⚠️).
  • What a kind shows is part of the kind (attributes and warnStates in core/home-kinds/): brightness for lights, the type and battery for sensors. A new kind says what's worth showing, and nothing else changes.
  • Size: one embed field per kind, cut at the field limit with "…and N more."
  • Logging: a refused status is logged with the real reason (unknown-device or tier) and the device name when it exists, never what was typed. Looking at a door is logged with who and which.

Changing devices: the buttons ​

A device's buttons in /ha are the one place Pixel changes something in the real world, so they're strict and honest. Each button is one of the kind's actions (on, off, toggle for lights and switches; lock, unlock for doors), and the kind's catalogue decides the service call: on on a light is light.turn_on on that entity, and nothing outside the catalogue can be sent. People can't pass a service name or any data. Pixel never offers or runs unlatch (open / lock.open).

  • Checked again at run time: the device, the action and the person are checked against the devices file and the shared rule in core/home-access.ts when the button is pressed (ha.act), whatever the panel offered. Anything not allowed gets the same generic HOME_DENIED answer as an unknown device, without calling Home Assistant. The log has the real reason (unknown-device, tier, capability or action) and never what was typed.
  • The panel uses the same rule: a device's panel offers a button only for the actions you may take on it (at most four, labelled with the action's name), greys out one whose state it's already in (a toggle never is), and offers none while the kind is switched off, saying so. A button is a convenience, never validation. The result is shown above the device's fresh state, and a refusal is a private reply that leaves the panel alone.
  • Check, act, confirm (features/home/control.ts):
    1. Read the state fresh. If the device is already there, say so and send nothing. If it's unavailable or unknown, or a lock is already on its way (unlocking), send nothing.
    2. Make exactly one call. If Home Assistant can't be reached to read the state, nothing is sent. There are no retries and nothing is queued, so a late action can never happen. If the call itself times out or the connection drops, Pixel says it can't confirm and never resends.
    3. Watch for the end state every half second for eight seconds, and report what really happened: done, still working (a lock that is unlocking), nothing changed, it didn't work (jammed, or the device went unavailable), can't confirm (contact lost), or Home Assistant refused. A toggle counts as done when the state changed.
  • One at a time per device, with a three-second cool-down after an action, so a double click sends one. A second run says someone is already changing it, or that it was only just changed.
  • Audit: every action that ran logs home.action with who (from the dispatcher), the device, kind, action, state before and after (cut to 40 characters), outcome and duration, and leaves a Sentry breadcrumb. Refusals are logged as home.action_denied.
  • Replies are private, and show Home Assistant's states as code spans. Home Assistant's own error text is never shown, only a fixed summary.
  • Doors go through the same rule and the same check-act-confirm path, with three extras:
    • Member-only. The door kind has a tier floor of member (minTier on the kind), so the devices file can't open a door to friends: the loader refuses it. Acting still needs ha-doors or ha-admin.
    • Lock and unlock only. Pixel never offers unlatch (open / lock.open): the door kind only has lock and unlock, so no button can run it and a devices file that lists open is refused. A door's buttons only ever appear on the private panel of someone who may use them.
    • An emergency switch. /admin → Doors → Switch door control off… (admins only) stops every door action from /ha at once, for everyone, and hides the door buttons; /admin → Doors → Switch door control on brings them back. It's on by default and remembered across restarts in data/home-switches.state (services/kind-switch.ts). If that file exists but can't be read, doors start off until an admin switches them on. /admin shows the state, and each switch is logged (home.kind_switched, with the admin's ID, name and handle) and left as a Sentry breadcrumb.
    • Every door action is logged like any other (home.action with who, door, action, before, after and outcome). There is no confirmation step, rate limit or public notice: logging is enough for now, and they can be added later if needed.

Setting the space state ​

/spacestate set state:open|close tells the space's state API that Pixelbar is open or closed. It is a command for members who hold the spacestate capability: both the tier and the capability must hold, only the dispatcher checks them, and an admin without the capability is refused.

  • The call. services/space-state-api.ts sends POST SPACESTATE_API_URL?user=…&token=…&state=open|closed and treats the answer SUCCESS as done. Anything else (another body, an HTTP error, a timeout, a body that is too large) is a failure. The option is close, but the API wants closed, so the feature maps it. The handler checks the value again whatever the platform let through.
  • The URL is a secret. The user and token travel in the query string, so the URL is never logged or put in an error message, redirects are refused so it can't be sent to another host, and the response body is never echoed. A failed request carries no cause, because a network error can hold the URL.
  • Who did it. Each change is logged (spacestate.set, with who by actorLogFields) and left as a Sentry breadcrumb. A failure is logged and reported to Sentry as spacestate, and the person is told privately that nothing changed.
  • No announcement from here. It only asks the API to change the state. /status and the open/closed announcements still read the state back from SpaceAPI, so they follow when the change shows up there and are confirmed the usual way (two polls).
  • Off when not configured. SPACESTATE_API_URL, SPACESTATE_USER and SPACESTATE_TOKEN are set together or not at all, like Home Assistant. With none set the command still exists and says it isn't set up.

Scheduled posts ​

/schedule posts messages, polls and attendance polls in a channel at set times, once or on a repeat. Discord has no scheduled messages for bots, so Pixel keeps the schedules and posts them itself.

  • Who: members (and admins) holding the schedule-posts capability, granted with /admin → People → Grant capabilities…. A schedule only keeps posting while whoever made it still has that access: when it's due, Pixel checks again and pauses it otherwise.
  • Where: a text or announcement channel picked on the form. People can only schedule into channels where they can post, native polls only where they can create polls, and pings only where they can ping everyone (from the permissions Discord reports for the person in that channel). Pixel also checks its own permissions there before saving, and again when posting.
  • What: a plain message (up to 2000 characters of markdown; pings are off unless they're turned on in the draft), or a native Discord poll (a question, 2–10 answers of up to 55 characters, open for 1 hour to 2 weeks, single or multiple choice). Discord counts the votes, shows them live and announces the result when it closes, so Pixel stores nothing about votes on those. Or an attendance poll (below), which Pixel counts itself. User-set text can include tokens ({{date}}, {{date+2}}, and the rest of the date family); they are stored as written and filled from the scheduled occurrence when the post goes out. A schedule's panel and the draft show the same fill as of the next occurrence (message text; poll question and answers). Discord's length limits apply to the filled text, so create and edit refuse a post that its tokens would push over a limit at any of its next 12 occurrences; if one still overflows when it goes out (say a token that grew), Pixel cuts it to the limit and logs schedule.clipped (never the text) instead of failing at every occurrence. See Interpolator above.
  • One command, then a panel. /schedule has no subcommands or options. It opens a private panel, and everything else is buttons, select menus and forms on it (ADR 0023, the components guide). Each one runs one of the feature's actions (schedule.open, schedule.save, schedule.pause, …), which have the same access as the command and go through Dispatcher.act.
    • The list shows every schedule (soonest first, paused and finished last), 25 to a page, in a select menu. Picking one shows its panel: what it will post next (filled as of the next occurrence), its next three times, and, for the person who made it only (createdBy.ref, by immutable ID), Edit, Pause or Resume, and Delete (which asks first). Someone else's schedule is read-only, and every action on one checks ownership again and refuses with a generic message (schedule.edit_refused in the log).
    • A form, then a draft. New message, New poll and Edit open a form: a channel picker, when, a repeat (radio: once, weekly, every 2 weeks, monthly, every 2 months), and the text (or the question and answers, one per line). Submitting doesn't save anything: it fills a draft (features/schedules/drafts.ts, in memory, the creator's only, 30 minutes from its last change, at most 100) and shows it with the time Pixel understood and exactly what will be posted. Settings that only apply sometimes are on the draft, shown only when they apply: the days for a weekly or fortnightly repeat (a multi-select, starting on the first post's weekday), how long a poll stays open, several answers, and pings (only offered where the person could ping everyone, or to turn them off). Anything wrong (a time Pixel can't read, a channel the person can't post in, too long once tokens are filled) is said on the draft, with Schedule disabled and Edit details reopening the form with what was typed. Schedule (or Save changes) checks it all again, including the channel, and saves.
    • Editing opens the same form filled in, with when as the next post's time. Leaving when, the repeat and the days alone keeps the timing; changing any of them counts from the edit (lastRunAt moves to now, as on resume), so an occurrence of the new timing that fell before the edit never posts late. The channel can be changed, and is checked again for what the person may do there now. An edit that would leave nothing to post (a repeat turned into a one-off whose start has passed) is refused, and so is resuming a one-off whose time passed while paused.
    • Every deploy drops open panels and drafts; the person runs /schedule again.
  • When: Discord has no date picker for bots (no slash option type, no modal component, no Components v2 picker). Autocomplete would force picking from a short list of dates, so when is free-form text. Pixel parses it (core/when.ts: wed 19:00, wed 1900, 19u30, 14 oct 19:00, tomorrow 9am, and similar) into one future wall-clock moment in PIXEL_TIMEZONE and shows that time on the draft. Compact 24h (1900) and Dutch 19u are times, not guessed by a date library. A day without a time is 19:00; a time without a day is the next clock hit. now is the next whole minute, and now+<duration> (now+30m, now + 2h30, now+1 day, any duration core/duration.ts reads) is that long from now, to the minute. Where a time is read after another one (Until when opening a spot in /admin → Parking, after From), now still means the real now. The repeat is once, weekly, fortnightly (on one or more days, chosen on the draft), monthly or every 2 months (on the start's day of the month, or the month's last day). Times stay on the wall clock through the summer/winter clock change (core/recurrence.ts).
  • Poll reminders: a poll can have a reminder: a channel and a time of day, set with Reminders on its draft (a form with a time and a channel picker; an empty time means none). Pixel posts a message in that channel every day at that time (PIXEL_TIMEZONE) while the poll is open, with the question (escaped), when it closes and a link to the poll. It never pings. A channel picked on the form must be one the person can post in; Pixel checks its own permissions there on every save, and an edit that leaves the reminder alone keeps it without checking the person again. When a poll goes out, the runner saves lastPoll (the filled question, the link the poster returns, the occurrence and when Discord closes it); reminders are the daily times after that occurrence and before it closes, so a reminder time already past on the day the poll goes out waits for the next day. They follow the post rules: once each (lastReminderAt), skipped over an hour late, none while paused, and the schedule pauses if its creator lost access. A one-off poll with a reminder stays in the file until its poll closes, then goes (unless it still has an occurrence to post, as a repeat edited into a one-off can). A change applies to the poll that's open. Only polls take a reminder.
  • Attendance polls (ADR 0025, #199): for "who's coming", where a native poll's winner is the wrong message. A question and 1 to 5 answers (up to 40 characters each), one per line, open for 1 hour to 2 weeks. On the draft, the creator picks which answers count as coming (a multi-select, starting with the first) and, with Results, a results channel (a form: the channel, an optional time of day for the totals so far, and the two texts). Pixel posts a message with a grey button per answer (features/schedules/attendance.ts, drawn by adapters/discord/attendance-buttons.ts). Anyone may answer (schedule.attend, guest, through Dispatcher.act for whoever clicked): one answer each, another one switches, the same one again takes it back. The click gets a private answer, and Pixel edits the message with each answer's total and who picked it (one edit at a time per poll; clicks meanwhile fold into one more). When it closes, the buttons go and the last totals stay (if that edit fails, Pixel tries again on every check for a day, closeShown in the file). While the file is broken, a click says attendance polls aren't working rather than that the poll has gone. With a results channel, Pixel posts the totals so far at the status time on the day it closes (a poll closing at midnight closes the day before; none if that time isn't while it's open) and the closing totals when it closes. Both texts are the creator's (defaults 📊 So far, {{count}} coming: {{names}} and 🗳️ The poll has closed. {{count}} coming: {{names}}), filled at post time with {{count}}, {{names}} (escaped, "Ada, Sam and Kim", or "nobody"), {{question}}, {{link}} and the date tokens (as of the poll's occurrence), cut to 2000 characters, never pinging. They follow the reminder's rules: once each, skipped over an hour late, and not posted once the creator lost access. The results settings are copied onto the poll when it's posted, so an edit or a delete of the schedule doesn't change one that's already out. Reminders work as for a native poll. Votes are kept in data/attendance.yaml (services/attendance.ts): each poll as posted (its ID, schedule, creator, channel, message, question, answers, results settings, when it closes) and each vote (the voter's discord:<id>, their display name when they voted, the answer). At most 100 polls and 500 votes each; closed polls go a week after they close. If the file is invalid, no vote is taken and no attendance poll is posted (schedule.failed), and it's never overwritten until it's fixed. Deleting it loses the votes of open polls, and their buttons then say the poll isn't running. Logs: attendance.voted (poll, and whether they answered or took it back, never which), attendance.results_posted, attendance.results_skipped, attendance.results_no_access, attendance.refresh_failed, attendance.failed.
  • Running: the feature checks every 30 seconds (features/schedules/runner.ts). A due post goes out once; if Pixel was down, it still posts up to an hour late, skips anything later, and only the latest of several missed occurrences can go out. Every occurrence is recorded as handled whether it posted, was skipped or failed, and remembered in memory too, so nothing ever posts twice. Failures are logged and reported, never retried. Each schedule is read again just before it's handled and after every wait, so one deleted, paused or edited while another was posting goes out as it is now, or not at all. A one-off is removed once it's handled.
  • Storage: data/schedules.yaml (services/schedules.ts), written atomically, at most 50 schedules, and data/attendance.yaml for attendance polls' votes (above). Unlike the rest of data/, schedules.yaml is not safe to delete. If it's invalid, nothing is posted or changed (and it's never overwritten) until it's fixed; Pixel logs and reports why.
  • Posting goes through the core ChannelPosts, which the Discord adapter plugs a poster into once it's connected, like the calendar. Every create, edit, pause, resume, delete and post is logged with who and which schedule, never the message body.

Parking ​

Pixel assigns parking spots at the gate (#150): the spots and their opening times (#151), /park and the bookings (#152), reminders (#153) and the gate itself (#154). ADR 0021 covers the files.

  • Spots (data/parking-spots.yaml, services/parking-spots.ts): a name (up to 32 characters with single spaces and at least one letter or digit, such as by the bins, A1 or Smith & Co., no. 3; punctuation such as & ' , . ( ) # : is fine, but not line breaks or Discord's formatting characters \ * _ ~ | < > [ ] and the backtick; kept as written but matched ignoring case; several are typed separated by commas, and a name that contains commas is matched whole), an optional description, and whether it's permanent (Pixelbar's own: open every day until the reset) or borrowed (open only in a window an admin sets). At most 100: /admin → Parking shows them 25 a page (a menu holds 25), and /park lists as many as fit and says how many more there are. The same file holds the settings: remindMinutes (15), graceMinutes (5), resetAt (23:59, in PIXEL_TIMEZONE) and the durations offered (30m, 1h, 2h, 4h, rest of day). If it's invalid, parking is off, nothing can change, and it's never overwritten until fixed.
  • Opening times (data/parking-windows.yaml, services/parking-windows.ts): windows open borrowed spots, blocks close permanent ones, each with from, until (instants) and who set it. Open… in /admin → Parking sets a borrowed spot's window for a day (replacing any that day; from defaults to now and until to the reset, both read by core/when.ts) or lifts a permanent spot's closure. close ends a borrowed spot's window for today or closes a permanent spot until a time (default the reset). Nothing runs past the reset on the day it starts, and entries are pruned once they end. Moving the reset earlier with Settings in /admin → Parking clips what's already set to it (clipToReset). If the file is invalid, borrowed spots are closed until the next open or close, which moves it aside to parking-windows.yaml.broken. Removing a spot or changing its kind drops entries that no longer apply, and so does startup (for hand edits).
  • Time (services/parking-time.ts): the next reset after an instant, and the durations people may type. Durations are read by the shared core/duration.ts (30m, 90 min, an hour, 2h30, 1 day, 2 weeks; also what /schedule uses for a poll's duration), with parking adding only rest of day and a one-day limit. The reset is a time of day read by parseTimeOfDay in core/when.ts, the same reader as /schedule's when (23:59, 2359, 11:59pm), and saved as HH:MM. openUntil (in the windows store) says until when a spot stays open, which /park uses.
  • One command, then a panel. /park opens a private panel (the components guide): when you're not parked, a button per duration in the settings (more than five become a select; ones that don't fit now are disabled), Another length… (a form) and, when no duration fits, the longest stay that would; when you are, Open the gate, Leave, Stay … longer (the durations that fit before the spot closes, at most four, plus the longest) and Someone's in my spot. Every panel lists who's where, in separate Free, Taken and Closed groups, free first. Each button runs one of the feature's actions (park.book, park.gate, park.extend, park.leave, park.taken, park.taken-confirm, …) with the command's access, through Dispatcher.act. Problems (a length it can't read, nothing free, too long for the reset) are shown on the panel. /park for: books straight away, as before.
  • Who: /park needs the member tier and the parking capability, granted with /admin → People → Grant capabilities…. Every reply is private: who is parked where is personal data.
  • Bookings (data/parking-bookings.yaml, services/parking-bookings.ts): the spot, who (ref and name), since, until (the end they asked for, an estimate) and remindedAt (#153). One per person and one per spot. A booking is deleted when it ends, so there's no history. If the file is invalid, no one can park (a spot could be given twice) and it's never overwritten.
  • Picking (services/parking-allocate.ts, plain functions): a spot fits if nobody's in it and it's open from now until the requested end (a permanent spot with no closure in that time, or a borrowed one with a window covering it). Nothing can end after the next reset, and rest of day means until then. Permanent spots first (file order), then borrowed ones by tightest fit (closing soonest after the end), then file order. Picking and saving are one synchronous claim, so two people at once can't get the same spot. If nothing fits, the gate stays shut and the reply names the longest stay that would.
  • The gate is a core port (core/ports/gate.ts): open({ principal, spot }) answers opened, not-connected, off or failed. The booking is saved first, then the gate is asked once (never retried); if it fails, the booking stays and Open the gate on the panel (or /park again) asks once more. The gate only opens for someone who holds a booking. With OpenR set up and OPENR_GATE_DOOR set, the gate is that OpenR door (openrGate in features/openr/): not-connected while Pixel is logged out of OpenR, off while /admin → Doors is off. Without them, notConnectedGate never opens anything, and either way the reply says to open it the usual way when it can't.
  • The tick (every 30 seconds, and once at startup in case Pixel was down; never two at once, never throws): ends every booking that started before the latest reset (resetAt); ends a booking graceMinutes after its end if it wasn't left or extended; and DMs a reminder remindMinutes before the end. The reminder says the end, how much longer the spot allows (with /park to stay longer, since DMs have no buttons) (or says it can't be extended, because the spot closes or the reset comes), and when Pixel will end it. remindedAt is saved before the DM is sent, so a restart never repeats it (a missed reminder beats a repeated one); a stay too short for a reminder gets none. Endings DM the person too (time up, the reset, or an admin's free / evict).
  • Extend: a Stay … longer button adds to the end (or to now, once that has passed), never past the spot's close or the reset; if it would, Pixel says by how much it can. It clears remindedAt so a new reminder is sent, unless that reminder would already be due.
  • A taken spot: Someone's in my spot is for someone whose spot has another car in it. It first shows what will happen: another spot free for the rest of their time (picked the same way as /park, never their own), with Move to …, or, if none is free, End my booking and report it. Confirming moves the booking with one synchronous move, like claim. If nothing is free (or the offered spot went meanwhile), the booking ends and they're asked to leave; the reply says it was reported to the board only if the post to the parking channel went through. Either way the taken spot gets a closure until the reset with reason: taken, so nobody else is sent there. Such a closure applies to borrowed spots too (it wins over their window), /admin → Parking shows "(reported taken)", and opening the spot lifts it.
  • Assigning a spot: Give it to someone… on a spot in /admin → Parking (a form with a person picker and how long) is for when someone parked in the wrong spot: the board finds who is there and gives it to them. It checks first and changes nothing if any check fails: the spot must be open (a "reported taken" closure doesn't count, since this clears it; an admin's closure does), for (default: until the spot closes) must fit, and nobody else may still hold the spot, unless the admin goes ahead when asked (which ends their booking). A button can't carry a picked person, so Go ahead runs parking-admin.assign-confirm with the person's ID and name from the form, held by Pixel. Pixel never moves the holder: the person booked there gets a new spot themselves with Someone's in my spot, which is how the spot came to be reported. Then it lifts the closure and makes one assign save: the person's other booking ends, an evicted holder's booking ends, and the person gets a fresh booking from now (reminder re-armed). If that save fails, the closure is put back, so a failure leaves everything as it was. An invalid bookings file is refused before anything changes. The person doesn't need the parking capability: an admin is recording where a car is, not letting them do anything. Everyone affected is DMed (assigned, or the admin ending), and it's posted and counted as assigned / evicted.
  • Activity (services/parking-activity.ts): every parking change goes to the parking channel (DISCORD_PARKING_CHANNEL_ID, off when unset) through the core ChannelPosts, with mentions off and names escaped: booking, nothing free, leaving, extending, Pixel ending a booking (time up or reset), a move or a report from Someone's in my spot, an admin's assign, free or evict, and every /admin → Parking change. A failed post is logged as parking.channel_failed (never the text) and changes nothing. Each is also counted in Sentry as the metric pixel.parking, with event (booked, none-free, left, extended, ended, moved, taken, assigned, freed, evicted, admin) and the spot (or the admin action, or the reason an ending had) as attributes, never a person, through the reporter's count. That's beside pixel.command.
  • DMs go through services/parking-notices.ts (the text, and a sender that never throws) and the core DirectMessages. Text is Pixel's own; spot names are validated; mentions are off. A failed DM is logged as parking.dm_failed (who, which spot, why; never the text) and changes nothing else.
  • Admin changes: Parking in /admin is a panel (features/admin/parking-panel.ts): an overview with menus to manage one spot or open and close several, and a panel per spot. Each control runs a parking-admin.* action with the admin floor, and the actions run the same operations the subcommands did (parkingOperations in features/admin/parking.ts, which aren't commands themselves). Free the spot and Remove ask first. Open, close, edit and remove work out the spots and opening times they'd leave; if that cuts into a booking on the spots they touch, Pixel shows whose and asks, and Go ahead runs it again with evict, which ends it.
  • Logs: parking.spot_added, spot_edited, spot_removed, opened, closed and settings_changed, with the admin and the spots (never a description). parking.booked, left, none_free, gate_opened / gate_failed with the person, the spot and the end; parking.freed and evicted with the admin and the person (parkedUser); parking.reset and parking.auto_ended for bookings the reset or the grace period ended; parking.extended, parking.reminded, parking.dm_sent and parking.dm_failed. Never the duration typed or a DM's text.

The events list ​

/events reads the calendar and shows:

  • What's on now first (🟢, with when it ends, or how long it's been going if it has no end time), then upcoming events soonest first, at most 5, plus "…and N more." if there are others.
  • Each event has a linked title, when it is, how soon (in 25m, in 3h 20m, in 2 days), where, and 🔁 how often it repeats.
  • Times are plain text in one time zone (PIXEL_TIMEZONE, default Europe/Amsterdam) with the zone shown, like Sun 4 Oct, 20:00–23:00 CEST. That reads the same on every platform and is unambiguous. The end time is shown only when it ends the same day. A per-reader local time, using Discord's timestamp markup, would need a small neutral mechanism in the core, and is worth adding if members are often in other time zones.
  • Anything that has already ended is dropped, even if Discord still lists it as running.
  • Event titles, locations and repeat text are written by whoever made the event, so they are escaped: they can't add formatting, fake a link, or inject a mention marker.
  • The reply is public, like /status. It asks Discord live on every use, which takes about 200 ms, so it needs no "Checking…" box. If Discord is slow, the adapter defers the reply itself.
  • If Discord can't be reached, the reply is a friendly error. It never shows an earlier list.

The info topics ​

/info answers common questions from markdown files, one per topic, in content/info/ (found through PIXEL_CONTENT_DIR):

markdown
---
title: Becoming a member
summary: Member and Friend memberships, what they cost and how to join
order: 30        # optional; lower comes first, default 100
---
The text of the answer, in markdown…
  • The file name is the topic's ID: lowercase letters, digits and single dashes, up to 32 characters (membership.md). It's what people pick in /info topic:.
  • Short answers that link to the canonical page. Pixelbar's website is the source of truth for prices, rules and opening times, so each topic is a few lines plus a link. That way the bot doesn't become a second copy to keep up to date.
  • Everything is checked at startup, and Pixel refuses to start if anything is wrong, listing every problem at once: a bad file name, a missing title or summary, unknown keys, empty text, text over 4,000 characters, or more than 25 topics (Discord's limit for choices). CI loads the real content/info/ folder in a test, so a broken edit fails the PR.
  • /info with no topic lists them all with their summaries, in a private reply, since the menu is only for the person asking. The topic choices are built from the files, so they're registered with Discord: editing a topic's text goes live on the next deploy, and adding, removing or renaming a topic also needs the commands re-registered (just register dev or just register prod; just deploy dev does the Dev guild).
  • Content is baked into the Docker image (COPY content ./content), so changing it means a deploy.
  • The content is public. This repository is public, so never put secrets (wifi passwords, door codes) or personal data in content/. That's why there are no member-only topics yet.
  • The text is shown as written, since it comes from this reviewed repository. (Text written by other people, like event titles, is always escaped.)
  • Placeholders: topic text can use {{name}}, filled in when the topics are loaded, so the same reviewed text works in every environment. Today there's one, {{announcements-channel}}, which becomes a clickable channel link in Discord when DISCORD_ANNOUNCEMENTS_CHANNEL_ID is set, and the plain words "the announcements channel" otherwise. An unknown placeholder or a stray {{ stops startup (and fails CI), so a typo can't show up in a reply. The length limit applies to the text after filling in. Values are Discord-flavoured for now, because Discord is the only platform. This is a different system from the post-time interpolator used for user-set posts (/schedule, closing-time): reviewed /info text fails closed at load, user-set tokens fill when posted.
  • Opening hours come from the board: "Normally Wednesday evening until late, check the announcements channel and vote in the weekly poll so someone knows you're interested!" Pixelbar's own pages disagree about the times, so /info doesn't state fixed hours.

Announcing changes ​

When the space opens or closes, the status feature announces it through the announcer (features/status/announce.ts):

  • Never on startup. The status service only reports changes it saw, so starting up, or a change while Pixel was down, announces nothing.
  • Only once it holds. A change is announced once the new state has been seen on 2 polls in a row (READINGS_TO_CONFIRM), so about 30–60 s after it happens. If it flips back before that, nothing is posted, so flicking the switch doesn't flood the channel.
  • If SpaceAPI is unreachable while confirming, Pixel keeps trying each interval for up to about 5 minutes, then gives up rather than guessing.
  • At most once. A change is never announced twice, even if every publisher failed.
  • At startup, stale posts are corrected, not re-announced. Each publisher gets the current state (Publisher.reconcile) once SpaceAPI answers. The live style uses it to turn a leftover "open" post into "closed" (without a closing time, since Pixel didn't see it) when the space closed while Pixel was down. It never posts anything new. Mastodon does not post on reconcile.

After a confirmed close, the status feature also runs the closing-time send path (features/closing-time). That is the same function /closing-time uses: it announces closing.time through the announcer, or no-ops if posting is off (logged, never fails the space-close). Nothing is posted on reconcile or on open. /closing-time is member (it posts to a configured channel, often members-only) and does not need a capability.

The message body is a file (PIXEL_CLOSING_TIME_FILE, default data/closing-time.md), not an env var: it is typically a multi-line checklist, which is awkward in .env and Azure settings, and it is not a secret. Admins set it with /admin → Closing time → Change the message… (a modal that prefills the saved message). The write is atomic (temp file + rename) into PIXEL_DATA_DIR, which on Azure is the Files share at /app/persist/data — that is the persist, not a later sync. A missing or empty file uses a built-in English default. Do not put door codes, wifi passwords or personal data in it. Tokens ({{date}} and the rest of the date family) fill in when the message is posted, same interpolator as /schedule. Discord posts the body as written (operator markdown, like /schedule); the portable text field is escaped after interpolate. Mentions stay disabled. The built-in default is Pixel-written and has no tokens.

/schedule is member-only and also needs the schedule-posts capability. Home Assistant already uses the same pattern (doors in /ha are member-only plus a capability).

Observability ​

  • Sentry (@sentry/node) is initialised in src/instrument.ts, which is loaded with --import before the app. If no DSN is set, it does nothing.
    • Each command scope carries the tags command, feature, platform and tier. The Sentry user is the stable platform ID (discord:<id>), so a user's issues can be traced over time, with their Discord handle as username and display name as name so people can recognise them.
    • The Discord ID is on every error that can be traced to someone. The dispatcher runs each command (and each autocomplete) inside ErrorReporter.withContext, which sets the user and tags on Sentry's isolation scope for the whole run, starts a command span, and counts a pixel.command metric. So an error reported anywhere beneath it, however deep (Home Assistant unreachable, the role mirror, a background report) carries who asked, as do breadcrumbs, traces and Sentry Logs from that moment. Two commands at once stay separate. A failure handling an interaction in the Discord adapter names the person too (captureBackground(error, source, actor)). Errors with no person behind them (SpaceAPI polling, startup, an uncaught exception outside a command) have no user. dataCollection.userInfo: false doesn't affect this: it only stops Sentry inferring IP addresses and the like. A test with a real Sentry client checks the ID is on the sent events.
    • Tracing is on (tracesSampleRate from SENTRY_TRACES_SAMPLE_RATE, default 1). Discord commands are not HTTP, so the command span is started in withContext. Outgoing HTTP (SpaceAPI, Home Assistant, Discord's API, Mastodon) is traced by the default HTTP and fetch integrations. Incoming /healthz and /readyz probes are ignored, and they are not counted as request sessions: Pixel's session is the process (processSessionIntegration, a default), so crash-free rate tracks the bot staying up, not Azure poking it.
    • Runtime metrics (nodeRuntimeMetricsIntegration) send CPU, memory, event-loop delay and uptime every 30 seconds.
    • Profiling (@sentry/profiling-node) attaches a CPU profile to sampled traces (profileLifecycle: "trace"). profileSessionSampleRate comes from SENTRY_PROFILE_SESSION_SAMPLE_RATE (default 1). The native integration is left out when the rate is 0.
    • Errors are not sampled (sampleRate is 1).
    • All dataCollection categories are off, including stack-frame local variables, and includeServerName is false. beforeSend, beforeSendSpan, beforeBreadcrumb and beforeSendLog scrub everything sent (observability/scrub.ts): anything that looks like a bot or Home Assistant token, and the interaction token in Discord's /interactions/<id>/<token> and /webhooks/<id>/<token> paths, which HTTP spans and breadcrumbs record for every reply (url.full, url.path) and which can post as the bot for 15 minutes. Spans are streamed (traceLifecycle: "stream", pinned), so every span passes through beforeSendSpan.
    • Releases are tagged with the git SHA (PIXEL_VERSION). Source maps upload from CD, the Release workflow, and just sentry-release. Those jobs fail closed if SENTRY_ORG, SENTRY_PROJECT, or SENTRY_AUTH_TOKEN is missing.
    • Source maps are applied by Sentry, not by Node. They are uploaded for the compiled files under /app/dist, and the image runs node without --enable-source-maps: that flag rewrites stack frames to src/*.ts before Sentry sees them, which matches none of the uploaded maps (and src/ isn't in the image), so Sentry would show no source. Sentry maps the dist/*.js frames back to TypeScript with source context. Stack traces in the console and the log files stay on dist/*.js lines.
    • environment is local, dev or prod.
  • Logs: everything is logged through the one pino logger (observability/logger.ts, no console), and each line goes to three places:
    • The console: JSON, or readable with pino-pretty when PIXEL_ENV=local.
    • A rotating file of JSON lines in PIXEL_LOG_DIR (default data/logs, empty turns it off): pixel.<date>.<n>.log with current.log pointing at the live one, a new file each day or at 20 MB, about two weeks kept (14 besides the live one, including files left by earlier runs), created readable only by the user running Pixel (mode 0600). That holds on a normal filesystem, such as a laptop; on Azure the log directory is on the Azure Files share, which ignores file modes (files there are 0664, and the init container opens the share to everyone in the container), so who can read the logs comes down to who can reach the Container App and the storage account. It's written by the pino-roll transport in a worker thread. If the directory can't be written, Pixel carries on with the console and Sentry and logs a warning (log.file_unavailable), so a read-only disk never stops the bot.
    • Sentry Logs: Sentry's pino integration (set up in observability/sentry-options.ts) sends info and above as Sentry Logs, whatever LOG_LEVEL is (debug stays in the console and the file), with each line's fields as attributes. They aren't turned into error events: real failures still reach Sentry as events through the ErrorReporter. Nothing is sent when no DSN is set.
    • Secrets: pino redacts token and authorization headers by name, and every line written (console and file) has anything shaped like a bot or Home Assistant token masked on the way out. Sentry's copy is scrubbed again in beforeSendLog.
    • Failures nobody caught: an unhandled promise rejection and an uncaught exception are logged too (process.unhandled_rejection, process.uncaught_exception), so they reach the file and Sentry as well as the console. Neither changes whether the process exits: a rejection is logged and the bot carries on (like Sentry's default), and an exception still ends the process.
  • Heartbeat (observability/heartbeat.ts): a dead bot can't report its own death, and Discord doesn't health-check bots, so Pixel checks in with a Sentry cron monitor (pixel-<env>, one per environment) every PIXEL_HEARTBEAT_MINUTES (default 5), and when Discord connects. A check-in is ok while the Discord gateway is connected and error while the process is up but disconnected. Sentry creates and updates the monitor from the first check-in (interval schedule, five minutes' margin so a deploy or restart doesn't count, one missed or failed check-in opens an issue, one good one resolves it). So a crash, a hang, a lost Discord connection or the host going down becomes a Sentry issue within about ten minutes. Off without a DSN, with PIXEL_HEARTBEAT_MINUTES=0, or when PIXEL_ENV=local (planHeartbeat decides, and startup logs heartbeat.off with the reason): laptops would otherwise share one pixel-local monitor that opens an issue every time someone stops just dev. A failing check-in is logged and never affects the bot.
  • Health: a small HTTP server exposes /healthz (process alive) and /readyz (Discord gateway connected).

Configuration ​

Environment variables are validated by config.ts (zod). Nothing else reads process.env. Access lists live in YAML files (see the identity doc).

VariableSecretNotes
PIXEL_ENVlocal, dev or prod (default local)
PIXEL_RUNTIMElocal or cloud (default local). Azure Container Apps sets cloud. /admin and the online post show it as Where
PIXEL_VERSIONSet by the image build (git SHA); the Sentry release
PIXEL_ADMINS_FILEDefault config/admins.yaml
PIXEL_MEMBERS_FILEDefault config/members.yaml. Pixel writes to it (and to <file>.bak and a temp file in the same folder), so the folder must be writable
PIXEL_DATA_DIRDefault data. Runtime state: schedules.yaml (must persist), attendance.yaml (attendance polls' votes; keep it while a poll is open), home-switches.state, space.state, announcement-routes.yaml (channels set with /admin → Announcements), parking-spots.yaml, parking-windows.yaml and parking-bookings.yaml (must persist, /admin → Parking and /park), announcement-posts/ (each live or single channel's current post), optional closing-time.md. Gitignored
PIXEL_TIMEZONEDefault Europe/Amsterdam. The time zone event times are shown in, and the zone {{date}} / {{dateWithTime}} (and the rest of the date family) use when a user-set post goes out
PIXEL_CONTENT_DIRDefault content. The reviewed content Pixel reads (info/*.md for /info). Read-only
DISCORD_TOKENyes
DISCORD_APP_ID
DISCORD_GUILD_IDThe only guild Pixel serves
DISCORD_ANNOUNCEMENTS_CHANNEL_IDOptional. The channel where announcements and the weekly poll are posted. /info points people at it. (Not the space-status posts below)
DISCORD_ANNOUNCE_BOT_CHANNEL_IDOptional. Where Pixel says it's online or offline. Defaults to DISCORD_ANNOUNCEMENTS_CHANNEL_ID
PIXEL_GIT_SHA, PIXEL_GIT_BRANCHSet by CI and the image build. The commit and branch shown in the online post. From a checkout, Pixel asks git instead
DISCORD_CLOSING_TIME_CHANNEL_IDOptional. Where the closing-time reminder is posted. Unset, empty, off or none disables both /closing-time and the automatic post after space-close
DISCORD_PARKING_CHANNEL_IDOptional. Where every parking change is posted for admins to watch (a private channel: posts name who parked). Unset, empty, off or none: no posts (Sentry still counts them). Terraform: discord_parking_channel_id
PIXEL_CLOSING_TIME_FILEOptional. Operator-authored closing-time body. Default {PIXEL_DATA_DIR}/closing-time.md. Missing or empty → built-in default. Not a secret
DISCORD_ROLE_MEMBEROptional. A Discord role name or ID that the member level is mirrored to. Unset means not mirrored
DISCORD_ROLE_FRIENDOptional. A Discord role name or ID that the friend level is mirrored to. Unset means not mirrored
DISCORD_ROLE_SYNCOptional. on reads the mapped roles back into members.yaml every minute (needs the Server Members Intent; ADR 0020). Unset or off means push only
HOME_ASSISTANT_URLOptional, with the token. Where Pixel reaches Home Assistant (http or https). On Azure prefer the Tailscale sidecar address; a Nabu Casa URL still works
HOME_ASSISTANT_TOKENOptional, with the URL. A long-lived access token from a non-admin Home Assistant user. A secret
MASTODON_INSTANCEOptional, with the token. Origin of the Mastodon instance (http or https). A path is stripped so only the origin is used. Never hard-coded
MASTODON_ACCESS_TOKENyesOptional, with the instance. An access token with write:statuses only. A secret. Both unset means the publisher is off
MASTODON_ACCOUNTOptional. Public handle for logs only (user@instance). The token decides which account posts
MASTODON_VISIBILITYOptional. unlisted (default) or public. Never private or direct
MASTODON_ANNOUNCE_KINDSOptional. Comma-separated kinds to toot. Default space.status. off / none turns toots off
SPACESTATE_API_URLOptional, with the user and token. The API /spacestate calls to set the space open or closed (http or https). Not a secret
SPACESTATE_USERyesOptional, with the address and token. Sent as user to that API. Key Vault spacestate-user when hosted
SPACESTATE_TOKENyesOptional, with the address and user. Sent as token to that API. A secret. All three unset means /spacestate says it isn't set up
SPACESTATE_ADDITIONAL_HEADERSyesOptional. A JSON object of extra headers for that call, such as {"X-Example": "value"}. A secret. Key Vault spacestate-additional-headers when hosted
OPENR_BUILDING_IDOptional, with the email and password. The OpenR building whose doors Pixel opens (digits). Not a secret. All three unset means OpenR is off
OPENR_EMAILyesOptional, with the building and password. The OpenR account Pixel logs in as. Key Vault openr-email when hosted
OPENR_PASSWORDyesOptional, with the building and email. That account's password. A secret. Key Vault openr-password when hosted
OPENR_DOORSOptional, with the building. The doors /openr open offers, as Name=ID pairs separated by commas (Entrance=103, Living room=203), at most 25, no name or ID twice, never the gate. Set this, OPENR_GATE_DOOR or both
OPENR_GATE_DOOROptional, with the building. The OpenR ID of the parking gate, which only /park opens
OPENR_URLDefault https://dashboard.openr.nl/api/v4; https only
PIXEL_HOME_ASSISTANT_DIRDefault config/home-assistant. Holds devices.yaml, the allow-list of devices. Required when Home Assistant is set up
SENTRY_DSNyesOptional. Operator-only (not in config.ts): SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT for just sentry-release / CD
SENTRY_TRACES_SAMPLE_RATEDefault 1. How many traces Sentry keeps (0–1). Needs SENTRY_DSN. Errors are always kept.
SENTRY_PROFILE_SESSION_SAMPLE_RATEDefault 1. How many of those traces get a CPU profile (0–1). Needs SENTRY_DSN.
LOG_LEVELDefault info
PIXEL_HEARTBEAT_MINUTESDefault 5. How often Pixel checks in with its Sentry cron monitor (pixel-<env>), so Sentry alerts when it goes quiet. 0 turns it off. Needs SENTRY_DSN; never runs with PIXEL_ENV=local
PIXEL_LOG_DIRDefault data/logs. A rotating JSON log file is written here (about two weeks; owner-only on a normal filesystem, not on the Azure Files share). Empty turns the file off; the console and Sentry Logs still get every line
HEALTH_PORTDefault 8080
SPACEAPI_URLDefault https://spaceapi.pixelbar.nl/; http(s) only
WIKI_API_URLDefault https://wiki.pixelbar.nl/w/api.php; https only. The MediaWiki API /wiki searches. Pixel only links pages on this URL's origin

If neither announcement channel is set, nothing is announced on Discord. Both can be the same channel. Mastodon is a separate publisher: unset MASTODON_INSTANCE and MASTODON_ACCESS_TOKEN leave it off. No account or instance is hard-coded.

Local development ​

Every task goes through the justfile. Run just to list the recipes. just dev runs the bot with tsx watch, which also restarts it when config/*.yaml changes. just check runs the same lint, type-check and test steps that CI will run.

Deployment ​

How the hosted pieces fit together (diagrams of Azure, GitHub, Discord, Home Assistant, Sentry): infra.md. Terraform layout: infra/.

There is no database. Access, schedules and (planned) account linking are files. Do not add Postgres because an old issue said so (#15, #18).

  • Platform: Azure Container Apps, exactly one replica, no ingress, with a managed identity. A Discord gateway connection needs an always-on process. Two replicas would both connect and answer every command twice. Max replicas = 1 (not a variable). Deploys stop the old instance before starting the new one (#11). This stack only pins min=max=1 and revision_mode = Single. A database lock is not a plan; there is no database.
  • Images: local just deploy dev pushes ghcr.io/pixelbar/pixel:<sha> (or a dev-dirty-* tag) and moving :dev for Azure dev. Merges to main push the SHA and moving :main via .github/workflows/cd.yml. A Release rebuilds from the bump commit and also tags :vX.Y.Z. Builds are linux/amd64 (Azure Container Apps); the Mini is arm64, so just deploy dev cross-builds. The images contain no secrets and no access lists. The GHCR package is public so Container Apps can pull without a registry password. If it is ever private again, dev can set container_registry_server / container_registry_username and Key Vault ghcr-pull-token. The running app pins an explicit tag, never latest alone. After first create, dev Terraform ignores that pin so apply cannot roll it back (#73). Prod apply will set container_image. CI still builds with push: false as a smoke check.
  • Secrets: Key Vault. Discord token, Home Assistant token, Sentry DSN, Tailscale auth key, admins.yaml, and optional ghcr-pull-token (private GHCR only) reach the container as secrets, never as image layers or Terraform state. The app ones are written only by just secrets-push dev|prod (ADR 0016, ADR 0017); Terraform writes only the Tailscale key and ghcr-pull-token, from ephemeral write-only inputs (write_secrets). Prod must not receive the Mini local / Pixel Dev token (that bot lives in pixel-dev-kv).
  • Access files: admins.yaml is a read-only Key Vault secret mount. members.yaml is rewritten by admin commands, so it cannot be a read-only secret mount. It lives on a writable Azure Files volume with snapshots. config/home-assistant/ is on the same volume: devices.yaml is a human allow-list (changed with just ha-devices). There is no block-list (#6).
  • Runtime files: the Azure Files share is mounted at /app/persist as uid 1000 (node), covering members.yaml, home-assistant/, and PIXEL_DATA_DIR (schedules.yaml must persist, and attendance.yaml while an attendance poll is open; home-switches.state lost → doors start off; space.state and announcement-posts/ only cost details; announcement-routes.yaml is where /admin → Announcements choices live, so keep it; so are parking-spots.yaml, parking-windows.yaml and parking-bookings.yaml for parking). A container's own filesystem is thrown away on every deploy.
  • Environments: dev (Pixel Dev bot, test guild) is infra/envs/dev. prod (production Pixel bot, production guild) is infra/envs/prod. Separate bots, tokens and vaults. just deploy dev (a local build) rolls Azure dev (one replica, then just register dev, then Sentry release). The Deploy dev workflow (workflow_dispatch, or just deploy-remote dev <ref>) does the same from a pushed branch or same-repo PR (ADR 0018). Merges to main publish GHCR only. Release (workflow_dispatch on main) cuts the version, publishes that image, GitHub Environment prod approval rolls pixel-prod to that digest (no Terraform apply) and then registers prod slash commands, and publishes the docs site. Pushes and pull requests never deploy by themselves. Fail closed: never copy the Mini / Pixel Dev Discord token into prod. See ADR 0012, ADR 0013 and ADR 0014.
  • Terraform layout: infra/bootstrap (state storage, GitHub OIDC, applied) then infra/modules/pixel, infra/envs/dev (#9) and infra/envs/prod (#12). Secret values never go into Terraform state; apply can take them as ephemeral write-only inputs. Subscription d150e252-e2f0-47fb-8a4a-c3f29e9aebd4, West Europe. See ADR 0008, ADR 0009 and ADR 0012. CD does not apply Terraform. After first create, dev apply ignores the Pixel container image; just deploy dev / az updates it. Prod apply sets container_image. Coding agents must not apply prod (PIXEL_APPLY_PROD=1 required).
  • Home Assistant from Azure: Tailscale sidecar into the space network is the preferred path (tailscale_enabled); HOME_ASSISTANT_URL can still be a Nabu Casa URL. Both URL and token or neither. Sidecar is userspace (no TUN). LAN MagicDNS through it is remaining work for #43.
  • Cost (rough): always-on dev is about €40–55/month West Europe without Tailscale, plus about €15 with the sidecar. Not a quote.
  • CI (built): .github/workflows/ci.yml runs on every PR and on pushes to main. Each expensive job depends on a list of files in .github/path-filters.yml, evaluated by dorny/paths-filter (some-with-excludes): check (just check + just build) on app/scripts/config/tooling files, workflows, Dockerfile and justfile; Docker smoke on what the Dockerfile copies (src/ without tests, content/, Dockerfile, lockfile, tsconfigs). Markdown-only changes skip them. A change to src/, the lockfile, workflows, Dockerfile, justfile, config/ or content/info/ still runs the matching job; if the filter job itself fails, those jobs run (fail closed). Skipped jobs never start a runner; GitHub shows them as passed. changelog.yml still runs on every PR, without a pnpm install (Node runs the gate's TypeScript itself). changelog-merge.yml runs when main's CHANGELOG.md changes (and after a Release): it merges main into each open same-repo PR that edits it and, when CHANGELOG.md is the only conflict and scripts/changelog-merge.ts can keep both sides' [Unreleased] entries safely, pushes the merge (with a GitHub App token if one is set up, so CI runs on it). Only main's resolver runs; PR code is never executed. docs.yml Biome-lints and builds the site when its docs paths change. terraform.yml starts only when infra/, that workflow, the justfile or the filter file change, and runs terraform plan on each env via GitHub Environment dev / prod OIDC only if infra/ Terraform files changed (markdown does not); plan jobs that aren't needed never start, so they never wait on an Environment. Missing ARM_* fails closed. No apply on merge. GitHub is not Key Vault Secrets User, so a secret refresh would 403 (ADR 0012). Actions are pinned to commit SHAs.
  • CD: just deploy dev for Azure dev (local GHCR push + az + just register dev + Sentry release), or .github/workflows/deploy-dev.yml when a maintainer runs it on a branch or same-repo PR: just check on that commit, GHCR :<sha> and :dev, Environment dev OIDC rolls pixel-dev (min=max=1, refuses unless PIXEL_ENV=dev), registers dev commands (reads only discord-token from pixel-dev-kv), Sentry release dev. Fork PRs are refused (ADR 0018). Production slash commands: the Release's register-prod job (fallback just register prod). .github/workflows/cd.yml on main only, and only when image paths changed: publish with GITHUB_TOKEN (packages: write on that job) and upload Sentry source maps (fails closed if org/token are missing). CD runs queue and are never cancelled, so a quick docs-only merge cannot cut short a publish that has already started. No Azure login and no pixel-prod update on merge. .github/workflows/release.yml (workflow_dispatch on main) runs just check on the main commit being released (CI on main may have been cancelled or red), cuts the version only if that passes, publishes :vX.Y.Z, Environment prod approval rolls pixel-prod (az, min=max=1, no Terraform apply), a second approval runs register-prod (reads only discord-token from pixel-prod-kv), and publishes GitHub Pages. See ADR 0010, ADR 0012 and ADR 0013.

CI and CD pipelines ​

How a change moves through GitHub. Operator steps: runbook.md. Hosting picture: infra.md. Job inputs: .github/path-filters.yml.

Pull request:

flowchart TD
  pr[Pull request push]
  pr --> clog[Changelog gate always, no install]
  pr --> docs{"docs paths?"}
  docs -->|yes| site[Biome + docs site build]
  docs -->|no| nodocs[Docs workflow off]
  pr --> tf{"infra/, justfile or terraform.yml?"}
  tf -->|yes| tffilter{"terraform paths, not infra markdown?"}
  tffilter -->|yes| tfval[just tf-validate]
  tfval --> tffiles{"infra Terraform files?"}
  tffiles -->|yes| azplan[Azure plan via Environment OIDC]
  tffiles -->|no| noaz[Plan jobs never start]
  tffilter -->|no| notfrun[Skip validate and plans]
  tf -->|no| notf[Terraform workflow off]
  pr --> checkp{"check paths?"}
  checkp -->|yes| check["just check + just build"]
  checkp -->|no| nocheck[Skip just check]
  pr --> imagep{"image paths?"}
  imagep -->|yes| docker[Docker image smoke]
  imagep -->|no| nodocker[Skip Docker]

Merge to main:

flowchart TD
  main[Push to main]
  main --> ci[Same path filters as PRs]
  main --> queue[CD runs queue, never cancel]
  queue --> imagep{"image paths in this push?"}
  imagep -->|yes| pub["CD: GHCR SHA and :main"]
  pub --> maps[Sentry source maps]
  imagep -->|no| nocd[Skip GHCR and Sentry]
  main --> clogp{"CHANGELOG.md changed?"}
  clogp -->|yes| clogm["Changelog merge: merge main into open PRs, push if only CHANGELOG conflicts and it's safe"]
  main --> note[Does not roll pixel-prod]

Release (a human runs Actions → Release on main; ADR 0013):

flowchart TD
  human[Actions Release on main]
  human --> check[just check on main HEAD]
  check --> bump[just bump commit]
  bump --> tag[Tag vX.Y.Z and GitHub Release]
  tag --> img["Rebuild GHCR :sha, :vX.Y.Z, :main"]
  img --> sentry[Sentry maps environment prod]
  img --> approve[Environment prod approval]
  approve --> roll[az containerapp update pixel-prod]
  roll --> approve2[Environment prod approval]
  approve2 --> register[Register prod slash commands]
  tag --> pages[VitePress to GitHub Pages]
  bump --> clogm[Changelog merge into open PRs]

Deploy dev (a human runs Actions → Deploy dev, or just deploy-remote dev <ref>; ADR 0018):

flowchart TD
  human["Actions Deploy dev: PR number, branch or SHA"]
  human --> resolve{"Branch in this repo?"}
  resolve -->|fork PR| refuse[Refuse]
  resolve -->|yes| check[just check on that commit]
  check --> img["GHCR :sha and :dev"]
  img --> roll["Environment dev: az containerapp update pixel-dev"]
  roll --> register[Register dev slash commands]
  roll --> sentry[Sentry maps environment dev]
  human --> note[Never touches pixel-prod]

Adding a platform (later) ​

  • Interactive (for example Telegram): create src/adapters/telegram/. It builds a PlatformActor, calls the dispatcher and renders Reply. Telegram users get tiers only after account linking, which writes a second id onto the existing members.yaml entry (see the identity doc). No database.
  • Outbound only (Mastodon is the first): implement Publisher in src/adapters/<platform>/, configure the instance and account through environment variables, and register it with the announcer. Features do not change. Each publisher decides which kinds it posts (Mastodon: MASTODON_ANNOUNCE_KINDS).

Open questions ​

  • Who has Owner (or Contributor) on the Pixel Azure subscription, besides the person applying bootstrap?
  • Which Sentry org? (Needed for Sentry releases from CD / just sentry-release. Set repository variables SENTRY_ORG and SENTRY_PROJECT, and secret SENTRY_AUTH_TOKEN. Image publish does not use them; the CD Sentry job fails closed if they are missing.)
  • Where should private change history for admins.yaml live (a private repo, or Key Vault versions)? members.yaml is bot-managed; backups are volume snapshots, not Key Vault secret versions.
  • Which channels should the live and timeline announcements go to in the real server?

Decision log ​

Record significant decisions as short ADRs in docs/adr/NNNN-title.md. Numbers 0006 and 0007 were skipped.

#DecisionStatus
0001TypeScript, ports-and-adapters core, interactive vs publisher adaptersaccepted
0002Phase 1 tiers from gitignored YAML files; admins in a separate file; fail closedaccepted
0003No database: access, schedules and planned linking are filesaccepted
0004Azure Container Apps, single replica; GHCR; dev + prodaccepted
0005Sentry for errors (no PII), pino to stdout, a rotating file and Sentry Logsaccepted
0008Terraform bootstrap: remote state (Azure AD, no shared keys) and GitHub OIDC (UAMI per env)accepted
0009Container Apps module + dev: one replica, required Azure Files volume, Key Vault, optional Tailscale sidecaraccepted
0010Temporary GHCR CD: local just deploy dev → Azure dev; main publishes GHCR only (prod CD off until #12)accepted (prod-off superseded by 0012, no PR deploys by 0018)
0011Home Assistant: official WebSocket client, non-admin token, no inbound webhooks, allow-list is the fenceaccepted
0012Prod environment (infra/envs/prod) and approved CD to pixel-prod (same digest, no rebuild)accepted (merge promotion superseded by 0013, registration by 0014)
0013Release workflow cuts versions and promotes prod + the docs site; merges to main do not roll pixel-prodaccepted (registration superseded by 0014)
0014Release registers prod slash commands; pixel-github-prod may read only discord-tokenaccepted
0015Secrets manifest (infra/secrets.manifest.yaml) is the one list of Key Vault secrets; Terraform reads it and a test fails on driftaccepted
0016just secrets-pull / just secrets-push sync the manifest's app secrets between Key Vault and .env.<env>, diffing against the live vault before a push; just pull / just push add the shared terraform-tfvars and share mirrors (only a hand-edited members.yaml goes back up)accepted
0017just secrets-push / just push are the only writers of the app secrets; Terraform's write_secrets writes only the infra group, and removed blocks drop the old app secrets from state without deleting themaccepted
0018The Deploy dev workflow puts a branch or same-repo PR on pixel-dev when a maintainer runs it (check, GHCR :<sha>/:dev, roll, register, Sentry); pixel-github-dev may read only discord-tokenaccepted
0019Parking state is three files in PIXEL_DATA_DIR, one per rate of change: spots and settings, opening times, and bookings (deleted when they end)accepted
0020OpenR doors and the parking gate: one Pixel-owned OpenR account, logged in by an admin with the emailed code, kept alive by refreshing and saved in PIXEL_DATA_DIR; Pixel decides who opens whataccepted

A Pixelbar community project.