Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ScottyLabs Docs

Unified documentation for all ScottyLabs projects. Repositories are included by default; opt out with docs = false in governance.

How it works

  1. Governance registers repositories (docs hub inclusion is on by default)
  2. At build time, CI resolves each repo (monorepo sibling or shallow clone) and copies its docs/ directory into this site
  3. The built site is published to docs.scottylabs.org

Aggregated pages are not stored in git. Edit documentation in each project’s own repository.

Adding your project

Add your repository in governance (no flag needed). To exclude a repo:

[[team.repos]]
name = "my-internal-tool"
docs = false

Commit markdown to docs/ in your repository, then trigger a documentation rebuild (push to the documentation repo, or run the deploy workflow manually).

Getting Started


title: Getting Started description: Learn how to use and contribute to ScottyLabs projects project: documentation repo: https://codeberg.org/ScottyLabs/documentation

Welcome! This documentation hub aggregates documentation from multiple ScottyLabs repositories.

For Users

Each project has its own section in the sidebar with:

  • Guides: Step-by-step tutorials
  • API Docs: Interactive API references (for projects with APIs)
  • Rustdoc: Generated documentation for Rust code

Start with ScottyLabs for organization-wide guides on contributing, communication, and credentials.

Use search (⌘K) or browse the sidebar to find what you need.

For Contributors

Repositories registered in governance are included in this hub by default. To exclude a repo, set docs = false in its team entry.

The build system automatically:

  • Clones your repository
  • Copies its docs/ directory into this site
  • Generates API references (if applicable)
  • Builds and deploys to docs.scottylabs.org

See the Documentation Hub page for the full workflow.

ScottyLabs

The single source of truth for all ScottyLabs documentation.

Replaces Notion, Discord pins, Google Drive docs, and scattered README files with a unified, searchable, automatically-updated documentation platform. Integrates with ScottyLabs governance to automatically pull documentation from projects marked with the docs flag.

Vision

Every ScottyLabs project, guide, process, and resource in one place:

  • Project Documentation: Automatically aggregated from repos with docs: true in governance
  • Org-Level Documentation: Central repository for organization-wide guides, processes, and resources
  • API References: Interactive documentation for all APIs (OpenAPI/Scalar)
  • Code Documentation: Auto-generated rustdoc for Rust projects
  • Institutional Knowledge: Onboarding, meeting notes, decision records - everything previously scattered across Notion/Discord

Features

  • Governance integration: Projects marked with docs: true flag are automatically included
  • Multi-repo aggregation: Clone and merge documentation from all flagged projects
  • Central org docs: Dedicated repository for ScottyLabs-wide documentation
  • OpenAPI support: Interactive API documentation with Scalar
  • Rustdoc integration: Automatic rustdoc generation and hosting
  • Full-text search: Find anything across all projects (powered by Pagefind)
  • AI agent access: Pages serve Markdown via Accept: text/markdown (Accept Markdown)
  • CI/CD ready: Rebuilds automatically when any project updates docs
  • Nix-powered: Reproducible builds and deployment via Nix flake

AI / LLM access

The docs site supports Accept Markdown content negotiation. AI agents can read any page as clean Markdown from the same URL browsers use for HTML:

# Canonical page (preferred)
curl -sI -H "Accept: text/markdown" https://docs.scottylabs.org/scottylabs/onboarding/contributing/
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept

curl -s -H "Accept: text/markdown" https://docs.scottylabs.org/scottylabs/onboarding/contributing/

Legacy URLs (e.g. /scottylabs/contributing/) return Markdown redirect stubs pointing to the canonical path.

At build time, the site exports a Markdown counterpart for every HTML page. Caddy on infra-01 must negotiate Accept: text/markdown at the edge and rewrite requests to the matching .md file in Garage. The docs CI upload alone is not enough.

Verify negotiation is live (both checks should pass after infra deploy):

# Should include: Vary: Accept
curl -sI -H "Accept: text/html" https://docs.scottylabs.org/scottylabs/onboarding/contributing/

# Should include: Content-Type: text/markdown and Vary: Accept (not text/html)
curl -sI -H "Accept: text/markdown" https://docs.scottylabs.org/scottylabs/onboarding/contributing/

If the second request still returns content-type: text/html with no Vary: Accept, apply the docs.scottylabs.org Caddy config in infrastructure/hosts/infra-01/garage.nix on infra-01 (nixos-rebuild switch).

Markdown files are always available at the sibling index.md path as a fallback, e.g. https://docs.scottylabs.org/scottylabs/onboarding/contributing/index.md.

Architecture

flowchart TB
    governance[Governance YAML<br/>docs: true flag] --> discover[Project Discovery]
    central[Central Docs Repo<br/>org-wide content] --> discover
    discover --> manifest[projects.toml]
    manifest --> build[Build Script]
    repos[Project Repos] --> build
    build --> starlight[Starlight Pages]
    build --> scalar[Scalar API Docs]
    build --> rustdoc[Rustdoc Sites]
    starlight --> site[Unified Site<br/>Single Source of Truth]
    scalar --> site
    rustdoc --> site
    site --> deploy[Garage S3]
    
    notion[❌ Notion] -.replaced by.-> site
    discord[❌ Discord] -.replaced by.-> site
    gdrive[❌ Google Drive] -.replaced by.-> site

Content Sources

  1. Central Org Docs (scottylabs-docs repo)

    • Onboarding guides
    • Organization processes and policies
    • Meeting notes and decision records
    • Event planning guides
    • Infrastructure documentation
  2. Project Docs (from repos with docs: true in governance)

    • Starlight - Markdown documentation that integrates into main navigation
    • Rust - Runs cargo doc, hosts at /{slug}/api/
    • OpenAPI - Generates Scalar-rendered interactive API reference

Automatic Updates

The documentation hub automatically rebuilds when governance changes. See .forgejo/README.md for setup instructions.

What triggers a rebuild:

  • Changes to data/ in the governance repository
  • Direct pushes to this repository
  • Manual workflow dispatch

Setup required in governance repository:

  1. Add access token secret: DOCS_TRIGGER_TOKEN
  2. Add workflow file: .forgejo/workflows/trigger-docs-rebuild.yml (see .forgejo/examples/trigger-docs-rebuild.yml)

Once configured, any change to governance (adding/removing docs = true flags, updating descriptions, etc.) will automatically trigger a documentation rebuild and deployment.

Quick Start

Prerequisites

  • Bun v1.0+
  • Nix (optional, for reproducible builds)
  • Git

Installation

# Clone the repository
git clone https://codeberg.org/scottylabs/documentation.git
cd documentation

# Install dependencies
bun install

# Enter development shell (Nix users)
nix develop

Governance Integration

Projects are automatically discovered from the ScottyLabs governance repository. When a repository has docs = true in its governance entry (same pattern as kennel and sentry flags), it’s included in the documentation hub.

To add your project’s documentation:

  1. In the governance repository (data/ directory), add docs = true to your repository entry:

    # data/my-team.toml
    [[team.projects]]
    name = "My Project"
    slug = "my-project"
    
    [[team.projects.repos]]
    name = "my-project-backend"
    description = "Backend for My Project"
    kennel = true
    docs = true  # <-- Add this flag (same level as kennel/sentry)
    
  2. Ensure your repository has a docs/ directory with markdown files

  3. The documentation hub will automatically pick it up on the next build

Optional configuration:

[[team.projects.repos]]
name = "my-api"
docs = true
docs_type = "openapi"  # or "rust" or "starlight" (default)
docs_dir = "documentation"  # custom docs directory
openapi_spec = "openapi.json"  # for OpenAPI projects
export_command = "cargo run --bin export-openapi"

Manual override: You can also manually add projects to projects.toml:

[[project]]
slug = "my-project"
name = "My Project"
repo = "https://codeberg.org/scottylabs/my-project"
type = "starlight"
docs_dir = "docs"
description = "Documentation for My Project"

Starlight Project Example

[[project]]
slug = "guides"
name = "User Guides"
repo = "https://codeberg.org/scottylabs/guides"
type = "starlight"
docs_dir = "docs"
description = "Comprehensive guides for all ScottyLabs services"

Rust Project Example

[[project]]
slug = "common-lib"
name = "Common Library"
repo = "https://codeberg.org/scottylabs/common-lib"
type = "rust"
docs_dir = "docs"
description = "Shared Rust utilities and types"

OpenAPI Project Example

[[project]]
slug = "courses-api"
name = "Courses API"
repo = "https://codeberg.org/scottylabs/courses-backend"
type = "openapi"
docs_dir = "docs"
openapi_spec = "openapi.json"
export_command = "cargo run --bin export-openapi"
description = "Course scheduling and registration API"

Development

# Build documentation from all projects
bun run build

# Start development server
bun run dev

# Clean build artifacts
bun run scripts/build.ts clean

Build Pipeline

The build process follows these steps:

  1. Parse manifest - Read projects.toml to get project list
  2. Clone repos - Parallel git clone into .repos/{slug}/
  3. Process by type:
    • Starlight: Copy markdown to src/content/docs/{slug}/
    • Rust: Run cargo doc, copy to public/{slug}/api/
    • OpenAPI: Export spec, generate Scalar page
  4. Generate nav - Build dynamic Starlight sidebar
  5. Build site - Run astro build

Project Structure

documentation/
├── astro.config.mjs       # Starlight configuration
├── package.json           # Dependencies
├── projects.toml          # Project manifest
├── flake.nix              # Nix development environment
├── .forgejo/
│   ├── README.md          # Forgejo integration (governance + diagram triggers)
│   ├── workflows/
│   │   └── deploy.yml     # CI/CD pipeline
│   ├── examples/
│   │   ├── trigger-docs-rebuild.yml    # Copy to governance repo
│   │   └── trigger-docs-diagrams.yml   # Copy to project repos
│   └── scripts/
│       └── dispatch-rebuild.sh
├── scripts/
│   ├── build.ts           # Main build orchestrator
│   ├── manifest.ts        # TOML parsing
│   ├── clone-repos.ts     # Git operations
│   ├── aggregate-docs.ts  # Content aggregation
│   ├── scalar-integration.ts  # OpenAPI handling
│   ├── rustdoc.ts         # Rust documentation
│   └── generate-nav.ts    # Navigation generation
├── src/
│   ├── content/
│   │   ├── config.ts      # Content collections
│   │   └── docs/          # Documentation pages
│   ├── pages/
│   │   └── [slug]/
│   │       └── api.astro  # Dynamic API pages
│   └── styles/
│       └── scalar-theme.css
└── .repos/                # Cloned repos (gitignored)

CI/CD

Automated Builds

The documentation hub rebuilds automatically on:

  1. Direct commits to the documentation repository
  2. Governance changes via repository dispatch (when governance data/ changes)
  3. Manual triggers via workflow dispatch

Governance Integration

To enable automatic rebuilds when governance changes, add the trigger workflow to the governance repository. See .forgejo/README.md for complete setup instructions.

Quick setup:

# In governance repository
mkdir -p .forgejo/workflows
cp /path/to/documentation/.forgejo/examples/trigger-docs-rebuild.yml \
   .forgejo/workflows/trigger-docs-rebuild.yml

# Add secret DOCS_TRIGGER_TOKEN to governance repo
# (see .forgejo/README.md for details)

Forgejo Actions

The included workflow automatically:

  1. Checks out the repository
  2. Installs dependencies with Bun
  3. Runs the build script
  4. Uploads artifacts
  5. Deploys to Garage S3 (on main branch)

Required Secrets

Configure these in your Forgejo repository settings:

  • GARAGE_ENDPOINT - S3 endpoint URL
  • GARAGE_ACCESS_KEY - S3 access key
  • GARAGE_SECRET_KEY - S3 secret key

The bucket name is configured in the workflow: scottylabs-docs

Manual Deployment

# Using Nix
nix run .#upload-garage

# Or directly with environment variables
export GARAGE_ENDPOINT="https://s3.example.com"
export GARAGE_ACCESS_KEY="your-access-key"
export GARAGE_SECRET_KEY="your-secret-key"
export GARAGE_BUCKET="scottylabs-docs"
nix run .#upload-garage

Project Guidelines

Documentation Structure

For projects contributing Starlight documentation:

your-project/
└── docs/
    ├── index.md           # Landing page
    ├── getting-started.md
    ├── guides/
    │   ├── installation.md
    │   └── configuration.md
    └── api/
        └── reference.md

Frontmatter

Standard Starlight frontmatter is supported:

---
title: Page Title
description: Page description for SEO
---

# Page Title

Content here...

The build system automatically adds:

  • project: The project slug
  • projectType: The project type (starlight/rust/openapi)

OpenAPI Export

For OpenAPI projects, ensure your export command:

  1. Runs without starting a server
  2. Writes to the path specified in openapi_spec
  3. Generates valid OpenAPI 3.0+ JSON

Example Rust implementation with utoipa:

// bin/export-openapi.rs
use utoipa::OpenApi;
use std::fs;

#[tokio::main]
async fn main() {
    let doc = ApiDoc::openapi();
    fs::write(
        "openapi.json",
        serde_json::to_string_pretty(&doc).unwrap()
    ).unwrap();
}

Why This Approach?

Replacing Scattered Documentation

Before:

  • Notion: Onboarding guides, meeting notes, processes (hard to search, requires account)
  • Discord: Pinned messages, FAQs (ephemeral, poor discoverability)
  • Google Drive: Shared docs (siloed, inconsistent permissions)
  • README files: Scattered across 30+ repos (no central search)
  • Tribal knowledge: In people’s heads or DMs

After:

  • One URL: docs.scottylabs.org
  • Full-text search: Find anything across all projects
  • Always up-to-date: Rebuilds on every commit
  • No account needed: Public, accessible, linkable
  • Git-based: Version controlled, reviewable, forkable

Governance Integration

Projects use a simple docs: true flag (same pattern as kennel: true):

  • Automatic discovery: No manual manifest maintenance
  • Consistent with existing workflows: Same governance system
  • Self-service: Project maintainers control their own docs
  • Audit trail: Changes tracked in governance repo

Why custom aggregation vs a plugin?

No mature multi-repo plugin exists for Starlight (unlike mkdocs-monorepo-plugin). A ~200 LOC build script provides:

  • Full control over navigation structure
  • Integration with governance system
  • Better build caching
  • Type-safe TypeScript implementation
  • Equivalent UX to established plugins

Why sibling rustdoc vs embedded?

Rustdoc generates a complete static site with its own theme, search, and navigation. Embedding would require:

  • Fragile iframe hacks
  • JSON-to-markdown conversion (lossy)
  • Custom theming to match (high maintenance)

The sibling pattern (/{slug}/api/) is the industry standard (docs.rs, tokio.rs, axum.rs, etc.)

Why Scalar vs alternatives?

Compared to Swagger UI and Redoc:

  • Better UX: Modern design, fast rendering
  • More features: Try It, code generation, dark mode
  • Better integration: First-party Astro component
  • Active development: 14K+ stars, regular releases

Troubleshooting

Build fails with “Project missing required field”

Check that all required fields are present in projects.toml:

  • slug, name, repo, type, docs_dir, description

For OpenAPI projects, also ensure:

  • openapi_spec is set
  • export_command is provided (if spec isn’t pre-generated)

Rustdoc not appearing

Ensure:

  1. Project type is set to "rust"
  2. Repository contains a valid Cargo workspace/package
  3. cargo doc runs successfully in the project

Check build logs for cargo errors.

The navigation is regenerated on each build. If changes aren’t appearing:

  1. Clean build artifacts: bun run scripts/build.ts clean
  2. Rebuild: bun run build
  3. Check that markdown files have correct file extensions (.md or .mdx)

Contributing

Adding Your Project

  1. Fork this repository
  2. Add your project to projects.toml
  3. Ensure your project has documentation in the specified docs_dir
  4. Test locally: bun run build && bun run dev
  5. Submit a pull request

Improving the Hub

Contributions to the documentation hub itself are welcome:

  • Build script improvements
  • Theme enhancements
  • Additional project type support
  • Documentation improvements

License

MIT License - see LICENSE file for details

Support

For questions or issues:

  • Open an issue on Codeberg
  • Ask in the ScottyLabs Discord
  • Email: tech@scottylabs.org

Communication

Join Our Communication Platforms!

By joining ScottyLabs on TartanConnect, you will receive an email including the invite links for Slack and Discord.

Events & Work Sessions

We hold in-person events and work sessions during the CMU school year. Check our calendar for the exact location and time details.

Resources

Here is a comprehensive list of available resources for ScottyLabs members.

Google Drive

ScottyLabs’s Google Drive is used for storing and sharing files like meeting notes, project documents, events planning, etc.

See Google Drive README for more information, including permissions.

GitHub

ScottyLabs GitHub Organization is used for storing and sharing code, documentation, and other resources.

Each project has its own repository and might have a wiki page that serves as the project’s documentation.

Notion

Notion Wiki serves as the internal documentation tool. Accessible only to ScottyLabs leadership.

Slack

ScottyLabs Slack is the primary communication platform.

Internal documentation link.

Slack Apps

Anyone can create Slack Apps! But you would need to request to have it installed to the ScottyLabs Slack.

Every internal Slack app must satisfy the following requirements:

  • Has a short description on what it is.

  • Has a long description including the DRI (directly responsible individual) and relevant information on how it was set up.

  • Shared with the ScottyLabs Admin member.

Discord

We also have a Discord server for Discord lovers, mainly limited to discussion in the tech committee.

Internal documentation link.

Tech Stack Wiki

https://github.com/ScottyLabs/ScottyStack/wiki

Design System

  • Website: https://corgi.scottylabs.org

  • GitHub: https://github.com/ScottyLabs/corgi/tree/main/src

  • Figma: https://www.figma.com/design/TlYR1IqgGhRDXHyKJ1LHQs/ScottyLabs-UI-Kit

Diagramming

Mermaid

Use fenced ```mermaid blocks in markdown. Diagrams render in docs and include a fullscreen button (hover the diagram, or press Escape to exit).

https://www.mermaidchart.com/play. Useful for very structured diagrams, such as database model.

Examples:

Excalidraw

https://excalidraw.com. Useful when collaborating and when you need more flexibility. The tech stack page embeds an Excalidraw diagram (source: documentation/scripts/generate-tech-stack-excalidraw.ts, output: public/diagrams/tech-stack.excalidraw.json).

Diagrams in project repos

Any repo with docs = true in governance can ship Excalidraw scenes under docs/diagrams/*.excalidraw.json. The documentation hub copies them to public/diagrams/{project-slug}/ on each build.

Embed in MDX (documentation hub or after aggregation):

import ExcalidrawDiagram from '@/components/ExcalidrawDiagram.astro';

<ExcalidrawDiagram
  scenePath="/diagrams/tartan-vote/architecture.excalidraw.json"
  caption="Optional caption"
/>

Optional programmatic diagrams: add scripts/generate-*-excalidraw.ts in your repo; the hub runs it before aggregating scenes.

After pushing diagram changes, either rely on the org push webhook on webhooks.scottylabs.org (infra-01) or copy .forgejo/examples/trigger-docs-diagrams.yml into your repo’s .forgejo/workflows/ with the DOCS_TRIGGER_TOKEN secret.

Examples:

Figma

https://figma.com. Useful for low-fidelity and hi-fidelity UI designs.

Ai Code Reviewers

CodeRabbit

You can configure via a YAML file in your repo.

Sentry

Add/remove your repo in the Sentry integrations settings page.

Deprecation Guideline

GitHub Repo

Update the README.md to prefix the heading with “[DEPRECATED]” and add a “Deprecation Notice” section explaining why the repo is archived and link to the replacement.

Then append suffix “-deprecated” to the GitHub name, add the deprecated topic tag, and archive the repo.

E.g: https://github.com/ScottyLabs/sss-installer-deprecated

Git Best Practices

"Good commit habits reflect on the developer. Being able to clearly reflect upon your changes and describe the impact of them means you are able to reason about your code and about why you are making the changes you are."
Yiyoung Liu
Generally to preserve good git history for readability and revertability, it is best to have some standard practices. Not only would it make it easier for new contributors, it would create a positive look on scottylabs if we have good git history.

Commit Styles

There are two main commit styles used by ScottyLabs.

Conventional Commits

Most projects use Conventional Commits so that we can automatically get a CHANGELOG in git history and communicate the changes to other members and sponsors.

This means that usually the commit is in a format like <nature>(<scope>): <changes>. Here nature is the nature of the commit, i.e. if it was a fix, feature, chore, etc. Scope is what the commit deals with, for instance the frontend, docs, or backend. Lastly the changes is what the commits actually changed.

Some of the most common natures are:

  • feat for new features
  • fix for bugfixes
  • docs for changes to documentation
  • chore for maintenance and routine tasks
  • refactor for refactors if it does not change behavior (e.g. a library version update)
  • revert if a previous change was reverted

This blog is a pretty good resource.

On many kennel repositories (the ones using devenv and governance PRs) Conventional Commits are enforced by DevOps.

Kernel Commit Style

Some projects (and Tech Leads) instead prefer kernel commit style. Most of the information here is copied over from Tartan Vote’s contributing document.

Here, commits are in a format like <system>: <subsystem if applicable>: <changes>. System is what the commit deals with, for instance the frontend, docs, or backend, and subsystem is the smaller division within them, for instance auth in backend. Lastly the changes is what the commits actually changed.

Here’s a list of possible commit types, but not exhaustive:

  • backend: auth: created migrations for token storage
  • backend: session: ensures user must exist before joining
  • docs: process: add section on code review
  • devenv: update to latest scottylabs version
  • frontend: motion: center vote div

Git Policy

Beyond just commit messages, there are several things that can be done to help with git commit history.

Pulling to a Branch

When you re-pull changes from main, use rebase instead of merge. This produces cleaner commit history and retains the commit owner, since rebase basically places your commits on top of current main again, meaning commit history and permissions/CODEOWNERS especially are computed correctly.

In contrast, merge commits are owned by the person who merges the PR. For example, this breaks governance’s file owner checks by making governance think someone who updated their branch to main via a merge commit was actually touching all of those files that were modified on main. This would cause CODEOWNERS issues, for instance, and prevent tech leads from being able to merge PRs from their own members.

Merging a PR

Similarly to the above, choose rebase and fast forward instead of creating merge commits in any way. This makes it so that commit history is preserved.

Pr Process

Opening a Pull Request

Once you have gotten your code far enough along that you are confident you’ll be able to complete it, open a pull request (PR) to the staging branch. You might also do this earlier if a maintainer requests to see your code in order to assist you.

For a larger features, a PR should be opened once you have meaningful progress. That way, it can be kept safe on GitHub and the maintainer can check in to see your status so your work is less of a mystery.

Here’s the important part: when you open a PR, it should be marked as a draft unless it is currently ready for review. The left image shows how to open a new PR as a draft, and the right image shows how to convert an existing PR to a draft.

Open a new PR as a draft / convert an existing PR to a draft

When you believe your code implements the needed functionality and doesn’t introduce any new bugs or broken features you should mark it as ready for review. A reviewer will be automatically requested to review your PR if the team has a CODEOWNERS file. Otherwise, ping the same reviewer as the one you requested in the Governance PR.

Title and description

Please make sure to link the corresponding issue in the description of the PR. Make sure to address the acceptance criteria of the issue, with relevant screenshots or video clips if applicable. Avoid revealing sensitive information by using a Google Drive link with the “Anyone in CMU with the link can view” access permission. It will also be very helpful for the reviewers if you take a few minutes to write about what you changed.

If you have concerns about a certain approach you took or if a certain part of your code is as clean as it could be, you can leave comments on lines of your own code from the “Files changed” tab after opening the PR.

Code review etiquette

It is your responsibility to run the project locally, thoroughly test your work, and employ common sense to avoid wasting a reviewer’s time in needing to point out obvious flaws. It is not uncommon for inexperienced contributors to request review when their code entirely fails to implement the task at hand, or breaks surrounding functionality in a way that should have been immediately apparent. This doesn’t leave a good impression and can frustrate reviewers.

If you don’t actually understand what is intended with your feature/fix and why this is meaningful to a user of the project, spend time becoming that user and understanding the context. Learning at least the basics of using the project is important. Then ask questions in Slack if you’re still confused about specific edge cases or the wording of the task.

It is also common for larger tasks to enter a round of review to confirm the direction is correct before you go back and polish the remaining details of the implementation. It’s good to be in touch with the team to decide on when is the right time for this kind of preliminary review. It can save you effort reworking problems if you misunderstand the goals, or if the exact details of the requirements were never well-defined and you’ll need to iterate on the design together with the team. Don’t feel that every part of your PR needs to be 100% finished before requesting feedback, but also be clear so you aren’t taking a reviewer away from other work to point out that you are obviously nowhere near done.

Self-review

Before marking your PR as ready for review, you should do a self-review. That means reading over the diff of all your changes to ensure they are correct, complete, and lacking frivolous changes like unintended whitespace alterations, leftover debugging code, or commented-out lines. Read over it with a fine-toothed comb so reviewers don’t have to nitpick as much. It is only fair that your first code reviewer should be yourself, so you catch the obvious flaws first.

Passing CI

Upon pushing a commit to your PR’s branch, CI will need to build and test your code. PRs from forks will have to wait until a reviewer approves the CI run.

Your goal is for the all the checks required by the project to pass with a ✅. If it fails with a ❌, you will need to investigate. Occasionally, other checks may fail, but you likely won’t be responsible for fixing those and they can be ignored.

Keeping your work up-to-date

Be sure to start your work from the latest commit on the staging branch by pulling (git pull) with staging checked out when you begin coding.

As time goes on and staging accumulates new commits, your branch will become outdated. It has to be synced up with staging before your PR can be merged. Sometimes there will be conflicts that you need to resolve, which you can find learning resources for online.

When your branch can be updated with staging without conflicts, you can click the “Update branch” button below the CI status. If you click the dropdown button beside it, you can choose instead to update with a rebase. If this can be done without conflicts, this is preferred because it maintains a clean, linear history for your branch.

Screenshots showing GitHub’s “Update with rebase” button

Be sure to pull the rebased, or updated-with-a-merge-commit, branch after you or a reviewer updates it (or pushes other commits to it) to ensure you are working on the latest code.

Review process

AI Code Review

ScottyLabs uses CodeRabbit for AI code reviews. It will automatically review your PR. Please respond to its comments and update your PR as needed. See AI Code Reviewers for configuration details.

Human Code Review

Assuming you have done what’s explained above, a reviewer will aim to review your PR within a few days if possible. Feel free to send reminders because PRs can get overlooked.

As a rule of thumb, at this stage you are about 50% done with your work. The other 50% of your time will be spent responding to feedback and making (sometimes significant) changes.

There are two parts to the review process, QA and code review, which occur separately:

  • Quality assurance (QA): A build of your code will be opened and tested to ensure it implements the requested functionality and doesn’t introduce regressions. This is not a substitute for your own testing, but it is a necessary line of defense against overlooked issues. Reviewers (and only reviewers) have the ability to invoke CI on your PR which will produce a Vercel preview link. That is a unique link hosting a build of your PR’s current code. If your change involves backend changes, a Railway dev server might also be built to test the backend changes, or if project doesn’t have a dev server environment, the changes will be tested locally and in the staging environment.

  • Code review: The code will be checked for flawed approaches, pitfalls, confusing logic, style guide adherence, sufficient comments and tests, and general quality. A review may be left through GitHub or your PR may have commits added to it. Feel free to read the diffs of those commits to understand what was changed so you can learn from that feedback. Direct commits are often faster than leaving dozens of comments. These can range from nitpicks to larger improvements. Our process is to collaborate on PRs as a team to write the best code possible, meaning your PR won’t always be exclusively written by you.

When changes are requested, the reviewer will usually mark the PR as a draft again while awaiting your updates. It is your responsibility to mark it as ready for review again once you’ve addressed the feedback.

  • If a PR is a draft, the ball is in your court to move it forward.
  • If it’s marked as ready for review, it means there is nothing more for you to do until the reviewer has time to review it.

After any number of back-and-forth cycles, a reviewer (usually Yuxiang who often gives the final say) will merge your PR. All your commits will be rebased on the staging branch. This keeps the Git history linear and easy to follow. During each ScottyLabs work session, the staging branch will be merged into the main branch, updating the live website.

Credited as a Contributor

Once your PR is merged and that you have also come to one ScottyLabs work session, you will be credited as a contributor in the corresponding team in Governance, forever!

Acknowledgment

The writing is adapted from the Graphite contribution guide. One of the ScottyLabs Tech Directors is a contributor to the Graphite project and had to write an analysis of its project processes in 17-313

Codeberg Setup

Sign up on codeberg.org.

Use the same username and email as your GitHub account. That is all you really need to do in this document.

Extra steps

To clone and push ScottyLabs repos you need SSH. Verified commits are optional. See Commit signing (optional) if you want them.

SSH setup

SSH is how Git proves you are you when you clone and push. You make a key pair on your laptop, paste the public key into Codeberg, and keep the private key on your machine.

  1. Create a key (replace the email with yours):

    ssh-keygen -t ed25519 -C "your@email"
    

    Press Enter to accept the default path (~/.ssh/id_ed25519). Set a passphrase when prompted.

  2. Copy the public key:

    cat ~/.ssh/id_ed25519.pub
    

    Copy the whole line (ssh-ed25519 …).

  3. Add it on Codeberg: open SSH / GPG keys, click Add key, paste, save.

  4. Test it:

    ssh -T git@codeberg.org
    

    You should see a message with your username, not Permission denied.

If ssh-add complains later, run ssh-add ~/.ssh/id_ed25519 and enter your passphrase.

(Optional) GitHub: add the same public key at GitHub SSH and GPG keys if you push there too.

Commit signing (optional)

Add your public key under SSH / GPG keys on Codeberg and click Verify to prove ownership.

SSH signing (Git 2.34+). You can use your auth key or a separate signing-only key:

git config --global gpg.format ssh
git config --global user.signingKey '~/.ssh/id_ed25519.pub'
git config --global commit.gpgSign true

GPG signing:

git config --global user.signingkey <key-id>
git config --global commit.gpgSign true

Next steps

After SSH is working, follow Contributing to request access through Governance. See GitHub Organizations for how ScottyLabs uses GitHub and Codeberg together.

Contributing

To contribute to a ScottyLabs project, follow the README instructions in Governance to join a team and obtain the necessary permissions.

You can join anytime of the year!

Did you find a bug?

  • Do not open up a GitHub issue if the bug is a security vulnerability, and instead send an email to ops@scottylabs.org.

  • Ensure the bug was not already reported by searching on GitHub under Issues.

  • If you’re unable to find an open issue addressing the problem, open a new one. Be sure to include a title and clear description and as much relevant information as possible.

Do you have an idea for a new feature?

  • Ensure the feature was not already proposed by searching on GitHub under Issues.

  • If you’re unable to find an open issue addressing the problem, open a new one. Be sure to include a title and description of the feature you want to add.

Do you want to contribute to the codebase?

Find an Issue

Start by looking through the open issues to find something you are interested in working on. Please avoid picking issues that are already assigned to someone.

  • Look for issues labeled good first issue. These are great entry points for new contributors.

If you don’t see something that interests you, feel free to open a new issue with your idea.

Note that a new contributor won’t be assigned to the issue until their PR is merged. This helps keep issues open for others who might also want to work on them, but we will try to not assign the same issue to multiple contributors over a short period of time.

Request Permission

Follow the README instructions in Governance to join a team and obtain the necessary permissions.

When opening your Governance PR, make sure to link the corresponding issue that you will be working on.

Setup and Develop

Sign up on Codeberg if you do not have an account. That page has SSH setup and optional commit signing. Repository-specific setup is in each project’s docs.

Submit a Pull Request and Get Credited as a Contributor

See PR Process.

Do you have questions?

Ask any question in the ScottyLabs Slack by messaging in the corresponding channel or DMing any maintainer of the project. You can find information about the Slack channels and maintainers of a project in Governance.

Join Us!

We encourage you to get involved and join the team!

Thanks!

ScottyLabs Team

Acknowledgments

This document was adapted from the Ruby on Rails contributing guide.

Labrador To Tech

Labrador vs Tech

New projects starts in Labrador and move to Tech after they are deployed. The Tech team typically requires more experience, as you will be working with an existing codebase.

Process of moving from Labrador to Tech

Make a PR in Governance with a video demo? Will be formally documented after CMU Study launches in Spring 2026.

Why should you move to Tech?

Reasons include but not limited to:

  • Domain name (e.g: cmumaps.com, cmucal.com, cmustudy.com)

  • Access to our internal documentation on Notion

  • Access to our services, including:

    • Apple Developer

    • Clerk

    • Cloudflare

    • Mailgun

    • Mailman

    • MinIO

    • MongoDB

    • OpenRouter

    • PostHog

    • Ops Email

    • Railway

    • Sentry

    • Sevalla

    • Slack

    • Vercel

    • Zapier

Projects

Public Documentation

See governance teams and each repository’s README and wiki.

Internal Documentations

See https://www.notion.so/wiki-scottylabs/Projects-23296192554c8005bbc0eb91a2888129

Project Wikis

  • CMU Maps Wiki: https://github.com/ScottyLabs/cmumaps/wiki

  • Governance Wiki: https://github.com/ScottyLabs/governance/wiki

Credentials

Hashicorp Vault

UI Login

You can login to the vault by pressing the “Sign in with OIDC Provider” button with Method “oidc”. Press “ScottyLabs” listed under “Secrets Engines” and navigate to the file you have permissions to access in your team’s folder to view the secrets. If you see the following error, it means that you are not in any ScottyLabs Vault group, so you are not able to log into the vault.

Well we don’t want any CMU student to use our Vault, right?

Vault access denied error

CLI

Replace tedious copy pasting with a single CLI command!

Run the following command at the root of your project to add the secrets sync scripts repo as a git submodule:

git submodule add git@github.com:ScottyLabs/secrets-sync-scripts.git scripts/secrets

If you cloned an existing repo with the git submodule already added, run the following command pull the submodule:

git submodule update --init --recursive --remote

Secret Metadata

Use it to document where the secret come from. One url for each needed secret.

Note

We are currently migrating to OpenBao for our secrets management. See OpenBao Secrets for the current setup.

OpenBao

See OpenBao Secrets for developer and infrastructure documentation.

VaultWarden

Use VaultWarden for storing login credentials that need to be accessed by leadership.

Permission

Owner: ops+vault@scottylabs.org

Admin: Exec + Head of DevOps

User: Leadership

Bitwarden

Use BitWarden for storing login credentials that will only be accessed by the Tech Leadership Maintainers.

The passwords to Bitwarden is meant to be stored locally in these individuals’ own password manager and may not be updated without updating all relevant people.

Emails

See internal Notion documentation

Github Orgs

We have two GitHub orgs:

As their names suggest, by default Tech committee projects will be in ScottyLabs and Labrador committee projects will be in ScottyLabs Labrador. Projects in both committees can opt into the other GitHub org as they wish.

Bus Sign

Prerequisites

  • Bun - JavaScript runtime and package manager
  • Cargo - Rust package manager and build system
  • PRT API Key - Obtained from creating a TrueTime account here

Setup

Setting up your environment variables

# Copy env variables from .env.example
$ cp .env.example .env

# Add your PRT_API_KEY to the .env file

Running the backend

$ cd backend

# Install dependencies and start the backend
backend $ cargo run

Running the frontend

$ cd frontend

# Install dependencies
frontend $ bun install

# Start the frontend
frontend $ bun dev

Cal

A unified web calendar for all CMU academic events

Cmugpt Agent

This project makes use of several excellent tools from Astral, including uv, ruff, and ty.

Setup

  1. Once you have installed uv, install dependencies with
uv sync
  1. Install the pre-commit hooks using
uv run pre-commit autoupdate
uv run pre-commit install --install-hooks
  1. VS Code will prompt you to install the recommended extensions, which you should accept. If you mistakenly closed it, you can find them in .vscode/extensions.json.

Usage

  • Format: uv run ruff format
  • Typecheck: uv run ty check
  • Lint: uv run ruff check

To run the FastAPI app locally with uv (the project uses uv for task execution), run:

uv run python src/main.py

You can set the PORT environment variable to change the listening port (defaults to 5000):

PORT=8080 uv run python src/main.py

Deployment (Kennel)

Production runs on Kennel via devenv and secretspec. Pushes to Codeberg main trigger deploys (GitHub mirror pushes do not).

URLs:

  • https://api.cmugpt-agent.scottylabs.org (custom domain)
  • https://cmugpt-agent-agent-main.scottylabs.net (default Kennel URL)

Validate locally before pushing:

SECRETSPEC_PROVIDER=dotenv://.env devenv build scottylabs.kennel.config
nix build .#packages.x86_64-linux.agent

Set production secrets (requires cmugpt-agent-admins group and bao login -method=oidc):

secretspec set -P prod OPENROUTER_API_KEY
secretspec set -P prod MCP_SERVER_URL
secretspec set -P prod AGENT_SHARED_SECRET
secretspec check -P prod

Guidelines

You should not globally disable rules enforced by ruff or ty. If absolutely necessary, you can ignore them on a line-by-line basis:

For ty, use ignore directives in the following order of precedence, based on what is strictly necessary.

  1. # ty: ignore[<rule>] for ignoring single rules
  2. # ty: ignore[rule1, rule2, ...] for ignoring multiple rules
  3. # type: ignore or # type: ignore[<rule>] for ignoring all violations on that line (even if a rule is specified!)
  4. The decorator @typing.no_type_check to suppress all violations inside a function

For ruff, follow the same pattern.

  1. # noqa: <rule> for ignoring single rules
  2. # noqa: rule1, rule2, ... for ignoring multiple rules
  3. # noqa for ignoring all violations on that line
  4. # ruff: noqa: <rule> for ignoring a specific rule across an entire file
  5. # ruff: noqa for ignoring all violations across an entire file

Cmugpt Sms Surface

SMS/iMessage companion feature for the CMUGPT Agent. Students send iMessages via BlueBubbles, authenticate with their CMU Andrew ID, and chat with the CMU campus assistant for event notifications, schedule planning, reminders, and orientation-week help.

Local setup

  1. Install dependencies with uv:
uv sync
  1. Copy the example environment file and fill in your values:
cp .env.example .env
  1. Run the service:
uv run python src/main.py

The app starts on http://localhost:8000 by default.

Development commands

  • Format: uv run ruff format
  • Lint: uv run ruff check
  • Typecheck: uv run ty check
  • Tests: uv run pytest

Architecture

sms-surface/
├── src/
│   ├── main.py          # FastAPI app + webhook routes
│   ├── config.py        # Environment-based settings
│   ├── sms_handler.py   # BlueBubbles inbound/outbound iMessage logic
│   ├── auth.py          # CMU Keycloak integration
│   ├── database.py      # DB session + engine
│   ├── agent_client.py  # Boundary with CMUGPT agent (API or direct import)
│   └── scheduler.py     # Reminders + event notifications

Planned integration with cmugpt-agent

  • Mirrors cmugpt-agent’s stack: Python 3.12, FastAPI, uv, ruff, ty.
  • Keeps all new code in this repo; cmugpt-agent/ is read-only context.
  • Talks to the agent through src/agent_client.py, which can be wired to either the agent’s HTTP API or a direct Python import once that decision is finalized.

Open questions before implementation

  1. Should SMS call the agent API or import the agent package directly?
  2. What database for production? (SQLite for MVP, then Postgres?)
  3. What “basic student info” should be saved?
  4. How does the CMU Keycloak auth flow work?
  5. Where does orientation/week event data come from?
  6. Should reminders be proactive or reactive in the MVP?

Cmugpt Surface

ScottyStack (ScottyLabs Tech Stack) is a full-stack typesafe template for building web applications.

Quickstart

See Quickstart Guide.

Documentation

See ScottyStack Wiki.

Components

This monorepo is ScottyLabs’ design system, providing design tokens and components for React and Svelte built from a shared Figma source.

The library is split across six packages:

  • @scottylabs/tokens generates the design tokens from Figma via Terrazzo, exposing them as CSS variables and TypeScript constants with both Light and Dark mode values
  • @scottylabs/styles contains the pre-compiled component CSS that references those tokens, using the sl- class prefix throughout
  • @scottylabs/variants holds the shared tailwind-variants configurations consumed by both framework packages
  • @scottylabs/types holds the shared TypeScript prop types so the React and Svelte public APIs stay in lockstep
  • @scottylabs/react is the React component layer, built on Radix UI primitives
  • @scottylabs/svelte is the Svelte component layer, built on Bits UI primitives

Four apps cover the publication surface:

  • apps/docs is the narrative documentation site, built with Astro Starlight
  • apps/storybook is a Storybook composition host that pulls in the framework-specific Storybooks via refs
  • apps/storybook-react and apps/storybook-svelte are those framework-specific Storybooks

The dev environment is devenv via ScottyLabs’ shared module, and deployment runs through kennel with site declarations in devenv.nix. See CONTRIBUTING.md for setup.

This project is dual-licensed under MIT or Apache-2.0 at your option.

Dalmatian

Dalmatian is a Discord bot designed for CMU students, providing easy access to campus resources like CMU Courses and CMU Eats right at your fingertips!

Getting Started

Prerequisites

  • devenv - Developer environment
  • direnv - shell extension (that we use for devenv)

(You’ll need Nix as well, but devenv gives you that command.)

Setup

For detailed setup instructions including creating a Discord bot, obtaining API credentials, and configuring your development environment, see docs/CONTRIBUTING.md.

Quick setup:

  1. Install devenv and direnv (see links above)
  2. Create a Discord bot at [https://discord.com/developers/applications]
  3. Get your DISCORD_TOKEN and DISCORD_CLIENT_ID

Running the Bot

# Set up environment variables (see CONTRIBUTING.md for details)
cp .env.example .env
# Edit .env with your Discord bot credentials

# Start the environment
devenv up

Deployment

Production runs on Kennel via devenv and secretspec.

Contributing

Please read CONTRIBUTING.md before you contribute to this project!

Contributing to Dalmatian

Thank you for your interest in contributing to Dalmatian! This guide will help you get started.

How to Contribute

  1. Fork the repository or create a new branch if you have write access
  2. Create a new branch from main with a descriptive name:
    git checkout -b your-feature-name
    # or
    git checkout -b bug-description
    
  3. Make your changes following the code style and conventions
  4. Test your changes locally by running the bot
  5. Commit using conventional commits (see below)
  6. Push to your fork or branch
  7. Open a Pull Request with a clear description of your changes

Conventional Commits

This project follows Conventional Commits.

Examples:

  • feat: add course search by instructor
  • fix: resolve dining hall location formatting issue
  • docs: update README installation steps
  • refactor: simplify embed pagination logic
  • chore: update dependencies to latest versions
  • style: format code with biome

Database Setup

Warning

This section is outdated and in need of attention. Please use this information with caution, and consider sending patches to update it.

The bot uses PostgreSQL for storing polls and reaction redirect configurations. The database runs in Docker for local development.

  1. Start the PostgreSQL database using Docker:

    docker-compose up -d postgres
    
  2. Run database migrations to create the tables:

    deno run db:migrate
    
  3. (Optional) Open Drizzle Studio to inspect the database:

    deno run db:studio
    

The database will persist data in a Docker volume. To completely reset the database, run:

docker-compose down -v
docker-compose up -d postgres
deno run db:migrate

Before Submitting

Before you commit and open a pull request, make sure to:

  • Run deno run lint and fix any errors/warnings
  • Run deno run format to format your code
  • Run deno run test to ensure all tests pass
  • Test your changes on your Discord bot by running devenv up
  • Ensure your commits follow the conventional commit format
  • Update documentation if you added/changed features

Pull Request Guidelines

  • Keep PRs focused - One feature or fix per pull request
  • Write clear descriptions - Explain what changed and why
  • Reference related issues - Use “Fixes #123” or “Closes #456” if applicable
  • Be responsive - Address review feedback promptly

Project Priorities & Planning

To understand current priorities, roadmap, and ongoing work:

Need Help?

If you have questions or need help:

  • Open an issue on GitHub
  • Check existing issues and pull requests for similar questions

Remember to follow conventional committing guidelines while contributing!

Setting up Dalmatian

Code Editor Setup

We recommend using VSCode, and the following setup guide will assume you are using VSCode.

Recommended VSCode extensions:

You will also need git installed.

Creating your .env file

Rename or copy .env.example into .env.

Create a new Discord bot or use one of your current ones.

In your application, under the Bot tab, reset your token and copy the token for DISCORD_TOKEN

Under the OAuth2 tab, grab the client ID for DISCORD_CLIENT_ID

The bot will work fine with only the DISCORD_TOKEN and DISCORD_CLIENT_ID keys.

Optionally, add GOOGLE_MAPS_API_KEY to .env to debug formatLocation in dining.ts (falls back from Vault to .env).

  1. Head to Google Cloud Console and create a new project.
  2. In APIs & Services, enable the Maps Javascript API and Maps Static API products.
  3. Get a key from Keys & Credentials to input into GOOGLE_MAPS_API_KEY.

Discord Verify

An Andrew ID Verification bot!

Development

# Set up environment variables
cp .env.example .env
# Edit .env with your personal Discord bot credentials

Create a new Discord bot or use one of your current ones, and put its token in .env. Everything else resolves from Vault when you enter the dev shell.

Data Model

# Guild Configuration
guild:{guild_id}:log_channel                  -> string (channel_id)
guild:{guild_id}:role:verified                -> string (role_id)
guild:{guild_id}:role:unverified              -> string (role_id)
guild:{guild_id}:role:level:Undergrad         -> string (role_id)
guild:{guild_id}:role:level:Graduate          -> string (role_id)
guild:{guild_id}:role:class:First-Year        -> string (role_id)
guild:{guild_id}:role:class:Sophomore         -> string (role_id)
guild:{guild_id}:role:class:Junior            -> string (role_id)
guild:{guild_id}:role:class:Senior            -> string (role_id)
guild:{guild_id}:role:class:Fifth-Year Senior -> string (role_id)
guild:{guild_id}:role:class:Masters           -> string (role_id)
guild:{guild_id}:role:class:Doctoral          -> string (role_id)

# Role assignment mode
guild:{guild_id}:role_mode                    -> string ("none" | "levels" | "classes" | "custom")
guild:{guild_id}:custom_levels                -> set (enabled level names)
guild:{guild_id}:custom_classes               -> set (enabled class names)

# User Verification Mappings
discord:{discord_id}:keycloak                 -> string (keycloak_id)
discord:{discord_id}:verified_at              -> string (unix_timestamp)
keycloak:{keycloak_id}:discord                -> string (discord_id)

# Temporary Verification State (TTL: 10 minutes)
verify:{state_token}                          -> json (PendingVerification)

Documentation

The single source of truth for all ScottyLabs documentation.

Replaces Notion, Discord pins, Google Drive docs, and scattered README files with a unified, searchable, automatically-updated documentation platform. Integrates with ScottyLabs governance to automatically pull documentation from projects marked with the docs flag.

Vision

Every ScottyLabs project, guide, process, and resource in one place:

  • Project Documentation: Automatically aggregated from repos with docs: true in governance
  • Org-Level Documentation: Central repository for organization-wide guides, processes, and resources
  • API References: Interactive documentation for all APIs (OpenAPI/Scalar)
  • Code Documentation: Auto-generated rustdoc for Rust projects
  • Institutional Knowledge: Onboarding, meeting notes, decision records - everything previously scattered across Notion/Discord

Features

  • Governance integration: Projects marked with docs: true flag are automatically included
  • Multi-repo aggregation: Clone and merge documentation from all flagged projects
  • Central org docs: Dedicated repository for ScottyLabs-wide documentation
  • OpenAPI support: Interactive API documentation with Scalar
  • Rustdoc integration: Automatic rustdoc generation and hosting
  • Full-text search: Find anything across all projects (powered by Pagefind)
  • AI agent access: Pages serve Markdown via Accept: text/markdown (Accept Markdown)
  • CI/CD ready: Rebuilds automatically when any project updates docs
  • Nix-powered: Reproducible builds and deployment via Nix flake

AI / LLM access

The docs site supports Accept Markdown content negotiation. AI agents can read any page as clean Markdown from the same URL browsers use for HTML:

# Canonical page (preferred)
curl -sI -H "Accept: text/markdown" https://docs.scottylabs.org/scottylabs/onboarding/contributing/
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept

curl -s -H "Accept: text/markdown" https://docs.scottylabs.org/scottylabs/onboarding/contributing/

Legacy URLs (e.g. /scottylabs/contributing/) return Markdown redirect stubs pointing to the canonical path.

At build time, the site exports a Markdown counterpart for every HTML page. Caddy on infra-01 must negotiate Accept: text/markdown at the edge and rewrite requests to the matching .md file in Garage. The docs CI upload alone is not enough.

Verify negotiation is live (both checks should pass after infra deploy):

# Should include: Vary: Accept
curl -sI -H "Accept: text/html" https://docs.scottylabs.org/scottylabs/onboarding/contributing/

# Should include: Content-Type: text/markdown and Vary: Accept (not text/html)
curl -sI -H "Accept: text/markdown" https://docs.scottylabs.org/scottylabs/onboarding/contributing/

If the second request still returns content-type: text/html with no Vary: Accept, apply the docs.scottylabs.org Caddy config in infrastructure/hosts/infra-01/garage.nix on infra-01 (nixos-rebuild switch).

Markdown files are always available at the sibling index.md path as a fallback, e.g. https://docs.scottylabs.org/scottylabs/onboarding/contributing/index.md.

Architecture

flowchart TB
    governance[Governance YAML<br/>docs: true flag] --> discover[Project Discovery]
    central[Central Docs Repo<br/>org-wide content] --> discover
    discover --> manifest[projects.toml]
    manifest --> build[Build Script]
    repos[Project Repos] --> build
    build --> starlight[Starlight Pages]
    build --> scalar[Scalar API Docs]
    build --> rustdoc[Rustdoc Sites]
    starlight --> site[Unified Site<br/>Single Source of Truth]
    scalar --> site
    rustdoc --> site
    site --> deploy[Garage S3]
    
    notion[❌ Notion] -.replaced by.-> site
    discord[❌ Discord] -.replaced by.-> site
    gdrive[❌ Google Drive] -.replaced by.-> site

Content Sources

  1. Central Org Docs (scottylabs-docs repo)

    • Onboarding guides
    • Organization processes and policies
    • Meeting notes and decision records
    • Event planning guides
    • Infrastructure documentation
  2. Project Docs (from repos with docs: true in governance)

    • Starlight - Markdown documentation that integrates into main navigation
    • Rust - Runs cargo doc, hosts at /{slug}/api/
    • OpenAPI - Generates Scalar-rendered interactive API reference

Automatic Updates

The documentation hub automatically rebuilds when governance changes. See .forgejo/README.md for setup instructions.

What triggers a rebuild:

  • Changes to data/ in the governance repository
  • Direct pushes to this repository
  • Manual workflow dispatch

Setup required in governance repository:

  1. Add access token secret: DOCS_TRIGGER_TOKEN
  2. Add workflow file: .forgejo/workflows/trigger-docs-rebuild.yml (see .forgejo/examples/trigger-docs-rebuild.yml)

Once configured, any change to governance (adding/removing docs = true flags, updating descriptions, etc.) will automatically trigger a documentation rebuild and deployment.

Quick Start

Prerequisites

  • Bun v1.0+
  • Nix (optional, for reproducible builds)
  • Git

Installation

# Clone the repository
git clone https://codeberg.org/scottylabs/documentation.git
cd documentation

# Install dependencies
bun install

# Enter development shell (Nix users)
nix develop

Governance Integration

Projects are automatically discovered from the ScottyLabs governance repository. When a repository has docs = true in its governance entry (same pattern as kennel and sentry flags), it’s included in the documentation hub.

To add your project’s documentation:

  1. In the governance repository (data/ directory), add docs = true to your repository entry:

    # data/my-team.toml
    [[team.projects]]
    name = "My Project"
    slug = "my-project"
    
    [[team.projects.repos]]
    name = "my-project-backend"
    description = "Backend for My Project"
    kennel = true
    docs = true  # <-- Add this flag (same level as kennel/sentry)
    
  2. Ensure your repository has a docs/ directory with markdown files

  3. The documentation hub will automatically pick it up on the next build

Optional configuration:

[[team.projects.repos]]
name = "my-api"
docs = true
docs_type = "openapi"  # or "rust" or "starlight" (default)
docs_dir = "documentation"  # custom docs directory
openapi_spec = "openapi.json"  # for OpenAPI projects
export_command = "cargo run --bin export-openapi"

Manual override: You can also manually add projects to projects.toml:

[[project]]
slug = "my-project"
name = "My Project"
repo = "https://codeberg.org/scottylabs/my-project"
type = "starlight"
docs_dir = "docs"
description = "Documentation for My Project"

Starlight Project Example

[[project]]
slug = "guides"
name = "User Guides"
repo = "https://codeberg.org/scottylabs/guides"
type = "starlight"
docs_dir = "docs"
description = "Comprehensive guides for all ScottyLabs services"

Rust Project Example

[[project]]
slug = "common-lib"
name = "Common Library"
repo = "https://codeberg.org/scottylabs/common-lib"
type = "rust"
docs_dir = "docs"
description = "Shared Rust utilities and types"

OpenAPI Project Example

[[project]]
slug = "courses-api"
name = "Courses API"
repo = "https://codeberg.org/scottylabs/courses-backend"
type = "openapi"
docs_dir = "docs"
openapi_spec = "openapi.json"
export_command = "cargo run --bin export-openapi"
description = "Course scheduling and registration API"

Development

# Build documentation from all projects
bun run build

# Start development server
bun run dev

# Clean build artifacts
bun run scripts/build.ts clean

Build Pipeline

The build process follows these steps:

  1. Parse manifest - Read projects.toml to get project list
  2. Clone repos - Parallel git clone into .repos/{slug}/
  3. Process by type:
    • Starlight: Copy markdown to src/content/docs/{slug}/
    • Rust: Run cargo doc, copy to public/{slug}/api/
    • OpenAPI: Export spec, generate Scalar page
  4. Generate nav - Build dynamic Starlight sidebar
  5. Build site - Run astro build

Project Structure

documentation/
├── astro.config.mjs       # Starlight configuration
├── package.json           # Dependencies
├── projects.toml          # Project manifest
├── flake.nix              # Nix development environment
├── .forgejo/
│   ├── README.md          # Forgejo integration (governance + diagram triggers)
│   ├── workflows/
│   │   └── deploy.yml     # CI/CD pipeline
│   ├── examples/
│   │   ├── trigger-docs-rebuild.yml    # Copy to governance repo
│   │   └── trigger-docs-diagrams.yml   # Copy to project repos
│   └── scripts/
│       └── dispatch-rebuild.sh
├── scripts/
│   ├── build.ts           # Main build orchestrator
│   ├── manifest.ts        # TOML parsing
│   ├── clone-repos.ts     # Git operations
│   ├── aggregate-docs.ts  # Content aggregation
│   ├── scalar-integration.ts  # OpenAPI handling
│   ├── rustdoc.ts         # Rust documentation
│   └── generate-nav.ts    # Navigation generation
├── src/
│   ├── content/
│   │   ├── config.ts      # Content collections
│   │   └── docs/          # Documentation pages
│   ├── pages/
│   │   └── [slug]/
│   │       └── api.astro  # Dynamic API pages
│   └── styles/
│       └── scalar-theme.css
└── .repos/                # Cloned repos (gitignored)

CI/CD

Automated Builds

The documentation hub rebuilds automatically on:

  1. Direct commits to the documentation repository
  2. Governance changes via repository dispatch (when governance data/ changes)
  3. Manual triggers via workflow dispatch

Governance Integration

To enable automatic rebuilds when governance changes, add the trigger workflow to the governance repository. See .forgejo/README.md for complete setup instructions.

Quick setup:

# In governance repository
mkdir -p .forgejo/workflows
cp /path/to/documentation/.forgejo/examples/trigger-docs-rebuild.yml \
   .forgejo/workflows/trigger-docs-rebuild.yml

# Add secret DOCS_TRIGGER_TOKEN to governance repo
# (see .forgejo/README.md for details)

Forgejo Actions

The included workflow automatically:

  1. Checks out the repository
  2. Installs dependencies with Bun
  3. Runs the build script
  4. Uploads artifacts
  5. Deploys to Garage S3 (on main branch)

Required Secrets

Configure these in your Forgejo repository settings:

  • GARAGE_ENDPOINT - S3 endpoint URL
  • GARAGE_ACCESS_KEY - S3 access key
  • GARAGE_SECRET_KEY - S3 secret key

The bucket name is configured in the workflow: scottylabs-docs

Manual Deployment

# Using Nix
nix run .#upload-garage

# Or directly with environment variables
export GARAGE_ENDPOINT="https://s3.example.com"
export GARAGE_ACCESS_KEY="your-access-key"
export GARAGE_SECRET_KEY="your-secret-key"
export GARAGE_BUCKET="scottylabs-docs"
nix run .#upload-garage

Project Guidelines

Documentation Structure

For projects contributing Starlight documentation:

your-project/
└── docs/
    ├── index.md           # Landing page
    ├── getting-started.md
    ├── guides/
    │   ├── installation.md
    │   └── configuration.md
    └── api/
        └── reference.md

Frontmatter

Standard Starlight frontmatter is supported:

---
title: Page Title
description: Page description for SEO
---

# Page Title

Content here...

The build system automatically adds:

  • project: The project slug
  • projectType: The project type (starlight/rust/openapi)

OpenAPI Export

For OpenAPI projects, ensure your export command:

  1. Runs without starting a server
  2. Writes to the path specified in openapi_spec
  3. Generates valid OpenAPI 3.0+ JSON

Example Rust implementation with utoipa:

// bin/export-openapi.rs
use utoipa::OpenApi;
use std::fs;

#[tokio::main]
async fn main() {
    let doc = ApiDoc::openapi();
    fs::write(
        "openapi.json",
        serde_json::to_string_pretty(&doc).unwrap()
    ).unwrap();
}

Why This Approach?

Replacing Scattered Documentation

Before:

  • Notion: Onboarding guides, meeting notes, processes (hard to search, requires account)
  • Discord: Pinned messages, FAQs (ephemeral, poor discoverability)
  • Google Drive: Shared docs (siloed, inconsistent permissions)
  • README files: Scattered across 30+ repos (no central search)
  • Tribal knowledge: In people’s heads or DMs

After:

  • One URL: docs.scottylabs.org
  • Full-text search: Find anything across all projects
  • Always up-to-date: Rebuilds on every commit
  • No account needed: Public, accessible, linkable
  • Git-based: Version controlled, reviewable, forkable

Governance Integration

Projects use a simple docs: true flag (same pattern as kennel: true):

  • Automatic discovery: No manual manifest maintenance
  • Consistent with existing workflows: Same governance system
  • Self-service: Project maintainers control their own docs
  • Audit trail: Changes tracked in governance repo

Why custom aggregation vs a plugin?

No mature multi-repo plugin exists for Starlight (unlike mkdocs-monorepo-plugin). A ~200 LOC build script provides:

  • Full control over navigation structure
  • Integration with governance system
  • Better build caching
  • Type-safe TypeScript implementation
  • Equivalent UX to established plugins

Why sibling rustdoc vs embedded?

Rustdoc generates a complete static site with its own theme, search, and navigation. Embedding would require:

  • Fragile iframe hacks
  • JSON-to-markdown conversion (lossy)
  • Custom theming to match (high maintenance)

The sibling pattern (/{slug}/api/) is the industry standard (docs.rs, tokio.rs, axum.rs, etc.)

Why Scalar vs alternatives?

Compared to Swagger UI and Redoc:

  • Better UX: Modern design, fast rendering
  • More features: Try It, code generation, dark mode
  • Better integration: First-party Astro component
  • Active development: 14K+ stars, regular releases

Troubleshooting

Build fails with “Project missing required field”

Check that all required fields are present in projects.toml:

  • slug, name, repo, type, docs_dir, description

For OpenAPI projects, also ensure:

  • openapi_spec is set
  • export_command is provided (if spec isn’t pre-generated)

Rustdoc not appearing

Ensure:

  1. Project type is set to "rust"
  2. Repository contains a valid Cargo workspace/package
  3. cargo doc runs successfully in the project

Check build logs for cargo errors.

The navigation is regenerated on each build. If changes aren’t appearing:

  1. Clean build artifacts: bun run scripts/build.ts clean
  2. Rebuild: bun run build
  3. Check that markdown files have correct file extensions (.md or .mdx)

Contributing

Adding Your Project

  1. Fork this repository
  2. Add your project to projects.toml
  3. Ensure your project has documentation in the specified docs_dir
  4. Test locally: bun run build && bun run dev
  5. Submit a pull request

Improving the Hub

Contributions to the documentation hub itself are welcome:

  • Build script improvements
  • Theme enhancements
  • Additional project type support
  • Documentation improvements

License

MIT License - see LICENSE file for details

Support

For questions or issues:

  • Open an issue on Codeberg
  • Ask in the ScottyLabs Discord
  • Email: tech@scottylabs.org

Contributing

Project documentation

Put markdown in a docs/ directory at the root of your repository. Use frontmatter for sidebar titles:

---
title: My Page
---

# My Page

Enable the repo in governance (included by default). Use docs = false to opt out. See Documentation Hub for the full workflow.

AGENTS.md files (AI agent context per agents.md) are not aggregated or published. Put human-facing docs in other markdown files.

Published pages are readable by AI agents via Accept Markdown. Request any docs URL with Accept: text/markdown to receive Markdown from the same URL browsers use for HTML.

Excalidraw diagrams

Place .excalidraw.json files in docs/diagrams/ in your repo. They are published at /diagrams/{your-project-slug}/ and can be embedded with the hub’s ExcalidrawDiagram component (see Diagramming).

Pushes that change docs/ trigger a docs rebuild via docs-updated; changes to diagrams/ or scripts/generate-*-excalidraw.ts use diagrams-updated. Both are handled automatically by the org webhook on infra-01 (repos with docs = true in governance). Per-repo fallback: copy .forgejo/examples/trigger-docs-update.yml or .forgejo/examples/trigger-docs-diagrams.yml with the DOCS_TRIGGER_TOKEN secret.

Local development

Run bun run dev; it fetches docs from source repos before starting the dev server. In a monorepo checkout, sibling repos (e.g. ../infrastructure) are used automatically.

Hub documentation

Pages in this repository’s docs/ folder (not src/content/docs/) are aggregated into the Documentation section. Edit those files for meta-docs about the hub itself: deployment, architecture, contributing.

Site chrome (home page, getting started) lives in src/content/docs/ and is not pulled from governance.

Governance

This repository is the source of truth for the Tech Committee’s governance model. It declaratively manages teams, repositories, and membership using OpenTofu and Atlantis.

Joining a team

  1. Link all available accounts in Keycloak.
  2. Add your Codeberg username to the members array in the desired team .toml file under data/.
  3. Open a PR using a conventional PR title.

Note that only team leads are allowed to modify other people’s memberships.

Creating a team

Teams are groups of leads, members, repositories, and channels. They can nest sub-projects recursively, each with the same shape. Copy an existing file in data/teams/ for a working starting point.

Reference the team schema for an authoritative list of fields and their constraints.

Features

Each repository opts into capabilities through its features table. Presence enables a feature, an empty table enables it with defaults, and features with settings take them as keys:

[[team.repos]]
name = "collie"
features = { kennel = {}, sentry = {} }

[team.repos.features.ai_gateway]
prod_monthly_budget = 20.0
  • kennel adds a Forgejo webhook that connects the repository to kennel for builds and deployments
  • sentry creates a Sentry project and writes its DSN to Vault
  • posthog creates a PostHog project and writes its key and host to Vault
  • cdn creates a public-read Garage bucket for the repository and writes its S3 credentials and public URL to Vault
  • oidc_client provisions prod and staging Keycloak OIDC clients with a fixed redirect URI and writes their credentials to Vault per profile; set admin = true to also provision a service-account client with user-management roles and write its credentials to Vault
  • ai_gateway provisions LiteLLM API keys with monthly budgets, a prod key and a lower-budget key shared by staging, preview, and dev, and writes the key and gateway URL to Vault per profile
  • docs registers the repository’s docs/ directory with the documentation hub

How a project declares and consumes what these provision lives in the kennel docs: Deploying a Project and Secrets.

Description

The following is a list of platforms Governance manages:

  1. Keycloak
    • Members are added to their team’s Keycloak groups, which gives them permission to access environment variables and other project-specific resources
    • Team leads are further added to the team’s admins subgroup, which gives additional access
    • For projects with it enabled, OIDC clients are provisioned
  2. OpenBao
    • Keycloak groups are given the appropriate access to secret paths on OpenBao
  3. Codeberg
    • Members are added to their Codeberg teams, which gives them appropriate access to the team’s repositories
    • Codeberg repositories are set up to automatically sync to GitHub for visibility
  4. Google
    • Members are automatically added to ScottyLabs’ and Tech’s mailing lists (Google Groups)
  5. Sentry
    • Projects are provisioned under Sentry
  6. PostHog
    • Projects are provisioned under PostHog for product analytics
    • Leads of teams with a PostHog project are invited as organization members, and devops as owners
  7. LiteLLM
    • Repositories with the AI gateway enabled receive budgeted API keys under their team, written to OpenBao per profile
  8. Kennel
    • Repositories automatically receive a deploy webhook that authorizes them to be deployed by kennel
  9. Website
    • Groups with a public_url are published to the scottylabs.org project catalog
  10. Discord and Slack
    • Members are added to the appropriate channels on both platforms
    • On Discord, members are assigned the Tech role and their team’s roles, and team leads additionally receive the Tech Lead role
    • Bidirectional sync is established between registered Discord and Slack channels via Matrix
  11. Vaultwarden
    • Members are given the appropriate access to account credentials on Vaultwarden

Here, “appropriate access” serves to delineate between member permissions and team lead permissions.

Groupme Mirror

Mirror messages from a GroupMe to a Discord webhook.

Usage

The following should be in the environment:

DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz
GROUPME_ACCESS_TOKEN=abcdefghijklmnopqrstuvwxyz1234567890
GROUP_ID=1234567890

You can get your access token from Access Token button at the top right of the the GroupMe developer portal. You can get the group ID from the share URL of the group.

Housing

The CMU Housing project hosted at https://cmuhousing.com serves as the obvious choice for CMU students to look for Housing. Search for the perfect dorm, explore ratings and reviews from real students, and find your roommate all in one website.

Contributing

Please read CONTRIBUTING.md before you contribute to this project!

Contributing to CMU Housing

Thank you for your interest in contributing to CMU Housing! This guide will help you get started.

Setup

Developers should add themselves to the cmu-housing team in governance following the instructions in its README. This gives you access to secrets and permission to create branches on the repo.

How to Contribute

  1. Create a new branch from latest main with a descriptive name:

    git fetch origin main
    git switch main
    git reset --hard origin/main
    
    git checkout -b feat/your-feature
    # or
    git checkout -b fix/the-bug
    
  2. Make your changes following the code style and conventions

  3. Test your changes locally by running the project. See README.md for more instructions on running the project.

  4. Commit using conventional commits (see below)

    git add .
    # then
    git commit -m "Your commit message"
    
  5. Push to your fork or branch

    # if first branch push
    git push --set-upstream origin your-branch-name
    
    # otherwise
    git push
    
  6. Open a Pull Request with a clear description of your changes. You can do this by going to the link provided in the push terminal response or by visiting the repo’s homepage on Codeberg.

Conventional Commits

This project follows Conventional Commits.

Examples:

  • feat: add course search by instructor
  • fix: resolve dining hall location formatting issue
  • docs: update README installation steps
  • refactor: simplify embed pagination logic
  • chore: update dependencies to latest versions
  • style: format code with biome

Code Editor Setup

We recommend using VS Code, and the following setup guide will assume you are using VS Code.

Recommended VS Code extensions:

You will also need git installed.

Local Development

Prerequisites: devenv.

Start the shared infrastructure (postgres, ricochet):

devenv up

Frontend, in a separate terminal:

cd apps/frontend
deno task dev

Backend, in a separate terminal inside the devenv shell:

cd apps/backend
PORT=3001 deno task dev

Before Submitting

Before you commit and open a pull request, make sure to:

  • Test locally with your changes (devenv up and then deno task dev in apps/frontend)
  • Update documentation if you added/changed features

Pull Request Guidelines

  • Keep PRs focused - One feature or fix per pull request
  • Write clear descriptions - Explain what changed and why
  • Reference related issues - Use “Fixes #123” or “Closes #456” if applicable
  • Be responsive - Address review feedback promptly

Project Priorities & Planning

To understand current priorities, roadmap, and ongoing work:

  • Visit the CMU Housing Development project
    • Pick an issue from the board and assign it to yourself.
    • Use Priority and Size labels to choose based on what you can handle in a timely fashion.
  • If you cannot access the board, ask a maintainer to add you to the ScottyLabs organization.

Need Help?

If you have questions or need help:

  • Check existing issues and pull requests for similar questions
  • Check resources below for help on issue subject
  • Reach out to leadership with questions

Project Resources

Points of Contact

  • Project Lead: Nikhil (@ecstaticpilot)
  • Advisors: Max (@tentype) and John (@gostmeaper)
  • Outreach/ResEd Contact: John (@gostmeaper)
  • Senate Contact: Sanjeev (@blender1778)
  • DevOps: Ryan (@thesuperrl)

Remember to follow conventional committing guidelines while contributing!

Building Data Codebook

Field-by-field reference for apps/frontend/src/data/buildingTypes.ts, buildings.json, and tags.tsx.

Building data tree

Where to find each piece of information on an exported Building object:

Building
├─ id                  string
├─ name                string
├─ media
│  ├─ mainImage        string
│  ├─ icon?            string
│  ├─ photos[]
│  │  ├─ link          string
│  │  └─ description   string
│  └─ floorPlans[]
│     ├─ link              string
│     ├─ description       string
│     ├─ category          "roomType" | "floor"
│     └─ virtualTourLink?  string
├─ amenities
│  ├─ roomTypes        RoomType[]
│  ├─ bathrooms
│  │  ├─ types         BathroomType[]
│  │  └─ details?      string
│  ├─ ac
│  │  ├─ level         ACLevel
│  │  └─ details?      string
│  ├─ kitchen
│  │  ├─ scope         KitchenScope
│  │  └─ details?      string
│  ├─ laundry
│  │  ├─ location      LaundryLocation
│  │  └─ details?      string
│  ├─ commonAreas
│  │  ├─ hasLounge     boolean
│  │  └─ details?      string
│  ├─ gym
│  │  ├─ available     boolean
│  │  └─ details?      string
│  └─ genderHousing    GenderHousing
├─ accessibility
│  ├─ wheelchairAccessible   boolean
│  ├─ serviceAnimalFriendly  boolean
│  ├─ groundFloorRooms       boolean
│  └─ strobeAlarm            boolean
├─ atmosphere
│  ├─ socialness?      number (1-5)
│  └─ noiseLevel?      number (1-5)
├─ location
│  ├─ latitude?        number
│  ├─ longitude?       number
│  ├─ closeBuildings[] string (building ids)
│  └─ note?            string
└─ editorialTags?[]    string

Enums

All enums below are plain numeric TypeScript enums (no explicit string values). The # column is the value stored in buildings.json and returned by the enum at runtime.

RoomType

#MemberMeaning
0TradSingleTraditional-style single, shared hallway bathroom.
1TradDoubleTraditional-style double, shared hallway bathroom.
2TradTripleTraditional-style triple, shared hallway bathroom.
3SemiSuiteSingleSemi-suite single, bathroom shared with an adjacent suite.
4SemiSuiteDoubleSemi-suite double, bathroom shared with an adjacent suite.
5SemiSuiteTripleSemi-suite triple, bathroom shared with an adjacent suite.
6SemiSuiteQuadSemi-suite, four occupants.
7ApartmentTripleApartment-style triple.
8StudioApartmentSingleStudio apartment, single occupant.
9StudioApartmentDoubleStudio apartment, two occupants.

BathroomType

#MemberMeaning
0CommunalShared per floor/wing, traditional style.
1SharedSuiteShared with one adjacent suite, semi-suite style.
2PrivateTruly en-suite / in-room, apartment style only.

ACLevel

#MemberMeaning
0NoneNo AC.
1ByNecessityAccommodation, triple, or lottery-only AC.
2WindowWindow units, not central.
3CentralFull central AC.

LaundryLocation

#MemberMeaning
0NoneNo laundry.
1BasementBasement only.
2EachFloorLaundry on every floor.
3InUnitIn-unit washer/dryer.

KitchenScope

#MemberMeaning
0NoneNo kitchen access.
1SharedCommunal, building or floor level; details says which.
2InUnitKitchenette in the room (“en suite kitchen”).

Floor-vs-building distinctions for Shared live in the details string, not as a separate enum value.

GenderHousing

#MemberMeaning
0CoEdCo-ed housing.
1WomenOnlyWomen only.
2MenOnlyMen only.
3GenderInclusiveGender-inclusive housing.

“Value + details” wrapper pattern

Bathrooms, AirConditioning, Kitchen, Laundry, CommonAreas, Gym all follow one pattern: a comparable/filterable value (enum, array, or boolean) plus an optional details string for a freeform human blurb. Every attribute that needs to be both compared and described gets this shape.

  • Bathrooms.types is an array so a building with more than one bathroom style lists all of them.
  • CommonAreas.hasLounge backs the “Common areas” filter.
  • Gym.available is a plain boolean for filtering; details carries the description.

Grouped types

  • AmenityData holds everything the filter/survey/comparison UI reads: room types, bathrooms, AC, kitchen, laundry, common areas, gym, gender housing.
  • Accessibility holds wheelchairAccessible, serviceAnimalFriendly, groundFloorRooms, strobeAlarm (strobe fire alarm & doorbell). Filled in per building as data becomes available.
  • Atmosphere holds socialness / noiseLevel, 1-5 scales matching the survey sliders and review table. Both are optional since a building may not have data yet.
  • Location holds latitude/longitude for a “distance from landmark” filter (no distance value is stored on the building itself), closeBuildings (a list of building ids for the “Closest Buildings” detail card), and an optional note for a human-written blurb.
  • GalleryImage is link + description, one per photo.
  • FloorPlan is link + description + category ("roomType" | "floor") + optional virtualTourLink, which points to a walkthrough for that specific floor plan.
  • Media holds mainImage, optional icon, photos[], and floorPlans[].
  • Building is the top-level shape: id, name, media, amenities, accessibility, atmosphere, location, and optional editorialTags for hand-authored tags with no structured source.

Tag derivation

deriveTags(building) in tags.tsx computes a building’s tag ids from its structured fields, so a tag can never drift out of sync with the data it is based on:

Tag idDerived from
noKitchenamenities.kitchen.scope === KitchenScope.None
limitedACamenities.ac.level === ACLevel.ByNecessity
noCentralACamenities.ac.level is neither None nor Central
basementLaundryamenities.laundry.location === LaundryLocation.Basement
gymAccessamenities.gym.available
girlsOnlyamenities.genderHousing === GenderHousing.WomenOnly
lgbtqInclusiveamenities.genderHousing === GenderHousing.GenderInclusive

editorialTags on a building are appended as-is, for a tag with no structured backing.

Data loading

buildings.ts imports buildings.json and casts it to Building[]. buildingTypes.ts holds every type and enum above, and is the single import source for the context, filter, survey, and comparison consumers.

Database Codebook

Our project uses PostGres SQL DB to serve our frontend with data storage and migrations. This document serves as a guide, or codebook, on what each value and data point is from our DB.

Schema is defined with Drizzle ORM in apps/backend/src/db/schema.ts. Drizzle generates TypeScript types from these table definitions, so the shapes below stay in sync with the code as long as the schema file is the source of truth.

Schema Diagram

Below is the working diagram made for our database schema which is also additionally produced in Lucid Chart.

erDiagram
    user {
        int id PK
        string name
        string andrew_id
        string oidc_subject
        timestamp created_time
    }
    user_preferences {
        int id PK
        int user_id FK
        string preferred_gender_housing
        string year
        string major
        int cooking_frequency
        int gym_frequency
        int productive_around_others
        int needs_alone_time
        int social_frequency
        string_array goals
        string_array accommodations
        string_array preferred_amenities
        timestamp updated_at
    }
    roommate_profile {
        int id PK
        int user_id FK
        boolean is_visible
        enum status
        boolean committed
        string where_from
        string school
        string intended_major
        string preferred_roommate_school
        string assigned_sex
        string pronouns
        string bathroom_preference
        string wake_time
        string sleep_time
        boolean snores
        string morning_prep_time
        string preferred_shower_time
        int neatness
        int volume_preference
        int social_energy
        int party_frequency
        boolean alcohol
        boolean drugs
        json extras
        timestamp updated_at
    }
    dorm {
        int id PK
        string name
        string image_url
        string_array close_buildings
        boolean has_ac
        string ac_details
        string kitchen_description
        string lounge_description
        string bathroom_type
        string bathroom_details
        string_array room_types
        string_array tags
        json photo_gallery
        numeric latitude
        numeric longitude
        timestamp updated_at
    }
    review {
        int id PK
        int user_id FK
        int dorm_id FK
        string body
        int rating_overall
        int rating_amenities
        int rating_room_quality
        int rating_atmosphere
        string lived_year
        string lived_term
        timestamp submitted_at
    }
    connection {
        int id PK
        int user_id FK
        string provider
        string handle
    }
    group {
        string id PK
        timestamp created_time
    }
    membership {
        int id PK
        string group_id FK
        int user_id FK
        enum role
    }
    invitation {
        string id PK
        int sender_id FK
        int receiver_id FK
        string group_id FK
        string message
        enum status
    }
 
    user ||--o| user_preferences : has
    user ||--o| roommate_profile : has
    user ||--o{ review : writes
    dorm ||--o{ review : "reviewed in"
    user ||--o{ connection : has
    user ||--o{ membership : has
    group ||--o{ membership : has
    group ||--o{ invitation : "scoped to"
    user ||--o{ invitation : sends
    user ||--o{ invitation : receives

Note: connection, group, membership, and invitation are not yet defined in apps/backend/src/db/schema.ts or in this documentation as they will be made and used when we create the Roomies.live OpenAPI. They’re included above to match the current lucid chart diagram, but the Tables and TypeScript types sections below only cover the five tables that actually exist in the Drizzle schema currently (user, user_preferences, roommate_profile, dorm, review). Once those four tables are added to schema.ts, this doc will be updated with their column/type details too.

Enums

roommate_status

Backing type for roommate_profile.status.

ValueMeaning
searchingUser is actively looking for a roommate.
committedUser has locked in a roommate/room situation.
inactiveUser is not currently participating in roommate matching.

Tables

user

Core account record. Every other table hangs off of user.id.

ColumnDB typeNullableNotes
idserialNo (PK)Auto-incrementing primary key.
andrew_idtextYesCMU AndrewID for the account.
created_timetimestampYesWhen the account was created.
nametextYesDisplay name.
oidc_subjecttextYesSubject claim from the OIDC identity provider (CMU SSO), used to link the login to this row.

user_preferences

One-to-one extension of user holding lifestyle/roommate-matching preferences.

ColumnDB typeNullableNotes
idserialNo (PK)Auto-incrementing primary key.
user_idintegerNo (FK -> user.id)Owning user.
accommodationstext[]YesList of accessibility/accommodation needs.
cooking_frequencyintegerYesSelf-reported frequency scale (e.g. times per week).
goalstext[]YesFree-text goals for housing/roommate search.
gym_frequencyintegerYesSelf-reported frequency scale.
majortextYesAcademic major.
needs_alone_timeintegerYesSelf-reported scale of how much alone time is needed.
preferred_amenitiestext[]YesDesired building/room amenities.
preferred_gender_housingtextYesPreferred gender composition for housing.
productive_around_othersintegerYesSelf-reported scale of productivity with others present.
social_frequencyintegerYesSelf-reported social activity scale.
updated_attimestampYesLast time preferences were edited.
yeartextYesClass year (e.g. Freshman, Sophomore).

roommate_profile

One-to-one extension of user holding the public-facing roommate-matching profile.

ColumnDB typeNullableNotes
idserialNo (PK)Auto-incrementing primary key.
user_idintegerNo (FK -> user.id)Owning user.
alcoholbooleanYesWhether the user drinks alcohol.
assigned_sextextYesAssigned sex, used for housing-eligibility matching.
bathroom_preferencetextYesPreferred bathroom arrangement.
committedbooleanYesWhether the user has already committed to a roommate.
drugsbooleanYesWhether the user uses drugs.
extrasjsonYesFree-form additional profile data not modeled as columns.
intended_majortextYesIntended/declared major shown on the profile.
is_visiblebooleanYesWhether the profile is visible in roommate search.
morning_prep_timetextYesHow long the user takes to get ready in the morning.
neatnessintegerYesSelf-reported tidiness scale.
party_frequencyintegerYesSelf-reported partying frequency scale.
preferred_roommate_schooltextYesPreferred school/college affiliation of a roommate.
preferred_shower_timetextYesPreferred time of day to shower.
pronounstextYesUser’s pronouns.
schooltextYesUser’s own school/college affiliation.
sleep_timetextYesTypical bedtime.
snoresbooleanYesWhether the user snores.
social_energyintegerYesSelf-reported social energy scale.
statusroommate_status enumYesOne of searching, committed, inactive.
updated_attimestampYesLast time the profile was edited.
volume_preferenceintegerYesPreferred noise/volume level scale.
wake_timetextYesTypical wake-up time.
where_fromtextYesHometown/origin.

dorm

Reference data for CMU residence halls, shared across all users (not tied to a user_id).

ColumnDB typeNullableNotes
idserialNo (PK)Auto-incrementing primary key.
ac_detailstextYesDescription of air conditioning setup.
bathroom_detailstextYesDescription of bathroom facilities.
bathroom_typetextYesCategory of bathroom (e.g. shared, private, communal).
close_buildingstext[]YesNearby buildings of interest.
has_acbooleanYesWhether the dorm has air conditioning.
image_urltextYesPrimary/cover image for the dorm.
kitchen_descriptiontextYesDescription of kitchen facilities.
latitudenumericYesGeographic latitude.
longitudenumericYesGeographic longitude.
lounge_descriptiontextYesDescription of lounge/common space.
nametextYesDorm name.
photo_galleryjsonYesArray/object of additional photo URLs.
room_typestext[]YesRoom configurations offered (e.g. single, double).
tagstext[]YesFreeform tags for filtering/search.
updated_attimestampYesLast time the dorm record was edited.

review

User-submitted reviews of a dorm. Many-to-one against both user and dorm.

ColumnDB typeNullableNotes
idserialNo (PK)Auto-incrementing primary key.
dorm_idintegerNo (FK -> dorm.id)Dorm being reviewed.
user_idintegerNo (FK -> user.id)Author of the review.
bodytextYesFree-text review content.
lived_termtextYesTerm the reviewer lived there (e.g. Fall).
lived_yeartextYesYear the reviewer lived there.
rating_amenitiesintegerYesAmenities rating.
rating_atmosphereintegerYesAtmosphere rating.
rating_overallintegerYesOverall rating.
rating_room_qualityintegerYesRoom quality rating.
submitted_attimestampYesWhen the review was submitted.

Relationships

  • user (1) -> (1) user_preferences via user_preferences.user_id
  • user (1) -> (1) roommate_profile via roommate_profile.user_id
  • user (1) -> (many) review via review.user_id
  • dorm (1) -> (many) review via review.dorm_id

No relations() helpers are defined in schema.ts yet, so joins are written manually with Drizzle’s query builder rather than the relational query API.

TypeScript types

Each table is a pgTable object, which Drizzle can turn into select (row-as-read) and insert (row-as-write) types via InferSelectModel / InferInsertModel (or the $inferSelect / $inferInsert shorthand). These aren’t hand-written anywhere yet, but adding them alongside the table definitions in schema.ts gives the rest of the app compile-time types for free:

import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { userTable, userPreferencesTable, roommateProfileTable, dormTable, reviewTable } from "./schema.ts";

export type User = InferSelectModel<typeof userTable>;
export type NewUser = InferInsertModel<typeof userTable>;

export type UserPreferences = InferSelectModel<typeof userPreferencesTable>;
export type NewUserPreferences = InferInsertModel<typeof userPreferencesTable>;

export type RoommateProfile = InferSelectModel<typeof roommateProfileTable>;
export type NewRoommateProfile = InferInsertModel<typeof roommateProfileTable>;

export type Dorm = InferSelectModel<typeof dormTable>;
export type NewDorm = InferInsertModel<typeof dormTable>;

export type Review = InferSelectModel<typeof reviewTable>;
export type NewReview = InferInsertModel<typeof reviewTable>;

Resulting shapes (all nullable DB columns become T | null in the select type; serial/nullable columns become optional in the insert type):

type User = {
  id: number;
  andrewId: string | null;
  createdTime: Date | null;
  name: string | null;
  oidcSubject: string | null;
};

type UserPreferences = {
  id: number;
  userId: number;
  accommodations: string[] | null;
  cookingFrequency: number | null;
  goals: string[] | null;
  gymFrequency: number | null;
  major: string | null;
  needsAloneTime: number | null;
  preferredAmenities: string[] | null;
  preferredGenderHousing: string | null;
  productiveAroundOthers: number | null;
  socialFrequency: number | null;
  updatedAt: Date | null;
  year: string | null;
};

type RoommateProfile = {
  id: number;
  userId: number;
  alcohol: boolean | null;
  assignedSex: string | null;
  bathroomPreference: string | null;
  committed: boolean | null;
  drugs: boolean | null;
  extras: unknown | null; // json
  intendedMajor: string | null;
  isVisible: boolean | null;
  morningPrepTime: string | null;
  neatness: number | null;
  partyFrequency: number | null;
  preferredRoommateSchool: string | null;
  preferredShowerTime: string | null;
  pronouns: string | null;
  school: string | null;
  sleepTime: string | null;
  snores: boolean | null;
  socialEnergy: number | null;
  status: "searching" | "committed" | "inactive" | null;
  updatedAt: Date | null;
  volumePreference: number | null;
  wakeTime: string | null;
  whereFrom: string | null;
};

type Dorm = {
  id: number;
  acDetails: string | null;
  bathroomDetails: string | null;
  bathroomType: string | null;
  closeBuildings: string[] | null;
  hasAc: boolean | null;
  imageUrl: string | null;
  kitchenDescription: string | null;
  latitude: string | null; // numeric columns come back as strings from postgres-js
  longitude: string | null;
  loungeDescription: string | null;
  name: string | null;
  photoGallery: unknown | null; // json
  roomTypes: string[] | null;
  tags: string[] | null;
  updatedAt: Date | null;
};

type Review = {
  id: number;
  dormId: number;
  userId: number;
  body: string | null;
  livedTerm: string | null;
  livedYear: string | null;
  ratingAmenities: number | null;
  ratingAtmosphere: number | null;
  ratingOverall: number | null;
  ratingRoomQuality: number | null;
  submittedAt: Date | null;
};

A couple of notes worth knowing when consuming these types on the frontend:

  • numeric columns (dorm.latitude, dorm.longitude) are typed as string, not number. Drizzle/postgres-js don’t coerce them, to avoid floating-point precision loss. Parse with Number() before doing math.
  • json columns (roommate_profile.extras, dorm.photo_gallery) type as unknown unless you supply a generic (json("extras").$type<MyShape>()), so cast/validate before use.
  • Almost every non-PK, non-FK column is nullable today. None of the table definitions use .notNull() except for the foreign key columns. Treat every profile/preference/dorm/review field as optional when rendering the frontend.

Setup

For ScottyLabs Org Member setup instructions, see CONTRIBUTING.md.

Prerequisites

Initial Setup

cd housing
direnv allow

Running the app

# From the repo root
devenv up
cd apps/frontend

deno task dev
cd apps/backend

PORT=3001 deno task dev

The backend is proxied through the Vite development server and can be accessed at http://localhost:3000 during development. The backend should not be accessed directly, as all paths prefixed with /api will be routed to the backend.

Deployment

Production runs on Kennel via devenv and secretspec. Pushes to Codeberg main trigger deploys.

URLs:

  • https://housing-frontend-main.scottylabs.net (default Kennel URL)
  • https://cmuhousing.scottylabs.org (custom domain, managed by Kennel)
  • https://cmuhousing.com, point DNS at deploy-01 after verifying the Kennel deploy

Validate locally before pushing:

SECRETSPEC_PROVIDER=dotenv://.env devenv build scottylabs.kennel.config
nix build

Infrastructure

This repository contains the NixOS configurations for ScottyLabs’ VMs.

Provisioning a New VM

In each of the guides, make sure to replace hostname with the desired hostname for your VM, and andrewid with your Andrew ID.

Architecture

This repository holds the NixOS configuration for every ScottyLabs machine. infra-01 runs the shared services (identity, git, secrets, monitoring), deploy-01 runs the Kennel deployment platform, signage-01 is a display kiosk, and snoopy is a Computer Club virtual machine. Each host’s full system is built from a set of small modules that live under modules/.

A service is defined in one file and then listed by name on the hosts that should run it.

Module namespace

Three inputs in flake.nix provide the module system:

imports = [
  inputs.flake-parts.flakeModules.modules
  (inputs.import-tree ./modules)
  inputs.terranix.flakeModule
];

import-tree ./modules imports every .nix file under modules/ as a flake-parts module, merging their definitions into one flake-wide configuration.

flake-parts.flakeModules.modules adds the flake.modules option that those files write into. Modules meant for a NixOS host go under flake.modules.nixos. A typical file declares one named entry:

# modules/global/base.nix
{
  flake.modules.nixos.base = { config, lib, ... }: {
    # NixOS options for the base system
  };
}

Each file names its entry after where the file lives:

  • A module under modules/global/ takes a bare name, so modules/global/caddy.nix declares caddy
  • A platform under modules/platforms/ takes its directory name, so modules/platforms/campus-cloud/default.nix declares campus-cloud
  • A module under modules/hosts/<host>/ takes the host as a prefix, so modules/hosts/infra-01/forgejo.nix declares infra-01-forgejo and modules/hosts/deploy-01/kennel.nix declares deploy-01-kennel

Every name is unique across the tree. To find the entry a file provides, read its first few lines. Every module across the tree is available as config.flake.modules.nixos.<name> in a single flat namespace, regardless of how deeply its file is nested.

Roles

A host is built from a list of module names living in modules/roles/.

modules/roles/global.nix defines the baseline shared by every host: the base system, networking, secrets, and the observability agents, together with external inputs such as home-manager, agenix, and disko.

Each host has its own role. Its imports are grouped into a platform, the common modules it shares with other hosts, and the host’s own services, with each group in a labelled section and sorted within the section:

flake.modules.nixos.infra-01.imports = with config.flake.modules.nixos; [
  # Platform
  campus-cloud

  # Common
  postgresql

  # Services
  infra-01-forgejo
  # ...
];

Platforms

Each host role includes a platform. Platforms live in modules/platforms/ and hold the machine-dependent parts of a configuration, such as the boot loader, kernel modules, and disk layout via disko. campus-cloud covers the CMU Campus Cloud VMware guests used by infra-01 and deploy-01, mele-cyber-x1 covers the signage hardware, and computer-club covers snoopy.

Systems and deployment

modules/systems.nix produces the nixosConfigurations used for local builds and the colmenaHive used for deployment. Both come from the same per-host module list, which combines the host role with the global role:

modulesFor = hostname: [ nixos.${hostname} nixos.global ];

nixosConfigurations calls nixpkgs.lib.nixosSystem for each host with that module list. colmenaHive passes the same list to Colmena and adds deployment settings, so each host is reached over SSH at <hostname>.scottylabs.org as the deploy user. Both forms receive the same specialArgs (inputs and the contents of users.nix), so a module behaves identically whether it is built locally or deployed.

Imported modules and enable flags

Most modules apply their configuration as soon as a role imports them. Others expose an option under the scottylabs.* namespace and apply nothing until it is set. modules/global/node-exporter.nix declares such an option and guards its config behind it:

options.scottylabs.nodeExporter = {
  enable = lib.mkEnableOption "Prometheus node_exporter";
  port = lib.mkOption {
    type = lib.types.port;
    default = 9100;
  };
};

Importing the module does nothing until scottylabs.nodeExporter.enable is set, which modules/global/observability-agents.nix does for all the agents.

The scottylabs.* namespace is the repository’s own settings surface, alongside the upstream NixOS options. It carries options such as the public IP (scottylabs.publicIp) and the observability-agent toggles (scottylabs.nodeExporter.enable). A module’s options.scottylabs.* block shows what it exposes and whether another module must switch it on.

Flake-level configuration

Some files under modules/ contribute configuration at the flake level. These values are collected across every host before they are used.

Files under modules/terranix/ declare entries under flake.modules.terranix, which are assembled into terranixConfigurations and import one another by name, as on the NixOS side.

A service declares its Grafana dashboards and alerts through the flake-level scottylabs.observability option, in the same file as the service. modules/hosts/deploy-01/kennel.nix defines both the Kennel module and its dashboard. Grafana runs only on infra-01 and reads the scottylabs.observability values from the whole flake, so a dashboard declared beside a service on deploy-01 is rendered by the Grafana on infra-01.

Internet Archive

Script that automatically saves a page of your choice to the internet archive.

Archived websites may take a few minutes before appearing.

Documentation for API

Usage

uv run python3 src/main.py -p soc --debug

Kennel

Kennel is the deployment platform for ScottyLabs. On every push it builds the project with Nix, runs its services as systemd units and static sites through Caddy, provisions per-deployment resources, resolves secrets from OpenBao, manages DNS through Cloudflare, and serves it over HTTPS.

Every branch and open pull request gets its own deployment at {project}-{branch}.scottylabs.net, redeployed on every push and torn down when the branch or PR closes.

A project’s devenv.nix, built on the shared ScottyLabs devenv modules in this repo (nix/modules), defines its local development environment, its CI, and its production deployment. The daemon and the modules are versioned together.

Internally, kennel is a single daemon that takes git webhooks, builds, deploys, and reconciles running state against declared intent. It keeps intent and build history in SQLite and leaves runtime state to systemd, Caddy, and Nix.

Kennel can also publish the hosts of its live deployments to a file. ricochet, a stateless OAuth2 callback relay, reads that file as its allowlist, so ephemeral previews can complete logins that identity providers won’t issue wildcard redirect URIs for.

Layout

  • crates/kennel: the daemon
  • crates/kennel-config: shared types and the devenv config contract
  • crates/kennel-provision: resource provisioning (PostgreSQL, Valkey, Garage)
  • crates/entity, crates/migration: SQLite schema and SeaORM entities
  • nix/modules: the shared devenv modules projects build on
  • nix/lib: Nix build helpers (mkLib)
  • nix/nixos.nix: NixOS module to run the daemon on a host
  • sites/docs: documentation (mdBook)

Adding modules, checks, or build helpers is documented in nix/README.md.

License

Licensed under the GNU Affero General Public License v3.0.

Lost And Found V2

Official Carnegie Mellon University platform for reporting, managing, and finding lost items

Maps

Overview

CMU Maps is a web application that provides a map of the Carnegie Mellon University campus, allowing users to easily access information about campus locations.

Key features include:

  • View floorplans
  • Room level navigation
  • Search for buildings and rooms
  • View building and room information

Wiki

Please check out our wiki for more information about the project.

Contributing

Please check out our Contributing Guide for more information about how to contribute to the project.

Mcp Server

A unified Model Context Protocol (MCP) server providing access to ScottyLabs’s Projects for CMU Students services through FastMCP. This server combines multiple CMU-related services into a single, composable MCP interface.

Overview

This project provides MCP tools for:

  • CMU Dining (Eats): Query dining locations, hours, menus, and real-time availability
  • CMU Maps: Search buildings, get directions, and calculate distances on campus

Built with FastMCP, this server uses a modular architecture that allows mounting multiple sub-services with namespace prefixes.

Features

CMU Dining Service (eats)

  • Get all dining locations with details (cuisine, hours, location)
  • Search locations by name
  • Find locations currently open
  • Check locations open at specific times
  • Query locations by cuisine type
  • Get detailed hours and specials for specific locations
  • Real-time open/closed status
  • Online ordering availability indicators

CMU Maps Service (maps)

  • Search buildings and locations by name
  • Get paths between two locations
  • Calculate distances between locations
  • List possible location matches for queries

Installation

Prerequisites

  • Python 3.11 or higher
  • uv or Poetry package manager
  • Docker (optional, for containerized deployment)
# Install dependencies
uv sync

# Activate virtual environment
source .venv/bin/activate

Using Poetry

# Install dependencies
poetry install

# Activate virtual environment
poetry shell

Using Docker

# Build the Docker image
docker build -t mcp-server .

# Run the container
docker run -p 8000:8000 mcp-server

# Or use docker-compose (if you create a docker-compose.yml)
docker-compose up

Usage

Running the Server

The server runs on HTTP transport by default on 0.0.0.0:8001:

# Run as module
python -m mcp_server

# Or run directly
python src/mcp_server/__init__.py

Using Individual Services

Each service can also be run independently:

# Run only the dining service
python src/mcp_server/services/eats/app.py

# Run only the maps service
python src/mcp_server/services/maps/app.py

Available Tools

Dining Tools (prefix: eats)

  • get_all_dining_locations(): List all CMU dining locations
  • search_dining_locations(name_query): Search by name
  • get_locations_open_now(): Find currently open locations
  • get_locations_open_at_time(day, hour, minute): Check availability at specific time
  • get_location_hours(location_name): Get detailed info for a location
  • get_locations_by_cuisine(cuisine_query): Find locations by cuisine type

Maps Tools (prefix: maps)

  • search_buildings(query): Search for buildings/locations
  • get_path(start_id, end_id): Get path between two locations
  • list_possible_locations(query): List location name matches
  • distance_between(start_id, end_id): Calculate distance in meters

Configuration

API Endpoints

  • Dining API: https://dining.apis.scottylabs.org
  • Maps API: https://rust.api.maps.scottylabs.org

Endpoints are configured in:

  • src/mcp_server/services/eats/constants.py
  • src/mcp_server/services/maps/app.py

Server Configuration

The main server configuration is in src/mcp_server/__init__.py:

  • Host: 0.0.0.0
  • Port: 8000
  • Transport: http (streamable HTTP transport)

CORS Configuration

CORS (Cross-Origin Resource Sharing) is enabled by default in src/mcp_server/core/app.py:

  • Allow Origins: * (all origins - adjust for production)
  • Allow Methods: All HTTP methods including OPTIONS
  • Allow Headers: All headers
  • Allow Credentials: Enabled

This configuration enables the server to handle OPTIONS preflight requests and accept requests from any origin. For production deployments, consider restricting allow_origins to specific domains.

Development

Architecture

The project uses a compositional architecture:

  1. Base App (core/app.py): Defines the main FastMCP instance
  2. Services (services/): Individual MCP services with their own tools
  3. Main Composition (main.py): Mounts services with prefixes to avoid conflicts

This allows:

  • Independent development and testing of services
  • Namespace isolation via prefixes
  • Easy addition of new services
  • Running services independently or combined

Adding a New Service

  1. Create a new directory under src/mcp_server/services/
  2. Implement your service with FastMCP tools
  3. Mount it in src/mcp_server/main.py:
from mcp_server.services.your_service.app import mcp as your_mcp

main_mcp.mount(your_mcp, prefix="your_service")

Dependencies

Core dependencies:

  • fastmcp>=2.12.3: MCP framework
  • aiohttp>=3.12.15: Async HTTP client
  • httpx: HTTP client for async requests
  • pydantic: Data validation and models

Data Models

DiningLocation

  • concept_id: Unique identifier
  • name: Location name
  • short_description: Brief description
  • description: Full description
  • location: Physical location on campus
  • accepts_online_orders: Online ordering availability
  • url: Location website
  • menu_url: Menu link
  • current_status: Open/closed status

TimeSlot

  • day: Day of week (0=Sunday, 6=Saturday)
  • start_hour: Opening hour (24-hour format)
  • start_minute: Opening minute
  • end_hour: Closing hour
  • end_minute: Closing minute

Output Format

All dining tools return formatted Markdown with:

  • Status indicators (=� open, =4 closed)
  • Online ordering indicators (=�)
  • Grouped by cuisine type
  • 12-hour time format
  • Consecutive day grouping for hours

Author

AI Team at ScottyLabs

Version

Current version: 0.1.0

Scottylabs Org

The official landing page for ScottyLabs, as well as the host for Clerk authentication.

This is a lightweight monorepo (haha oxymoron am I right) with a /apps/backend and a /apps/web. It’s set up this way so all frontend api calls can use the contract defined in the backend and be fully typesafe.

If this is your first time setting up the repo, please run pnpm setup-db first to create your local database. (Note that you should have the Docker CLI installed). For frontend .env variables, check the latest Vercel deployment. For backend .env variables, check the latest Railway deployment. If you don’t have access to either deployment, ask Eric Xu on Slack.

To run everything: pnpm -r --parallel run dev To run a specific module, pnpm -F @apps/backend dev, for example

Frontend: React + Vite + Typescript + Tanstack Query

Backend: Fastify + ts-rest + Drizzle + octokit (Slack API) + Node runtime

Note: if you make any db schema changes, please run pnpm db:generate before committing to generate migration files

Tartan Vote

Tartan Vote is a CMU Undergraduate Senate-commissioned, ScottyLabs-developed voting app, to help the Senate and other student organizations manage attendance and host elections and motions. Currently, the app is still under development, but we strongly hope to get it completed very soon!

Built With

  • SvelteKit
  • Rust
  • PostgreSQL

Assumptions about the reader

Hello, reader! For the remainder of this README, and other documentation, we will assume that you are a developer or contributor, using WSL or a Unix development system, and have some familiarity with the command line. If you need any help, you are free to contact one of the codeowners found in CODEOWNERS, or join the discord.

Getting Started

Prerequisites

  • devenv — provides Cargo, Deno, Node, PostgreSQL, and other tooling via Nix
  • direnv (recommended)

Quick Setup

For detailed setup instructions, see SETUP.md. Configuration and secrets are documented in secrets-and-config.md.

Authenticate once per machine with OpenBao so secretspec can read dev secrets:

export BAO_ADDR=https://secrets2.scottylabs.org
bao login -method=oidc

Allow direnv (or enter the shell manually):

direnv allow
# or: devenv shell

Run the app (inside the devenv shell, from the repo root):

devenv up
# add --detach or -d to run it in the background
# devenv processes down to shut it down
cd frontend && deno task build
cargo run

Then open http://localhost:8080. Inside the devenv shell, DATABASE_URL and secrets are provided automatically.

Deployment

Production runs on Kennel via devenv and secretspec.

Contributing

Please check CONTRIBUTING.md before you contribute to this project!

Licenses

Voting App is distributed under the Apache 2.0 and MIT Licenses, found in the files LICENSE-APACHE-2.0 and LICENSE-MIT respectively.

Auth

Authentication is OIDC via Ricochet/Keycloak: /auth/login, /auth/callback, /auth/logout. OIDC secrets are provided by secretspec.

Backend flow

  1. The frontend links to /auth/login, which sits behind OidcLoginLayer; axum-oidc redirects the browser to Keycloak via the Ricochet relay. The OAuth state carries a CSRF token plus the app callback ({APP_URL}/auth/callback).
  2. Keycloak authenticates the user; the relay forwards the code to /auth/callback, served by axum_oidc::handle_oidc_redirect.
  3. The callback exchanges the code for tokens and stores them in a server-side session (Valkey, via tower-sessions).
  4. OidcAuthLayer establishes the claims on each request; sync_user_middleware upserts a local user keyed on the OIDC subject and exposes it as SyncedUser.
  5. The frontend reads /auth/status.

Logout (GET /auth/logout) flushes the local session and returns to the app root. The Keycloak SSO session is left intact, so re-login does not re-prompt for credentials.

Sessions

Server-side sessions are backed by Valkey (tower-sessions-redis-store over fred), connected via VALKEY_URL. Only the session token set lives in Valkey user identity stays in Postgres, re-derived from the token subject per request via sync_user_middleware.

Files

  • src/core/auth/oidc.rs: GroupClaims, the SessionWrapper bridge from tower-sessions to axum-oidc’s session contract, the relay state generator, and the discovered OidcClient builder.
  • src/core/auth/middleware.rs: SyncedUser and its extractors, plus sync_user_middleware.
  • src/domain/auth/handlers.rs: GET /auth/status, the /auth/login and /auth/logout handlers, and the demo page.
  • src/server.rs: mounts the session layer, OidcAuthLayer, sync_user_middleware, and CORS.

Config

Secrets are loaded through the secretspec Rust SDK (declare_secrets! against the repo-root secretspec.toml), using the read-only env provider.

Some usefull information about the fonts.

The four liberation-sans.*.ttf files are used for PDF generation.

Currently, the font settings are hardcoded in backend/crates/voting-app/src/static_event_renders.rs. If you want to change the fonts, you’ll need to update and align the hardcoded section accordingly.

JSON Information

vote.data

The vote.data field stores the submitted voting payload for a single vote record.

Type Definition

type VoteData = {
    vote_type: string;
    proxy: boolean;
    proxy_for_user_id: number | null;
    vote_response: string[];
};

Field Descriptions

  • vote_type: Specifies the type of vote represented by this response. Examples include "motion" and "election".
  • proxy: Whether this vote was cast as a proxy vote instance.
  • proxy_for_user_id: If proxy is true, this stores the proxied user’s id; otherwise null.
  • vote_response: Stores the participant’s submitted response as an array.
    • For a standard motion, this array typically contains a single value.
    • For a ranked-choice election, this array stores the ranked selections in order of preference.
    • A lower array index indicates a higher preference (e.g., index 0 = first choice, index 1 = second choice).

Example Usage

{
    "vote_type": "motion",
    "proxy": false,
    "proxy_for_user_id": null,
    "vote_response": ["choice1", "choice2"]
}

event.data

The event.data field stores event-specific configuration and metadata.

Type Definition

type EventData = {
    description: string;
    session_code: string;
    vote_type: string;
    threshold: number;
    visibility: {
        participants: string;
    };
    proxy: bool;
    vote_options: string[];
};

Field Descriptions

  • description: A textual description of the event.
  • session_code: A code used for joining or identifying the session.
  • vote_type: Specifies whether the event is a motion or an election.
  • threshold: A floating-point value representing the approval threshold required for the vote. This value in the range [0, 1].
  • visibility.participants: Defines what participants can see during the event. Example values include "hidden_until_release" and "live".
  • proxy: Indicates whether proxy voting is enabled for the event.
  • vote_options: Lists the selectable voting options for the event.

Example Usage

{
    "description": "Event description goes here",
    "session_code": "code",
    "vote_type": "motion",
    "threshold": 0.75,
    "visibility": {
        "participants": "hidden_until_release"
    },
    "proxy": true,
    "vote_options": ["option1", "option2"]
}

Notes

  • For now, all users are treated as having the same role.

organization.data

The organization.data field stores organization-level metadata.

Type Definition

type OrganizationData = {
    description: string;
};

Field Descriptions

  • description: A textual description of the organization.

Example Usage

{
    "description": "Organization Description"
}

log.data

The log.data field stores audit log information for system actions. Logging has not been implemented yet, so this section is not in use currently.

Type Definition

type LogData = {
    action: string;
    target: {
        table: string;
        id: number;
    };
    actor:
        | {
              user_id: number;
              role: string;
          }
        | "system";
    event_id: number;
    changes: {
        before: {};
        after: {};
    };
};

Field Descriptions

  • action: A description of the action being logged.
  • target: Identifies the record affected by the action.
  • table: The name of the affected table.
  • id: The primary key of the affected record.
  • actor: Identifies the user responsible for the action.
  • user_id: The ID of the acting user.
  • role: The role of the acting user.
  • event_id: The related event identifier, if applicable.
  • changes: Stores the state transition caused by the action.
  • before: The state before the change.
  • after: The state after the change.

Example Usage

{
    "action": "Action Description",
    "target": {
        "table": "tablename",
        "id": 0
    },
    "actor": {
        "user_id": 0,
        "role": "Role String"
    },
    "event_id": 0,
    "changes": {
        "before": {},
        "after": {}
    }
}

Generating migrations

Using sea-orm, we follow the standard migration-first approach. We recommend using sea-orm-cli to generate migrations and entities.

Migrations

To create a new migration file, navigate to the crates folder and run

backend/crates $ sea-orm-cli migrate generate MIGRATION_NAME

To migrate the database, follow the steps in SETUP.md or for short, run

backend/crates/voting-app $ cargo run

(PostgreSQL is managed automatically by devenv when you enter the shell.)

Entities

Generating entities requires the database to be migrated, so that the entities can be built off the structure of the database. After migrating the database, run either

backend/crates/entity/src $ sea-orm-cli generate entity

or

backend/crates $ sea-orm-cli generate entity -o entity/src

Schema Information

Users

The Users table stores information on users.

Schema Definition

#![allow(unused)]
fn main() {
struct User {
  id: i32,
  name: string,
  andrew_id: string,
  oidc_subject: string,
  created_at: DateTimeWithTimeZone
}
}

Field Descriptions

  • id: Primary Key. The id for the user. Autogenerated.
  • name: The (full) name of the user, as fetched from auth.
  • andrew_id: The andrew_id of the user, as fetched from auth.
  • oidc_subject: The OIDC subject of the user, as fetched from auth. This is for authentication, and not for the frontend.
  • created_at: The timestamp when the user was created (when the entry was created). Autogenerated.

Organizations

Schema Definition

#![allow(unused)]
fn main() {
struct Organization {
  id: i32,
  name: string,
  data: Json
}
}

Field Descriptions

  • id: Primary Key. The id for the organization. Autogenerated.
  • name: The name of the organization.
  • data: Other data related to the organization. See organization.data.

Organization Members

The OrganizationMembers table is a join table between the Organizations and Users table to describe the many-to-many relationship users are allowed to have with organizations. It also stores information on what the user is allowed to do in the organization.

Schema Definition

#![allow(unused)]
fn main() {
struct OrganizationMember {
  organization_id: i32,
  user_id: i32,
  user_role: string,
  joined_at: DateTimeWithTimeZone
}
}

Field Descriptions

  • organization_id: Primary Key. The id for an organization. References Organizations.
  • user_id: Primary Key. The id for the user in the organization. References Users.
  • user_role: The role of the user in the organization. Specifies their permissions in the organization and creating events.
  • joined_at: The timestamp when the user joined the organization (when the entry was created). Autogenerated.

Sessions

Schema Definition

#![allow(unused)]
fn main() {
struct Session {
  id: i32,
  join_code: String
  status: SessionStatus,
  created_by_user_id: i32
}
}

Field Descriptions

  • id: Primary Key. The id for the session. Autogenerated.
  • join_code: The join code for the session. Capital alphabet and digits, 6 characters.
  • status: The status of the session, can be “open”, “locked”, “closed”.
  • created_by_user_id: The id of the user who created the session. Reference Users.

User Sessions

Schema Definition

#![allow(unused)]
fn main() {
struct UserSession {
  id: i32,
  user_id: i32,
  session_id: i32,
  proxy: Option<string>,
  join_left: JoinLeft,
  timestamp: DateTimeWithTimeZone
}
}

Field Descriptions

  • id: Primary Key. Autogenerated.
  • user_id: Foreign Key. The id for the user in the session. References Users.
  • session_id: Foreign Key. The id for the session. References Sessions.
  • proxy: Nullable string. If set, stores the user id (as a string) of the participant this user is proxying for.
  • join_left: Whether the user joined or left the session. Can be “joined” or “left”.
  • timestamp: The timestamp when the user joined the session (when the entry was created).

Events

Schema Definition

#![allow(unused)]
fn main() {
struct Event {
  id: i32,
  event_type: EventType,
  name: String,
  status: StatusOption,
  start_time: DateTimeWithTimeZone,
  end_time: Option<DateTimeWithTimeZone>,
  data: Json,
  created_by_user_id: i32,
  session_id: i32,
}
}

Field Descriptions

  • id: Primary Key. The id for the event. Autogenerated.
  • event_type: The type of the event. Is an enum type. Can be “motion” or “election”.
  • name: The name for the event.
  • status: The status of the event. Is an enum type. Can be “active” or “inactive”.
  • start_time: The time when the event(voting) starts.
  • end_time: The time when the event(voting) ends, if ever.
  • data: The other data related to the event. See event.data
  • created_by_user_id. The id of the user who created the event. References Users.
  • session_id: The id for the session this event belongs to. References Sessions.

Votes

This table probably breaks a normalization rule or two, but that doesn’t really matter right now.

Schema Definition

#![allow(unused)]
fn main() {
struct Vote {
  id: i32,
  event_id: i32,
  user_session_id: i32,
  cast_time: DateTimeWithTimeZone,
  data: Json,
}
}

Field Descriptions

  • id: Primary Key. The id for the vote. Autogenerated.
  • event_id: Foreign Key. The event this vote belongs to. References Events.
  • user_session_id: Foreign Key. The user_session row representing the participant/proxy vote instance. References User Sessions.
  • cast_time: The time at which this vote was cast (when the entry was created). Autogenerated.
  • data: Other data pertaining to the vote. See vote.data

Votes are unique per (event_id, user_session_id).

ProxySetup Endpoint - Debugging Guide

What I’ve Updated

I’ve enhanced the ProxySetup.svelte component with comprehensive logging and debugging to help identify why the backend request isn’t working correctly.

Changes Made:

  1. Enhanced Console Logging - The component now logs:

    • Request URL being called
    • Full request body
    • Response status and headers
    • Response body (success and error cases)
    • Detailed error messages with colored console output (🔵, 🟢, 🔴)
  2. Debug Info Display - The ProxySetup screen now shows:

    • The VITE_API_BASE value currently being used
    • The full URL being constructed for the request
  3. Better Error Handling:

    • Detects response content-type and parses accordingly
    • Shows user-friendly error messages
    • Validates that response is JSON before parsing
  4. Session Code Validation - Shows error if session code is missing

How to Debug

Step 1: Check Console Logs

When the ProxySetup screen appears:

  1. Open browser DevTools (F12)
  2. Go to “Console” tab
  3. The component will show:
    • Debug Info box with the API base URL and full endpoint URL being used
    • Expected URLs: http://localhost:8000/session/ABC123/proxy (adjust port/domain as needed)

Step 2: Click Continue and Watch Logs

When you select a senator option and click “Continue”:

  1. Look for 🔵 (blue circle) logs showing the request being sent
  2. Watch for response logs:
    • If you see 🟢 (green circle): Request succeeded! Check that the notice message appears
    • If you see 🔴 (red circle): Request failed. Error message will be displayed

Step 3: Common Issues and Solutions

Issue: “HTTP 401 Unauthorized”

Causes:

  • User is not authenticated with the auth service
  • Auth cookie is not being sent with request
  • Auth service session expired

Solutions:

  • Make sure you’re logged in via SignIn screen first
  • Check if auth service cookie is set (look in DevTools > Application > Cookies)
  • Try clearing cookies and re-authenticating

Issue: “HTTP 404 Not Found”

Causes:

  • API base URL is wrong
  • Endpoint path is incorrect
  • Backend isn’t running

Solutions:

  • Check the Debug Info box on the screen - verify the URL matches your backend
  • Make sure VITE_API_BASE environment variable is set correctly (e.g., http://localhost:8000)
  • Verify backend is running on the expected port: cargo run --bin backend

Issue: “HTTP 500 Internal Server Error”

Causes:

  • Backend crashed or database error
  • Session code doesn’t exist
  • Database not initialized

Solutions:

  • Check backend console for error messages
  • Verify database migrations have run
  • Try a known valid session code

Issue: “Expected JSON response, got text/html”

Causes:

  • Request is hitting a different endpoint (maybe a 404 page)
  • CORS issue preventing proper response

Solutions:

  • Verify the API base URL in Debug Info is correct
  • Check backend CORS configuration is allowing the request
  • Look at “Network” tab in DevTools to see actual response

Issue: “Error: Timeout” or no response at all

Causes:

  • Backend not running
  • Network connectivity issue
  • API base URL unreachable

Solutions:

  • Start backend: cd backend && cargo run --bin backend
  • Verify API base URL is accessible (try pinging it, or open in browser)
  • Check firewall/network settings

Step 4: Network Tab Inspection

For more detailed information:

  1. Open DevTools
  2. Go to “Network” tab
  3. Click “Continue” button
  4. Look for the proxy request
  5. Click on it to see:
    • Headers: Shows request headers (Content-Type, credentials)
    • Request: Shows the JSON body being sent
    • Response: Shows the server’s response
    • Timing: Shows how long the request took

Step 5: Backend Logs

If the console logs show the request was sent but you can’t see what the backend is doing:

  1. Run backend with verbose logging:
cd /home/yy/repos/voting-app/backend
RUST_LOG=debug cargo run --bin backend
  1. Look for logs about:
    • Incoming requests
    • Database queries
    • User authentication
    • Session lookups

Expected Behavior When Working

When everything is working correctly:

  1. ProxySetup screen appears with:

    • The correct session code
    • Debug info showing the API URL
    • Senator dropdown and optional proxy input
  2. You select options and click Continue:

    • Senator: Yes/No (required)
    • Proxying for: Name (optional)
  3. Console shows (in order):

    🔵 Sending proxy request: { url: "...", ... }
    🔵 Response received: { status: 200, statusText: "OK", ... }
    🟢 Success! Proxy response: { vote_instance_count: 2, is_senator: true, has_proxy: true }
    
  4. Success message appears:

    • “You now have 2 vote instances (your own vote + one proxy vote).”
    • Or appropriate message based on your configuration
  5. Moves to WaitingPage with the participation notice displayed

Testing the Endpoint Manually

If you want to test directly without the frontend:

Using curl:

curl -X POST http://localhost:8000/session/ABC123/proxy \
  -H "Content-Type: application/json" \
  -H "Cookie: <your_auth_cookie>" \
  -d '{"is_senator": true, "proxy_for": "Jane Doe"}'

Expected Response:

{
    "vote_instance_count": 2,
    "is_senator": true,
    "has_proxy": true
}

Using API Client (Postman, REST Client, etc.):

  1. Method: POST
  2. URL: http://localhost:8000/session/{SESSION_CODE}/proxy
  3. Headers:
    • Content-Type: application/json
    • Cookie: <auth_session_cookie>
  4. Body (JSON):
{
    "is_senator": true,
    "proxy_for": "Jane Doe"
}

Additional Debug Info

Backend Endpoint Details

  • Route: POST /session/{session_code}/proxy
  • Auth Required: Yes (needs valid session cookie)
  • Request Type: SetSessionProxyRequest
    • is_senator: bool (required)
    • proxy_for: Option<String> (optional, null if not proxying)
  • Response Type: SetSessionProxyResponse
    • vote_instance_count: number
    • is_senator: boolean
    • has_proxy: boolean

Frontend Environment Variables

Make sure these are set correctly:

# .env or .env.local
VITE_API_BASE=http://localhost:8000
# or for production:
VITE_API_BASE=https://api.example.com

Next Steps After Debugging

Once you identify the issue:

  1. If it’s an auth issue: Ensure auth service is running and cookies are being set
  2. If it’s a URL issue: Update VITE_API_BASE environment variable
  3. If it’s a backend issue: Check database, migrations, and server logs
  4. If everything works: Remove debug info display (optional - it won’t hurt to leave it)

To remove debug info display later, simply delete or comment out the <div class="debug-info"> block in ProxySetup.svelte.

Questions to Answer

When debugging, try to identify:

  • ✅ Is the request being sent at all? (Check console logs)
  • ✅ What is the response status code? (200, 401, 404, 500, etc.)
  • ✅ What is the API base URL being used?
  • ✅ Is the user authenticated? (Check cookies)
  • ✅ Is the backend running? (Try accessing /health endpoint)
  • ✅ Does the session code exist? (Check database or backend logs)

Good luck debugging!

Proxy Voting System Implementation - Complete Summary

Overview

The voting application now supports a sophisticated proxy voting system where users can declare themselves as “senator” elected representatives and optionally proxy vote for other members. The system enforces participation rules server-side to ensure correct vote instance counts.

Participation Model

The system allocates vote instances based on senator status and proxy assignment:

User TypeBase InstanceProxy InstanceTotal InstancesNotes
Senator, no proxy-1Votes as self
Senator, with proxy2Votes as self + proxies for someone
Non-senator, no proxy--0Cannot vote
Non-senator, with proxy-1Can only proxy for a senator

Architecture

User Flow

  1. Authentication → User logs in via SignIn.svelte
  2. Join Session → User selects voter role via Home.svelte (provides session code)
  3. Proxy SetupNEW User declares senator status + optional proxy target via ProxySetup.svelte
  4. Waiting Page → User waits for motion to become active; sees participation confirmation banner
  5. Voting → User casts vote(s) on active motion
  6. Results → View results

Core Endpoints

POST /session/{code}/proxy

Declares senator status and optional proxy assignment (idempotent).

Request:

{
    "is_senator": true,
    "proxy_for": "Jane Doe" // optional, null if not proxying
}

Response:

{
    "vote_instance_count": 2,
    "is_senator": true,
    "has_proxy": true
}

Semantics:

  • If is_senator=true: Ensures exactly one base (non-proxy) instance exists
  • If is_senator=false: Deletes all base instances
  • If proxy_for=Some(value): Ensures exactly one proxy instance with that value
  • If proxy_for=None: Deletes all proxy instances
  • Returns final instance count (0, 1, or 2)

GET /events/{id}/vote-instances

Lists all vote instances available to the current user for a specific event.

Response:

[
    {
        "voter_instance_id": 42,
        "is_proxy": false,
        "proxy_for_name": null,
        "has_voted": false
    },
    {
        "voter_instance_id": 44,
        "is_proxy": true,
        "proxy_for_name": "Jane Doe",
        "has_voted": false
    }
]

POST /events/{id}/vote

Casts a vote on a specific instance (specified by voter_instance_id).

GET /session/{code}/attendance

Lists all participants in session with proxy metadata (for host meeting overview).

Response includes:

{
    "attendees": [
        {
            "user_id": 1,
            "user_name": "Alice",
            "is_proxy_holder": true,
            "proxy_for": ["Bob"]
        },
        {
            "user_id": 2,
            "user_name": "Bob",
            "is_proxy_holder": false,
            "proxy_for": []
        }
    ]
}

Database Schema Changes

UserSession Table

  • New field: proxy: Option<String> — proxy target name (null if not a proxy instance)
  • Semantic: One user can have multiple user_session rows per session:
    • One non-proxy row (base instance, if senator)
    • Zero or one proxy row (if proxying for someone)

Unique Constraint: (user_id, session_id, proxy) — prevents duplicate entries

Vote Table

  • Keys: event_id + user_session_id — links vote to specific instance
  • Payload includes: proxy: bool, proxy_for_name: String | null

Removed

  • Voters table (no longer needed; participation tracked via user_session rows)

Frontend Components

ProxySetup.svelte (NEW)

Pre-voting-page screen that captures participation configuration.

Props:

  • sessionCode: string | null — session code
  • onBack: () => void — callback to return to join page
  • onNext: (notice: string | null) => void — callback when setup complete

State:

  • senatorChoice: 'yes' | 'no' | '' — senator status (mandatory select)
  • proxyFor: string — proxy target name (optional text input)

Behavior:

  1. User must select “Yes” or “No” for senator question (no skip option)
  2. User optionally enters proxy target name
  3. On submit, calls POST /session/{code}/proxy with parsed payload
  4. On success, generates user-friendly notice:
    • “You now have 2 vote instances (your own vote + one proxy vote).”
    • “You now have 1 proxy vote instance.”
    • “You now have 1 vote instance.”
    • “You currently have 0 vote instances for this session.”
  5. Passes notice to App.svelte via onNext(notice) callback

WaitingPage.svelte (ENHANCED)

Updated to display participation confirmation banner.

New Props:

  • notice: string | null — confirmation message from ProxySetup

New UI Element: If notice is provided, displays styled banner:

<p class="notice">{notice}</p>

App.svelte (ROUTING UPDATED)

Screen navigation flow now includes proxy setup step.

New State:

  • waitingNotice: string | null — stores notice from ProxySetup to persist through route

Updated Routes:

  • joinproxySetupwaiting (was directly joinwaiting)
  • proxySetup.onNext(notice) sets waitingNotice before transitioning to waiting
  • waiting.onEventFound() clears waitingNotice before voting
  • Vote return routes also clear waitingNotice

SessionCreation.svelte (ENHANCED)

Host meeting control screen now displays proxy assignments in participant cards.

New Fields in Participant Hover Card:

  • Shows is_proxy_holder: boolean
  • Lists all names the participant is proxying for (e.g., “Proxy: Yes (Jane, Bob)”)

Implementation Details

Backend Handler: set_session_proxy()

Location: backend/crates/voting-app/src/domain/session/handlers.rs

Algorithm:

  1. Validate session exists and is open
  2. Trim and filter proxy name (null if empty)
  3. Fetch all existing joined sessions for user
  4. Separate base vs proxy instances
  5. Senator logic:
    • If is_senator=true: Ensure one base instance exists (create if missing)
    • If is_senator=false: Delete all base instances
  6. Proxy logic:
    • If proxy_for=Some(name): Update first proxy or create new if missing
    • If proxy_for=None: Delete all proxy instances
  7. Query final count and return response

Idempotency: Safe to call multiple times; always reconciles instance set to match desired state.

Vote Instance Filtering

All vote instance queries filter by JoinLeft::Joined to ignore stale rows:

#![allow(unused)]
fn main() {
.filter(user_session::Column::JoinLeft.eq(JoinLeft::Joined))
}

This ensures only active, joined sessions are counted when provisioning votes.

Documentation Updates

docs/db/db-schema.md

  • Removed Voters table (no longer exists)
  • Updated UserSession table to document proxy: Option<String> field
  • Updated Vote table to show event_id + user_session_id keys + uniqueness constraint

docs/db/db-json.md

  • Updated vote data structure to include:
    • proxy: boolean — whether this vote instance is a proxy
    • proxy_for_user_id: number | null — ID of person being proxied for (preserved for audit)

Quality Assurance

Compilation Status

Frontend (svelte-check + tsc): ✅ 0 errors, 0 warnings Backend (cargo check): ✅ 2 harmless warnings (unused HasActiveEventResponse + has_active_event() function)

Test Coverage Needed

  1. Non-senator proxy flow:

    • User selects “No” + proxy name “Jane”
    • Verify: 1 vote instance created (proxy only)
  2. Senator proxy flow:

    • User selects “Yes” + proxy name “John”
    • Verify: 2 vote instances created (base + proxy)
  3. Senator no-proxy flow:

    • User selects “Yes” + no proxy name
    • Verify: 1 vote instance created (base only)
  4. Non-senator no-proxy flow:

    • User selects “No” + no proxy name
    • Verify: 0 vote instances created
  5. Idempotency:

    • Call same endpoint twice with same payload
    • Verify: Same instance count returned both times
  6. Re-submission with changes:

    • User calls with (is_senator=true, proxy=null)
    • Then calls with (is_senator=false, proxy="Jane")
    • Verify: Base instance deleted, proxy instance created
  7. Attendance display:

    • Confirm host sees correct is_proxy_holder and proxy_for arrays

Edge Cases Handled

✅ Proxy name with leading/trailing whitespace (trimmed server-side) ✅ Proxy name as empty string (treated as null) ✅ Changing senator status clears base instance if needed ✅ Changing proxy target updates existing instance (no duplicates) ✅ Users with 0 vote instances can view waiting page (no voting options appear) ✅ Multiple proxy assignments for one user (only shows proxy instances, not base)

Future Enhancements (Out of Scope)

  • Proxy validation: Verify proxy target is eligible to be proxied for
  • Vote instance summary in host overview: “3 senators, 2 proxies, 1 external”
  • Proxy audit log: Track who voted as proxy for whom
  • Revoke proxy mid-session: Allow user to cancel proxy assignment
  • Proxy confirmation: Require explicit confirmation from proxy recipient

Deployment Checklist

  • Run database migrations (no new migrations needed; proxy field made nullable in past migration)
  • Backend: cargo build --release
  • Frontend: npm run build
  • Test with fresh database
  • Verify session creation and attendance endpoints work
  • Manual end-to-end test: Create session, join as senator with proxy, vote, check results

Files Modified

Backend

  • crates/voting-app/src/domain/session/handlers.rs — Added SetSessionProxyRequest, SetSessionProxyResponse, rewrote set_session_proxy()

Frontend

  • src/screens/ProxySetup.svelteNEW participation declaration screen
  • src/screens/WaitingPage.svelte — Enhanced to display notice
  • src/screens/SessionCreation.svelte — Participant cards updated with proxy info
  • src/App.svelte — Updated routing + state threading

Documentation

  • docs/db/db-schema.md — Removed voters table, updated schema
  • docs/db/db-json.md — Updated vote payload structure

Notes for Integration

  1. No breaking changes to existing endpoints; new ProxySetup screen is additive
  2. Session creation unchangedPOST /session/create returns same response
  3. Voting unchanged — Vote casting still uses POST /events/{id}/vote
  4. Participation is optional — Non-senator non-proxies simply get 0 instances and see “no voting options”
  5. Proxy names are flexible — Any string accepted (not validated against user roster)

Proxy Voting System - Testing Guide

Pre-Testing Checklist

  • Backend compiled successfully: cargo check passes
  • Frontend compiled successfully: npm run check passes
  • Database exists and migrations have been run
  • Auth service running if required
  • Fresh database state recommended

Test Scenarios

Scenario 1: Non-Senator (No Proxy)

Expected Behavior: User gets 0 vote instances, cannot vote

Steps:

  1. Log in to voter interface
  2. Enter session code and join
  3. On ProxySetup screen:
    • Select “No” for senator
    • Leave proxy name empty
    • Click “Continue”
  4. Should see notice: “You currently have 0 vote instances for this session.”
  5. Verify on WaitingPage that notice is displayed
  6. When motion becomes active, verify user sees “No voting options available”

Verification:

  • /session/{code}/proxy returns vote_instance_count: 0
  • Database check: 0 user_session rows for this user+session with join_left = Joined
  • Attendance endpoint shows user as is_proxy_holder: false, proxy_for: []

Scenario 2: Non-Senator Proxy

Expected Behavior: User gets 1 vote instance (proxy only), can vote once

Steps:

  1. Log in to voter interface
  2. Enter session code and join
  3. On ProxySetup screen:
    • Select “No” for senator
    • Enter proxy name: “Jane Doe”
    • Click “Continue”
  4. Should see notice: “You now have 1 proxy vote instance.”
  5. When motion becomes active, verify user sees 1 voting option labeled “Jane Doe”
  6. Cast vote on that option
  7. Verify vote is recorded with is_proxy: true, proxy_for_name: "Jane Doe"

Verification:

  • /session/{code}/proxy returns vote_instance_count: 1, is_senator: false, has_proxy: true
  • Database check: 1 user_session row with proxy = 'Jane Doe'
  • /events/{id}/vote-instances returns 1 instance with is_proxy: true, proxy_for_name: "Jane Doe"
  • Vote in database has proxy: true in data field
  • Attendance endpoint shows user as is_proxy_holder: true, proxy_for: ["Jane Doe"]

Scenario 3: Senator (No Proxy)

Expected Behavior: User gets 1 vote instance (base only), can vote once as self

Steps:

  1. Log in to voter interface
  2. Enter session code and join
  3. On ProxySetup screen:
    • Select “Yes” for senator
    • Leave proxy name empty
    • Click “Continue”
  4. Should see notice: “You now have 1 vote instance.”
  5. When motion becomes active, verify user sees 1 voting option (unnamed, or labeled “Yourself”)
  6. Cast vote on that option
  7. Verify vote is recorded with is_proxy: false

Verification:

  • /session/{code}/proxy returns vote_instance_count: 1, is_senator: true, has_proxy: false
  • Database check: 1 user_session row with proxy = NULL
  • /events/{id}/vote-instances returns 1 instance with is_proxy: false, proxy_for_name: null
  • Vote in database has proxy: false in data field
  • Attendance endpoint shows user as is_proxy_holder: false, proxy_for: []

Scenario 4: Senator Proxy

Expected Behavior: User gets 2 vote instances (base + proxy), can vote twice

Steps:

  1. Log in to voter interface
  2. Enter session code and join
  3. On ProxySetup screen:
    • Select “Yes” for senator
    • Enter proxy name: “John Smith”
    • Click “Continue”
  4. Should see notice: “You now have 2 vote instances (your own vote + one proxy vote).”
  5. When motion becomes active, verify user sees 2 voting options: one unnamed (self) + one labeled “John Smith”
  6. Cast votes on both options (can be same or different)
  7. Verify both votes are recorded correctly

Verification:

  • /session/{code}/proxy returns vote_instance_count: 2, is_senator: true, has_proxy: true
  • Database check: 2 user_session rows (one with proxy = NULL, one with proxy = 'John Smith')
  • /events/{id}/vote-instances returns 2 instances: one with is_proxy: false, one with is_proxy: true, proxy_for_name: "John Smith"
  • Both votes in database with appropriate proxy fields
  • Attendance endpoint shows user as is_proxy_holder: true, proxy_for: ["John Smith"]

Scenario 5: Idempotency - Same Submission Twice

Expected Behavior: Endpoint is idempotent; calling twice with same payload returns same result

Steps:

  1. On ProxySetup screen:
    • Select “Yes” for senator
    • Enter “Jane Doe”
    • Click “Continue” → notice shows 2 instances
  2. (Hypothetically) Call same endpoint again with identical payload
  3. Should still get notice saying 2 instances

Manual Test (via curl or API client):

curl -X POST http://localhost:8000/session/ABC123/proxy \
  -H "Content-Type: application/json" \
  -H "Cookie: <auth_cookie>" \
  -d '{"is_senator": true, "proxy_for": "Jane Doe"}'
# Response: 2 instances

# Call again with same payload
curl -X POST http://localhost:8000/session/ABC123/proxy \
  -H "Content-Type: application/json" \
  -H "Cookie: <auth_cookie>" \
  -d '{"is_senator": true, "proxy_for": "Jane Doe"}'
# Response: should still be 2 instances, no error

Verification:

  • Both calls return identical response
  • No duplicate user_session rows created
  • Database remains consistent

Scenario 6: Re-Submission with Changes

Expected Behavior: Changing configuration updates instance set correctly

Steps:

  1. User goes through flow as senator with proxy “Jane” → gets 2 instances
  2. On wait page, user realizes they entered wrong name → goes “Back”
  3. Re-enters proxy setup, changes to senator with proxy “John”
  4. Should see notice: “You now have 2 vote instances…” (same count, updated proxy)
  5. Verify database only has “John”, not “Jane”

Manual Test:

# First call
curl -X POST http://localhost:8000/session/ABC123/proxy \
  -d '{"is_senator": true, "proxy_for": "Jane"}'
# Response: 2 instances

# Second call with different proxy
curl -X POST http://localhost:8000/session/ABC123/proxy \
  -d '{"is_senator": true, "proxy_for": "John"}'
# Response: 2 instances (but for John, not Jane)

# Verify database
SELECT proxy FROM user_session WHERE user_id = ? AND session_id = ? AND join_left = 'Joined'
# Should show: NULL and 'John' (not 'Jane')

Verification:

  • Old proxy instance replaced with new proxy name
  • Instance count remains 2
  • No orphaned database rows

Scenario 7: Changing from Senator to Non-Senator

Expected Behavior: Base instance deleted; only proxy remains

Steps:

  1. User initially selects “Yes” for senator with proxy “Jane” → 2 instances
  2. User changes mind on proxy setup, selects “No” for senator + proxy “Jane”
  3. Should see notice: “You now have 1 proxy vote instance.”
  4. Verify database: only 1 row with proxy = 'Jane', no base row

Manual Test:

# First call (senator)
curl -X POST http://localhost:8000/session/ABC123/proxy \
  -d '{"is_senator": true, "proxy_for": "Jane"}'
# Response: 2 instances

# Second call (non-senator, same proxy)
curl -X POST http://localhost:8000/session/ABC123/proxy \
  -d '{"is_senator": false, "proxy_for": "Jane"}'
# Response: 1 instance

# Verify database
SELECT proxy FROM user_session WHERE user_id = ? AND session_id = ? AND join_left = 'Joined'
# Should show: 'Jane' only (no NULL row)

Verification:

  • Base instance deleted
  • Proxy instance preserved
  • Instance count becomes 1

Scenario 8: Host Attendance View

Expected Behavior: Host sees proxy assignments clearly in meeting overview

Steps:

  1. Host creates session and starts waiting for attendees
  2. Multiple users join with different configurations:
    • User A: Senator, no proxy
    • User B: Non-senator, proxying for “User A”
    • User C: Senator, proxying for “User D”
  3. Host views attendance (in SessionCreation hover cards)
  4. Verify each user shows correct proxy status

Verification:

  • GET /session/{code}/attendance returns:
    • User A: is_proxy_holder: false, proxy_for: []
    • User B: is_proxy_holder: true, proxy_for: ["User A"]
    • User C: is_proxy_holder: true, proxy_for: ["User D"]
  • Host UI displays these correctly in participant hover cards

Scenario 9: Proxy Name Whitespace Handling

Expected Behavior: Leading/trailing spaces trimmed server-side

Steps:

  1. User enters proxy name: “ Jane Doe “ (with extra spaces)
  2. Backend should trim and store as “Jane Doe”
  3. Verify notice and voting options show clean name

Manual Test:

curl -X POST http://localhost:8000/session/ABC123/proxy \
  -d '{"is_senator": true, "proxy_for": "  Jane Doe  "}'
# Response: should work, instance created with "Jane Doe"

Verification:

  • Database stores “Jane Doe” (no extra spaces)
  • Voting interface displays “Jane Doe” (no padding)

Scenario 10: Empty Proxy Name

Expected Behavior: Empty string treated as null; no proxy instance created

Steps:

  1. User enters proxy name: “” (empty string)
  2. Backend should treat as null
  3. If senator, should get 1 base instance
  4. If non-senator, should get 0 instances

Manual Test:

curl -X POST http://localhost:8000/session/ABC123/proxy \
  -d '{"is_senator": true, "proxy_for": ""}'
# Response: 1 instance (base only)

curl -X POST http://localhost:8000/session/ABC123/proxy \
  -d '{"is_senator": false, "proxy_for": ""}'
# Response: 0 instances

Verification:

  • Empty string not stored in database
  • Instance count calculated correctly

Integration Test: Full Voting Flow

Objective: Test complete proxy voting flow from session creation to vote recording

Setup:

  • Create a session as admin
  • 3 attendees join: Alice (senator), Bob (non-senator proxying for Alice), Charlie (senator proxying for David)

Steps:

  1. Alice: “Yes” senator, no proxy → expects 1 instance
  2. Bob: “No” non-senator, proxy “Alice” → expects 1 instance
  3. Charlie: “Yes” senator, proxy “David” → expects 2 instances
  4. Admin starts a motion
  5. Each user casts votes on all available options
  6. Verify vote counts in results:
    • Should see 4 total votes (1 + 1 + 2 = 4, not 3)
    • Votes labeled with their “from” user and proxy status

Verification:

  • Vote instances total 4 (not 3)
  • Each vote correctly tagged with user + proxy info
  • Results display includes proxy vote attribution

Performance & Edge Cases

High Concurrency Test

  • 100+ users join session simultaneously
  • All submit participation config in parallel
  • Verify no duplicate instances created

SQL Injection / Input Validation

  • Try proxy name: "'; DROP TABLE user_session; --"
  • Verify: Safely escaped, stored literally (or rejected)

Large Proxy Names

  • Try proxy name: 1000+ character string
  • Verify: Either accepted or reasonable error message

Special Characters

  • Try proxy names with: emoji, unicode, quotes, ampersands
  • Verify: Accepted and displayed correctly

Regression Tests

Ensure No Breaking Changes

  1. Session creation still works:

    • POST /session/create returns expected response
    • No proxy fields in response
  2. Regular voting still works (non-proxy case):

    • Users without proxy can still vote normally
    • Vote structure unchanged
  3. Results endpoints unchanged:

    • /events/{id}/results returns same structure
    • (Proxy data is supplementary in vote metadata)
  4. Admin endpoints unchanged:

    • Session status checks work
    • Event start/end unchanged

Debugging Commands

Check instance count for user

SELECT COUNT(*) FROM user_session
WHERE user_id = ? AND session_id = ? AND join_left = 'Joined'

Check proxy assignments

SELECT user_id, proxy FROM user_session
WHERE session_id = ? AND join_left = 'Joined'
ORDER BY user_id

Check votes cast

SELECT user_session_id, data FROM vote
WHERE event_id = ?
ORDER BY user_session_id

Verify attendance endpoint

curl http://localhost:8000/session/ABC123/attendance \
  -H "Cookie: <auth_cookie>"

Test proxy endpoint directly

curl -X POST http://localhost:8000/session/ABC123/proxy \
  -H "Content-Type: application/json" \
  -H "Cookie: <auth_cookie>" \
  -d '{"is_senator": true, "proxy_for": "Test Name"}'

Expected Behavior Matrix

User TypeInputExpected InstancesBaseProxyNotice
SenatorNo proxy1-“You now have 1 vote instance.”
SenatorProxy “Jane”2“You now have 2 vote instances…”
Non-senatorNo proxy0--“You currently have 0 vote instances.”
Non-senatorProxy “Jane”1-“You now have 1 proxy vote instance.”

Cleanup After Testing

# Clear all sessions (if needed)
DELETE FROM vote;
DELETE FROM user_session;
DELETE FROM session;

# Or reset database
# (Depends on your DB setup/teardown strategy)

Sign-Off Checklist

After all tests pass:

  • Non-senator no-proxy test OK
  • Non-senator proxy test OK
  • Senator no-proxy test OK
  • Senator proxy test OK
  • Idempotency test OK
  • Configuration change test OK
  • Senator→Non-senator change test OK
  • Host attendance view test OK
  • Whitespace handling test OK
  • Empty proxy name test OK
  • Full voting flow test OK
  • Regression tests OK
  • Code compiles without errors
  • Documentation is accurate

Contributing

Thanks for your interest in contributing to Tartan Vote!

Before contributing to this repository, please discuss the change you wish to make via issue on this repository, email to one of the codeowners, or on the ScottyLabs discord.

How Can I Contribute?

For now, please just refer to the communication channels listed above. As this project matures, we will establish a more well-formed contributing structure.

Documentation

When making a change, it would be wonderful if you could update the corresponding documentation. If you cannot or are unsure how to, please leave an issue or let Yiyoung Liu know so that the documentation does not lag behind. If the documentation does not exist, don’t worry about it! (or write the documentation yourself, that would be greatly appreciated.)

Pull Requests

Direct pushes to main are blocked. You should create a branch (if you are a contributor in ScottyLabs) or fork the repository, make your changes, then create a PR to main.

Style Guide

  • All Rust code should be formatted using cargo fmt and linted with cargo clippy. The CI/CD will check that all PR’ed code passes cargo fmt and cargo clippy.
  • All Svelte code should be checked with deno task check. The CI/CD will automatically check this too.

Commit Guidelines

I am a firm believer in the kernel commit style. Not all of the sections in that document are useful, such as the fact that we do not mail patches (unfortunately), but most of the pieces of advice are helpful nonetheless. Good commit habits reflect on the developer. Being able to clearly reflect upon your changes and describe the impact of them means you are able to reason about your code and about why you are making the changes you are.

Commit Subjects

Commit subjects should be styled in the following method:

system: subsystem (if applicable): short description

A list of possible commit types, but not exhaustive:

  • backend: auth: created migrations for token storage
  • backend: session: ensures user must exist before joining
  • docs: process: add section on code review
  • devenv: update to latest scottylabs version
  • frontend: motion: center vote div

I would prefer not to see ‘chore: format’ or ‘fix: some stuff’. This is not helpful to me as a maintainer or to your future self or other people by being vague about what you are doing.

It should not be terribly difficult to write commit subjects. If it feels that your commit can’t be easily grouped into a system or subsystem, perhaps reevaluate if you should split your commit into two or more smaller commits.

Commit Descriptions

In addition, add a description to your commits. This is where you summarise the changes you made and why you made them, so that anyone can come back and read about the thought process and reasoning behind the changes.

You can more easily write a long commit description with the command git commit rather than git commit -m.

The description should truncate lines at about 80 characters (it should do this automatically if you are editing via command line, but I’m not too sure about other editors). This makes it easier to read commits on terminal screens from git log and on the git repos.

Making fixes

Perhaps I will ask you to make some changes to your code. While it is tempting to make your fixes and make a commit called fixes, I recommend against you doing that, and rebasing your changes into the commit in which it goes along with.

For example, say (hypothetically) I get a PR submitted to me, with some changes that look like

--- a/main.c
+++ b/main.c
...
+ printf("hi

Now, you may not need to know how to read a patch file, and you may not know how to read c code the best, but you can probably tell that that code probably doesn’t compile (it’s missing a quotation mark, a parenthesis, and a semicolon!). A lazy way to fix this code would be to make a new commit called main: fix syntax, but when I merge your changes people don’t really want to see that you fixed some syntax in the git history…

The best (and in my opinion, correct) way to do this is to rebase your commits. You can make a random commit message (doesn’t really matter, it’ll disappear anyways) for these new fixes.

Then, you can use the command git rebase -i HEAD~2 (or however many commits you want to go back, such as HEAD~5, etc.) to bring up the interactive rebasing screen.

When you change the word in front of your newest commit to fixup or f, for example

pick a943d2e main: print hi message
pick 27eaa11 random commit message

turns into

pick a943d2e main: print hi message
fixup 27eaa11 random commit message

saving and leaving the file will combine your fixup commit with the one above it, and this cleans up your git history! Now you can git push --force to update your PR upstream. (don’t worry, pushing with force to your own branch is OK, but don’t do it to others without their approval!)

Undoing fixes

Maybe you messed up. That’s perfectly fine! Git provides you many tools to undo your mistakes.

One of the best tools is git reflog, short for “reference log”. Many things you do in git change the reference you are on, and so undoing your mistakes is as simple as going to a previous reference.

Suppose I rebased the two commits exactly how they appeared in the previous section (pick, then fixup). A reflog of the rebase (with git rebase -i HEAD~2) may look something like:

a943d2e HEAD@{0} rebase (finish): returning to refs/heads/branch
a943d2e HEAD@{1} rebase (fixup): main: print hi message
32ga76f HEAD@{2} rebase (start): checkout HEAD~2
ef9a327 HEAD@{3} previous stuff...

Suppose I didn’t actually want to rebase. (oops!) I could run git checkout HEAD@{3} to checkout the third previous reference, in this case, “previous stuff…”, which occurred before all of the rebasing.

This allows you do undo commits, rebases, branch deletions, almost everything except for resetting your uncommitted changes! (git reset --hard HEAD) Please do be careful.

Extensive Guide to Running Tartan Vote

Prerequisites

This project uses devenv to provide Cargo, Deno, Node, PostgreSQL, and all other development dependencies. Follow the devenv installation instructions.

Starting up

Now, we will get your own instance of Tartan Vote running!

Setup

You will need git.

Clone the repository from Codeberg:

git clone https://codeberg.org/ScottyLabs/tartan-vote.git
cd tartan-vote

Run direnv allow (or devenv shell) to enter the development environment. This exposes Cargo, Deno, Node, PostgreSQL, and other tooling.

Secrets

Configuration is provided automatically inside devenv shell — you do not need to create a .env. Secrets are pulled from OpenBao via secretspec, so authenticate once:

export BAO_ADDR=https://secrets2.scottylabs.org
bao login -method=oidc

If devenv shell reports missing secrets or you get 403 permission denied, see secrets-and-config.md, which documents the full secrets model and troubleshooting.

Run everything

From the repo root, inside the devenv shell:

# 1. Start the managed services (Postgres, OAuth relay)
devenv up

# 2. In another terminal: build the frontend into frontend/dist
cd frontend && deno task build && cd ..

# 3. Run the backend; it serves the API and the built frontend on :8080
cargo run

Then open http://localhost:8080.

When working on the frontend, run deno task build:watch in a separate terminal instead of the one-off build; it rebuilds frontend/dist on save, and a browser refresh picks up the changes.

Terrier

Open-source hackathon management platform for universities and organizations.

Features

  • Registration and team management. Customizable application forms, team formation, and attendee management.
  • Live judging. Real-time expo-style judging with support for multiple scoring systems.
  • Multiple distribution methods. Docker(-compose), Nix flakes, and standalone binaries are supported.
  • Enterprise SSO. OIDC and SAML support for institutional authentication, available to everyone.
  • Mobile app. Native iOS and Android app for attendees, organizers, and judges.
  • Documentation. Comprehensive documentation site with deployment guides and usage instructions.
  • AI-enabled. MCP server integration and tasteful AI features for quality-of-life improvements.
  • Self-hosted. You have full control over your data and infrastructure.

Canonical Deployment Domains

Terrier uses the following production custom domains:

ComponentKennel keyDomain
Frontend siteN/Aterrier.scottylabs.org
API servicescottylabs.kennel.services.terrierapi.terrier.scottylabs.org
Documentation sitescottylabs.kennel.sites.docsdocs.terrier.build

The API and documentation values are declared in devenv.nix and should be treated as the source of truth for deployment routing.

Maintainers

Developed and maintained by ScottyLabs at Carnegie Mellon University.