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

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://git.cmu.dev/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://git.cmu.dev/ScottyLabs/my-project"
type = "starlight"
docs_dir = "docs"
description = "Documentation for My Project"

Starlight Project Example

[[project]]
slug = "guides"
name = "User Guides"
repo = "https://git.cmu.dev/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://git.cmu.dev/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://git.cmu.dev/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.

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.

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…

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 Forgejo 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.

Forgejo Setup

Sign up on git.cmu.dev.

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@git.cmu.dev
    

    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.

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

A real-time bus sign that displays Pittsburgh Regional Transit (PRT) arrivals for the Forbes & Morewood bus stops. Made in collaboration with the Undergraduate Student Senate. Launched in the Cohon University Center, coming soon to the Tepper Building!

Visit the online bus sign at https://bus-sign.scottylabs.org!

Prerequisites

Setup

Secrets

The PRT and OpenWeather API keys are loaded from OpenBao by secretspec. Authenticate once per machine with:

nix run git+https://git.cmu.dev/ScottyLabs/kennel#login

Allow devenv, or enter the shell if already allowed:

devenv allow
# or: devenv shell

Running

# Starts the backend on :8080 and the frontend on :5173
devenv up

Visit the frontend at http://127.0.0.1:5173/!

Cal

A unified web calendar for CMU academic events, live at cmucal.com. Source: ScottyLabs/cal.

Getting Started

Run the CMUCal frontend and backend on your laptop. You do not need nix or devenv; only kennel and CI use them.

1. Join the org

  1. Sign up on git.cmu.dev and add an SSH key.
  2. Ask the tech lead to add you to the CMUCal team in governance.
  3. Link all five accounts (CMU, git.cmu.dev, GitHub, Discord, Slack) at idp.scottylabs.org. Governance validation fails if one is missing.

2. Prerequisites

Node 22 with npm, Python 3.10 or newer, uv, and the OpenBao and secretspec CLIs:

brew install uv openbao secretspec

On Windows, install uv from its site and take bao and secretspec from the OpenBao and secretspec releases.

3. Secrets

Dev secrets live in OpenBao. Once governance has added you to the CMUCal team, log in with your CMU account (it opens a browser):

bao login -address=https://secrets.scottylabs.org -method=oidc

The token lasts a few weeks; run it again when secretspec reports permission denied. Then clone the repository and check that every secret resolves:

git clone git@git.cmu.dev:ScottyLabs/cal.git
cd cal
secretspec check -P dev

secretspec run -P dev -- <command> starts a command with the secrets in its environment, so nothing secret is written to disk. If OpenBao does not work for you, use the manual setup instead.

4. Backend

cd api
uv sync
secretspec run -P dev -- uv run python run.py

The API listens on http://localhost:8080; curl localhost:8080/api/health checks it.

5. Frontend

Sign-in goes through ScottyLabs Keycloak and the Ricochet relay, which you run locally. Install it once (needs Rust):

cargo install --git https://codeberg.org/anish/ricochet --rev ddc58bcad0bb2b898f900a3c13c94c263e797ad2 --locked

Then, in its own terminal:

RICOCHET_DEV=1 RICOCHET_BIND=127.0.0.1:8090 ricochet

In another terminal, from the repository root:

cd web
npm install
secretspec run -P dev -- npm run dev

Open http://localhost:3000 and sign in with your CMU account.

6. Migrations

When a pull request adds an Alembic migration:

cd api
secretspec run -P dev -- uv run alembic upgrade head

Everyone shares one dev database. Never run alembic downgrade without asking the tech lead, and never point a local checkout at production.

Manual setup

Without OpenBao, keep the secrets in local env files and run every command above without the secretspec run -P dev -- prefix.

cp api/.env.example api/.env.development
cp web/.env.example web/.env.local

In api/.env.development, fill in SUPABASE_DB_URL (the shared dev database). In web/.env.local, fill in OIDC_CLIENT_SECRET and SESSION_SECRET (any random 32+ characters). Ask the tech lead for the values, or copy them from secretspec/cal/dev in the OpenBao web UI (sign in with OIDC). Never commit these files.

Troubleshooting

  • secretspec reports permission denied: your OpenBao token expired, so run bao login again. If it still fails, governance has not added you to the CMUCal team yet.
  • Sign-in says Keycloak login is not configured: npm run dev was started without secretspec run -P dev --, or (manual setup) a value in web/.env.local is empty. Next.js reads env files only at startup, so restart npm run dev after editing it.
  • Sign-in ends on a connection error at localhost:8090: Ricochet is not running.
  • API crashes with Expected string or URL object, got None: SUPABASE_DB_URL is missing: the API was started without secretspec run -P dev --, or (manual setup) api/.env.development does not exist or you did not start the API from api/.
  • Pages load but data never does: some CMU networks block outbound Postgres. Try CMU-SECURE, eduroam, or a hotspot.

Cmugpt Agent

Bark Agent is the backend service for Bark, the campus assistant for Carnegie Mellon University built by ScottyLabs. It receives chat messages from the Bark web application and answers them with a language model and a set of campus data tools. Each answer is checked for safety and accuracy before it is returned, and the service keeps long-term memory for each user.

Overview

Bark consists of two services.

  • The Surface (cmugpt-surface) is the web application and its server. It authenticates users, stores chats, and forwards each message to this service.
  • The Agent (this repository) processes each message. It selects the campus tools the question requires, runs a LangGraph agent against models served through OpenRouter, validates the result, and streams the answer back to the Surface.

Campus data comes from the CMU MCP server (mcp-server), which publishes tools for maps, courses, dining, and the student guide over the Model Context Protocol. OpenAI provides the embeddings used for memory search and the moderation endpoint. Per-user memory is stored in PostgreSQL with the pgvector extension.

flowchart TB
    browser["Browser"]
    surface["Surface<br/>web app and API server"]
    agent["Bark Agent<br/>(this repository)"]
    openrouter["OpenRouter<br/>language models"]
    mcp["CMU MCP server<br/>campus data tools"]
    openai["OpenAI<br/>embeddings, moderation"]
    postgres["PostgreSQL<br/>user memory (pgvector)"]

    browser -->|chat| surface
    surface -->|"POST /agent/respond/stream"| agent
    agent --> openrouter
    agent --> mcp
    agent --> openai
    agent --> postgres

    style agent stroke-width:3px

Request lifecycle

A request passes through five stages.

  1. Validation. The Surface posts the message, the prior turns of the chat, and a hashed user identifier. Before any model call, the service enforces request size limits, checks the user’s daily token budget, and screens the message with OpenAI’s moderation endpoint.
  2. Planning. planning.py determines what the turn requires. It decides which tool groups to bind, whether the remember and forget tools are needed, and whether memory recall should run. Conversational messages bind no data tools. The map tool is bound on every turn unless the user has disabled maps, because the model decides whether a map belongs in the answer.
  3. Execution. graph.py runs a LangGraph graph. It recalls relevant facts about the user, invokes the model, executes any tool calls, and repeats until the model produces a final answer. Tool output is wrapped as untrusted data so that it cannot inject instructions.
  4. Verification. guards.py and the maps/ package check the finished answer without a model. The model’s map selection is validated against the building catalog, and incorrect claims that a lookup failed are repaired. Secrets and system prompt text are removed, and tool usage is disclosed accurately.
  5. Delivery. The answer is streamed to the Surface as Server-Sent Events. Once the answer is complete, a background task extracts durable facts about the user from the exchange and stores them for future turns.

Memory holds only the facts extracted from conversations and the facts a user explicitly asks Bark to remember. Raw chat turns are never stored. A user can view and delete their facts through the Surface, and each user’s memory is kept separate by identifier.

Project structure

cmugpt-agent/
│
├── src/cmugpt/
│   │
│   ├── api/                    # HTTP layer
│   │   ├── server.py           #   FastAPI app: lifespan, CORS, error envelope, routers
│   │   ├── deps.py             #   Bearer-token and body-size checks shared by routes
│   │   └── routes/
│   │       ├── agent.py        #   /agent/respond, /agent/respond/stream, /agent/title
│   │       ├── memory.py       #   /memory/*
│   │       └── health.py       #   /api/health
│   │
│   ├── settings.py             # All environment variables
│   ├── schema.py               # Request and response models
│   ├── planning.py             # Per-turn tool and memory selection
│   ├── graph.py                # LangGraph control flow
│   ├── prompts.py              # System prompt construction
│   ├── llm.py                  # OpenRouter model client factory
│   ├── mcp_tools.py            # MCP tool discovery and group filtering
│   ├── guards.py               # Deterministic output checks
│   ├── moderation.py           # OpenAI moderation for input and output
│   ├── token_limits.py         # Per-user daily token budget (SQLite)
│   ├── title.py                # Chat title generation
│   │
│   ├── memory/                 # Per-user long-term memory
│   │   ├── store.py            #   LangGraph store: Postgres with pgvector, or in-memory
│   │   ├── facts.py            #   Recall, save, forget
│   │   ├── tools.py            #   The remember and forget tools exposed to the model
│   │   ├── extraction.py       #   Background fact extraction
│   │   └── manage.py           #   List, delete, clear
│   │
│   └── maps/                   # Campus map support
│       ├── buildings.py        #   Building catalog and aliases
│       ├── buildings.json      #   Catalog data
│       ├── tool.py             #   The maps_show_map tool
│       └── inference.py        #   Map validation and URL construction
│
├── tests/unit/                 # Offline tests, run by CI
├── evals/                      # Live evaluations, run manually
│
├── pyproject.toml              # Dependencies, entry point, tool configuration
├── devenv.nix                  # Local environment and Kennel settings
├── flake.nix                   # Nix build
├── secretspec.toml             # Secret declarations
└── .env.example                # Environment variable template

Requirements

You will need the following, or Nix and devenv in their place (see Installation with devenv):

Installation

The steps below set up local development with the full feature set: persistent memory, semantic memory search, and moderation. This configuration is recommended, since the service then behaves as it does in production. The service also starts without OPENAI_API_KEY or DATABASE_URL, with the reduced behavior described under Configuration.

  1. Clone the repository and install its dependencies.

    git clone https://git.cmu.dev/ScottyLabs/cmugpt-agent.git
    cd cmugpt-agent
    uv sync
    
  2. Create the memory database. Install PostgreSQL and the pgvector extension, then create an empty database named cmugpt_agent. The service creates the pgvector extension, its schema, and its tables on first start. If you prefer not to install PostgreSQL by hand, the devenv shell defined in devenv.nix provides a database with pgvector already set up (see Installation with devenv).

  3. Create the environment file and add the API keys.

    cp .env.example .env
    

    Then set the two keys in .env:

    OPENROUTER_API_KEY=<your OpenRouter key>
    OPENAI_API_KEY=<your OpenAI key>
    

    MCP_SERVER_URL and DATABASE_URL are prefilled. They point at the production MCP server and at the cmugpt_agent database on the local default socket.

Installation with devenv

Members of the slai team can use the devenv shell in place of the Installation steps. It provides Python, uv, and PostgreSQL with pgvector, and it loads the team’s shared development keys from OpenBao, so no personal API keys or .env are needed.

Before starting, complete the ScottyLabs setup on docs.scottylabs.org:

  • Forgejo Setup creates a git.cmu.dev account and SSH key.
  • Contributing explains how to join a team through governance. Join slai (data/teams/slai.toml), since team membership grants access to the secrets.
  • Credentials describes how ScottyLabs stores secrets in OpenBao. Kennel’s Secrets guide covers the local login and secretspec profiles used below.

Then install the tools:

  1. Nix, with the Determinate Systems installer on macOS, Linux, or WSL:

    curl -fsSL https://install.determinate.systems/nix | sh -s -- install
    
  2. devenv, from a new terminal:

    nix profile install nixpkgs#devenv
    
  3. Optionally, direnv with its shell hook, so the environment loads when you enter the repository.

The shell resolves secrets as it starts and fails without an OpenBao token, so log in once per machine first:

nix run git+https://git.cmu.dev/ScottyLabs/kennel#login

Then clone the repository and start PostgreSQL and the service together:

git clone ssh://forgejo@git.cmu.dev/ScottyLabs/cmugpt-agent.git
cd cmugpt-agent
devenv up

The token renews on each shell entry and expires after 90 days without use. If the shell fails with an OpenBao or permission error, run the login command again. If it still fails, confirm that your git.cmu.dev username is listed in data/teams/slai.toml. secretspec check -P dev reports which secrets resolve without printing their values.

Configuration

All configuration is read from environment variables by settings.py, and .env.example documents each variable with its default.

VariablePurpose
OPENROUTER_API_KEYChat, memory extraction, and chat titles
OPENAI_API_KEYEmbeddings for memory search and the moderation endpoint. Unset, recall orders facts by recency and moderation is skipped
DATABASE_URLPostgreSQL connection string. Unset, memory lives in an in-memory store that is cleared on restart
MCP_SERVER_URLBase URL of the CMU MCP server, including the /mcp path
AGENT_SHARED_SECRETBearer token the Surface presents on every request. Unset, requests are unauthenticated. Set in production
AGENT_ENVproduction makes startup fail without DATABASE_URL and an AGENT_SHARED_SECRET of at least 32 characters. Unset until the prod profile of secretspec.toml sets it again
ALLOWED_ORIGINSComma-separated browser origins for CORS. Default https://cmugpt.com
PORTListening port. Default 5055
TITLE_MODELModel for chat titles. Default qwen/qwen3.7-flash
MEMORY_EXTRACTION_MODELModel for background fact extraction. Default qwen/qwen3.7-flash
TOKEN_USAGE_DBSQLite file for the daily token budget. Default /tmp/cmugpt_token_usage.sqlite3

Production does not read .env. Kennel injects DATABASE_URL for its managed PostgreSQL instance, the API keys and MCP_SERVER_URL are resolved from OpenBao, and AGENT_SHARED_SECRET is set so that only the Surface can call the service. See Deployment.

Running the service

Start the service with:

uv run cmugpt-agent

It listens on port 5055. To confirm that it is healthy, request the health route:

curl -s localhost:5055/api/health
{"status":"ok","memory":{"backend":"postgres","initialized":true,"semantic_search":true,"embedding_model":"text-embedding-3-large","ready":true}}

backend reports in-memory when DATABASE_URL is unset, and semantic_search is false when OPENAI_API_KEY is unset. When the memory store cannot be queried, the endpoint returns HTTP 503 with "status": "degraded".

API

When AGENT_SHARED_SECRET is set, every route except /api/health requires the header Authorization: Bearer <secret>.

RouteDescription
POST /agent/respondReturns the complete answer as a JSON object
POST /agent/respond/streamReturns the answer as Server-Sent Events
POST /agent/titleGenerates a short title from a chat’s first message
GET /memory/{user_id}Lists a user’s stored facts, with search and paging
DELETE /memory/{user_id}/items/{kind}/{item_id}Deletes one fact
DELETE /memory/{user_id}Deletes all facts for a user
GET /api/healthService status and active memory backend

For example:

curl -s localhost:5055/agent/respond \
  -H 'content-type: application/json' \
  -d '{"query": "What is open for lunch near Gates?", "user_id": "example"}'

Request fields for /agent/respond and /agent/respond/stream:

FieldDescription
queryThe user’s message. Required. At most 8,000 characters
user_idIdentifier for memory and the token budget. The Surface sends a hash of the authenticated user
message_historyPrior turns as {"role", "content"} objects. The last 40 are used
modelOpenRouter model identifier. Default openai/gpt-5.6-luna
disabled_toolsTool groups the user has switched off: maps, courses, eats, guide

The streaming endpoint emits several event types. status events are sent while tools run, and delta events carry text as it is generated. A map event is sent when a campus map accompanies the answer, and a memory event when a fact is saved or removed. A final done event contains the complete response object, and an error event terminates a failed turn.

Each user is limited to one million tokens per day. Requests beyond that limit receive HTTP 429.

Testing

DATABASE_URL="" uv run pytest    # offline unit tests, as run by CI
uv run pytest evals              # live evaluations

The unit tests in tests/unit/ run with the model replaced by a stub and an in-memory store. They are deterministic and fail only when the code is incorrect.

The evaluations in evals/ send real questions to the configured model and MCP server and check the behavior of the answers: tool usage, refusal of prompt injection, and absence of fabricated details. They require OPENROUTER_API_KEY and MCP_SERVER_URL and incur API costs. When either key is absent they are skipped, and they are not part of the default pytest run.

Development

The code is formatted and linted with ruff and type-checked with ty:

uv run ruff format    # format
uv run ruff check     # lint. The project configuration applies fixes.
uv run ty check       # type check

Deployment

Production runs on Kennel, the ScottyLabs deployment platform. Kennel builds the agent package defined in flake.nix and runs its cmugpt-agent entry point as a systemd unit. PORT, DATABASE_URL, and the secrets from the prod profile of secretspec.toml are injected as environment variables. Once that profile sets AGENT_ENV=production again, the service refuses to start without DATABASE_URL and an AGENT_SHARED_SECRET of at least 32 characters. Until then production starts without that check. Pushes to main on git.cmu.dev trigger a deployment, and each pull request receives a preview deployment.

Production secrets are stored in OpenBao and managed with secretspec.

Contributing

CONTRIBUTING.md describes the workflow, the code style, the commit conventions, and the pull request process.

  • cmugpt-surface: the web application and its server.
  • mcp-server: the CMU MCP server that publishes the campus data tools.
  • kennel: the deployment platform.

License

Apache License 2.0. See LICENSE.

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

Bark Surface is the user-facing web app and BFF (backend-for-frontend) API for Bark, ScottyLabs’ CMU-focused AI chat assistant. It handles authentication, chat history, memory management, and streams chat responses from the cmugpt-agent service, which does the actual agent/LLM work.

This repo is built on ScottyStack, ScottyLabs’ full-stack typesafe template, so the general ScottyStack docs also apply here. This README covers what’s specific to this project.

Repo structure

This is a Deno workspace with two apps and no separate agent code - the actual chat/LLM logic lives in the sibling cmugpt-agent repo.

cmugpt-surface/
|-- apps/
|   |-- server/            # Express API (BFF) - auth, chat, memory
|   |   |-- src/
|   |   |   |-- server.ts        # entrypoint: migrations, middleware, routes
|   |   |   |-- env.ts           # zod-validated environment schema
|   |   |   |-- controllers/     # tsoa controllers (source of truth for the API)
|   |   |   |-- routes/          # hand-written routes (SSE streaming, auth)
|   |   |   |-- services/        # business logic (chat, memory, preferences)
|   |   |   |-- middlewares/     # error handling, 404s
|   |   |   |-- lib/             # OIDC client, agent HTTP client, auth helpers
|   |   |   `-- db/              # drizzle schema + client
|   |   |-- drizzle/             # SQL migrations (drizzle-kit)
|   |   `-- build/               # generated: OpenAPI spec, routes, types
|   `-- web/                # React SPA
|       |-- src/
|       |   |-- routes/          # TanStack Router file-based routes
|       |   |-- components/      # chat UI, memory manager, icons
|       |   |-- integrations/    # auth + TanStack Query wiring
|       |   `-- lib/api/         # typed API client (generated from server OpenAPI)
|       `-- e2e/                 # Playwright end-to-end tests
|-- nix/, devenv.nix, flake.nix    # Nix/devenv dev environment + build packages
|-- scripts/secrets/               # git submodule: secrets-sync-scripts
`-- secretspec.toml                # declares required env vars / secrets

Key thing to know: apps/server is the source of truth for the API shape. tsoa generates an OpenAPI spec + Express routes from the controllers in apps/server/src/controllers, and apps/web imports the generated openapi.d.ts types directly (see the generate task below) to get a fully typed API client with no manual API type definitions on the frontend.

Tech stack

LayerTech
Runtime / package managerDeno (workspace of two deno.json projects, npm deps via npm: specifiers)
API serverExpress 5, tsoa (decorators to OpenAPI + routes), Zod
DatabasePostgreSQL + pgvector (semantic memory search), Drizzle ORM + drizzle-kit
AuthOIDC (Andrew ID SSO) via openid-client/jwks-rsa, plus lightweight server-side sessions (better-auth-shaped tables)
FrontendReact 19, Vite, TanStack Router + TanStack Query, Tailwind CSS v4
API contractAuto-generated OpenAPI spec, consumed via openapi-fetch + openapi-react-query
TestingVitest (unit, web), Playwright (e2e, apps/web/e2e)
Dev environmentdevenv + Nix, via ScottyLabs’ internal kennel devenv modules
Secretssecretspec - local .env or ScottyLabs’ OpenBao vault
CI/CDForgejo Actions, reusing ScottyLabs/kennel’s shared CI workflow

Before you start

Complete the ScottyLabs setup on docs.scottylabs.org first:

  1. A git.cmu.dev account with SSH set up. See Forgejo Setup.
  2. Membership in the slai team. Contributing explains how to join a team through governance: add your git.cmu.dev username to members in data/teams/slai.toml and open a PR. This membership is what grants read access to the project’s dev secrets (OIDC client, agent secret, and so on) in OpenBao. Without it you can log in, but secrets fail to load. Credentials and Kennel’s Secrets guide describe how these secrets are stored and loaded.
  3. Nix and devenv (below). The shell provides Deno, Postgres with pgvector, the local login relay, bao, and secretspec, so you don’t need to install those yourself. You only need Git.

Installing Nix and devenv

  1. Nix, with the Determinate Systems installer on macOS, Linux, or WSL:

    curl -fsSL https://install.determinate.systems/nix | sh -s -- install
    
  2. devenv, from a new terminal:

    nix profile install nixpkgs#devenv
    
  3. Optionally, direnv with its shell hook, so the environment loads when you cd into the repo.

Logging in to OpenBao (once per machine)

The devenv shell loads secrets from ScottyLabs’ OpenBao as it starts, so it won’t build until you’re logged in. Log in once with the standalone command (it runs before you have bao installed):

nix run git+https://git.cmu.dev/ScottyLabs/kennel#login

A browser window opens for ScottyLabs SSO. The token renews each time you enter the shell, so you only need to log in again on a new machine or after 90 days without using it.

Running it

git clone ssh://forgejo@git.cmu.dev/ScottyLabs/cmugpt-surface.git
cd cmugpt-surface
devenv shell   # or `direnv allow` if you use direnv
devenv up

Don’t clone with --recurse-submodules. scripts/secrets is a legacy submodule from the old Vault setup that points at a GitHub SSH URL, and you don’t need it.

devenv shell only builds the environment and loads secrets. devenv up starts every process defined in devenv.nix and the shared ScottyLabs module:

  • postgres: Postgres with pgvector (devenv exports DATABASE_URL)
  • ricochet: the local OAuth relay on 127.0.0.1:8090. Andrew ID login won’t work locally without it.
  • api: the server on :3001
  • web: Vite on http://localhost:4173

To run the apps yourself (for example, to see their output in your own terminal), start only the supporting services and run Deno in another devenv shell:

devenv up postgres ricochet            # terminal 1
deno install && PORT=3001 deno task dev # terminal 2

PORT=3001 matters: devenv only sets it for its own api process, and without it the server falls back to port 80, which the Vite dev proxy doesn’t target.

By default the server talks to the deployed agent at https://api.cmugpt-agent.scottylabs.org. To use a local agent, run cmugpt-agent with devenv up and set AGENT_API_URL=http://localhost:5055 before starting the server. The agent’s README covers its own setup.

Troubleshooting

  • Shell fails with an OpenBao, secretspec, or permission error. Your OpenBao token is missing or expired: re-run the nix run ...#login command above. If login works but secrets still fail, you’re probably not in the slai team yet (see Before you start).
  • Check which secrets resolve (reports whether each is present, never the values): secretspec check -P dev
  • Server crashes on startup with a zod error. A required variable is missing. Usually DATABASE_URL is unset because Postgres wasn’t started through devenv up.
  • See full startup logs: devenv --no-tui shell

Nix is the supported path. Without it, Andrew ID login won’t work locally, because the ricochet relay it depends on is provided by Nix. You can still work on the parts that don’t need login.

  1. Install:

  2. Create a database with pgvector:

    createdb cmugpt_surface
    psql -d cmugpt_surface -c 'CREATE EXTENSION IF NOT EXISTS vector;'
    export DATABASE_URL='postgresql:///cmugpt_surface?host=/tmp'
    export PORT=3001
    
  3. Log in to OpenBao (needs slai team membership) and run with the dev secrets injected:

    BAO_ADDR=https://secrets.scottylabs.org bao login -method=oidc
    deno install
    secretspec run -P dev -- deno task dev
    

Migrations run automatically on server startup (apps/server/src/server.ts calls drizzle-orm’s migrate() against apps/server/drizzle before the Express app starts listening).

Environment variables

Declared in secretspec.toml and validated at runtime by apps/server/src/env.ts:

VariableRequiredDescription
DATABASE_URLyesPostgres connection string
OIDC_ISSUER_URLyesOIDC issuer for Andrew ID SSO
OIDC_CLIENT_IDyes (default sl-ai-local)OIDC client ID
OIDC_CLIENT_SECRETyesOIDC client secret
ALLOWED_ORIGINS_REGEXyesRegex of allowed CORS origins
AGENT_API_URLyes (default https://api.cmugpt-agent.scottylabs.org)Base URL of the cmugpt-agent service
AGENT_SHARED_SECRETprod only, but strongly recommended locallyApp-to-app bearer secret shared with cmugpt-agent; must not use a VITE_-prefixed name or otherwise reach the browser. Generate with openssl rand -hex 32. Required to be >=32 chars with no leading/trailing whitespace in production.
OAUTH_RELAY_URLnoShared “ricochet” OAuth relay callback, used so preview deployments can authenticate without registering their own redirect URI
PORT / SERVER_PORTno (default 80; devenv sets PORT=3001)API server port. PORT takes precedence.
APP_URLno (default http://localhost:4173)Public URL of the web app, used for OIDC callback construction
ADMIN_GROUPnoAdmin group name (stopgap until real governance wiring exists)

See apps/server/README.md for more on the agent-connection secret.

Common tasks

Run from the repo root (these fan out to the two workspace apps):

CommandDescription
deno task devRun server + web dev servers concurrently (outside devenv up, prefix with PORT=3001)
deno task generateRegenerate the OpenAPI spec/routes from server controllers (also runs automatically before build/dev)
deno task buildBuild both apps for production
deno task checkType-check both apps (deno check)
deno task test:e2eRun Playwright e2e tests against the web app
deno task db:generateGenerate a new Drizzle migration from schema changes
deno task db:migrateApply pending migrations

Per-app tasks (run with deno task --cwd apps/<app> <task>, or cd into the app first):

  • apps/server: server (no regen), tsoa:watch, openapi:watch, start, check
  • apps/web: dev, build, preview, test (Vitest unit tests), check

Testing

  • Unit tests (apps/web): deno task --cwd apps/web test (Vitest + Testing Library)
  • E2E tests: deno task test:e2e from the repo root, powered by playwright.config.ts. It boots the web dev server automatically and runs specs in apps/web/e2e/.
  • Type checking: deno task check

Deployment

Deployed via ScottyLabs’ internal kennel platform (see the kennel block in devenv.nix):

  • Web (SPA) -> bark.scottylabs.org
  • API service -> api.cmugpt.com

flake.nix defines Nix packages (web, api) used to build production artifacts - the API compiles to a standalone Deno binary via deno compile.

  • cmugpt-agent - the actual chat agent/LLM service this server proxies to (OpenRouter for chat, OpenAI embeddings for memory search, pgvector-backed long-term memory).

License

Dual-licensed under Apache-2.0 or MIT, at your option.

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.

Courses

Course browsing app with a Svelte frontend and Rust API.

Setup

Install Nix and devenv. You’ll also need access to git.cmu.dev and ScottyLabs’ OpenBao secrets service. Ask a maintainer for access and authentication setup; the dev profile loads secrets automatically.

The API also needs a published catalog.bin in the configured CDN_S3_BUCKET. If startup reports NoSuchKey, ask a maintainer to confirm the catalog location or publish it using the Scrape and Publish Catalog workflow.

git clone ssh://forgejo@git.cmu.dev/ScottyLabs/courses.git
cd courses
devenv shell

The environment provides Rust, Deno, Git, and the WebAssembly tooling. First startup downloads dependencies and builds the browser’s WebAssembly module.

Optional: with direnv configured in your shell, run direnv allow at the repository root to activate the environment automatically when entering the directory.

Run locally

Use three terminals, each with the development environment loaded. Start from the repository root in each terminal.

# Terminal 1: PostgreSQL and the Ricochet login relay
devenv up
# Terminal 2: web API on port 3002
cargo run -p courses-web-api
# Terminal 3: frontend
cd sites/web
deno task dev

Open http://localhost:5173. The frontend proxies /api and /auth to the web API on port 3002. Stop the foreground processes with Ctrl+C.

Preview a production build

From sites/web, with the development environment loaded:

deno task build
deno task preview

Open http://localhost:4173. Keep the API running for backend features. To test login with the configured callback URL, stop the dev server and use deno task preview --port 5173, with the login relay running. Rebuild after changes. Preview is for local testing; deployment serves the generated sites/web/build directory through courses-web-api using STATIC_DIR.

Useful commands

CommandRun fromPurpose
deno task checksites/webCheck Svelte and TypeScript
build-wasmRepository rootRebuild browser bindings after Rust index changes
direnv reloadRepository rootReload the environment after configuration changes
devenv --no-tui shellRepository rootShow plain startup logs when troubleshooting

More details: architecture and scraper usage.

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! Made in TypeScript using Discord.js.

Try some of dalmatian’s bot commands!

  • /fce - View and compare Faculty Course Evaluations (FCEs) across semesters and professors
  • /dining - View the statuses of dining locations on campus
  • /course - Look up information about a specific course

To add Dalmatian to your server, click here!

Getting Started

Prerequisites

Setup

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

Quick setup:

  1. Install devenv (see link above)
  2. Create a Discord bot at https://discord.com/developers/applications and invite it to a server you can test in
  3. Get your DISCORD_TOKEN and DISCORD_CLIENT_ID and put them in .env:
    cp .env.example .env
    # Edit .env with your Discord bot credentials
    

Running the Bot

Authenticate once per machine with:

nix run git+https://git.cmu.dev/ScottyLabs/kennel#login

Then start the bot and a local PostgreSQL database:

devenv up

Database migrations and slash command registration happen automatically when the bot starts. The first run may take a while as devenv downloads its tooling.

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.

If this is your first time, follow SETUP.md first to create your Discord bot and run it locally.

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

The bot uses PostgreSQL for storing polls and reaction redirect configurations. You don’t need to install or start it yourself. devenv up runs a local PostgreSQL server, and the bot applies any pending migrations when it starts.

If you change the schema in src/db/, generate a new migration with:

deno run db:generate

To inspect the database, run deno run db:studio inside devenv shell while devenv up is running.

To completely reset the database, stop devenv up, delete .devenv/state/postgres, and start devenv up again.

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, please check the issues board.

Need Help?

If you have questions or need help:

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

Remember to follow conventional committing guidelines while contributing!

Setting up Dalmatian

Creating your .env file

Copy .env.example into .env by running

cp .env.example .env

The .env file stores environment-specific config data for your project, including developer secrets. Don’t publish it anywhere!

Creating a Discord bot

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

Under the Bot tab, make sure that the Privileged Gateway Intents are enabled.

Then, reset your token and copy the token for DISCORD_TOKEN. You won’t be able to see it again once you click out. Paste your token after the DISCORD_TOKEN= text in your .env file. Don’t share this with anyone!

Next under the OAuth2 tab, grab the client ID for DISCORD_CLIENT_ID. Paste it after the DISCORD_CLIENT_ID= text in your .env file.

Inviting your Discord bot

To add the bot to a server for testing, go to the OAuth2 tab’s URL Generator, select the bot and applications.commands scopes, select the Administrator permission, and open the generated URL. Use a personal test server rather than a public server.

Adding Google Maps API (optional)

Optionally, add GOOGLE_MAPS_API_KEY to .env. This is useful if you are working on the /dining command.

  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.

Running the bot

First authenticate if you haven’t done so before:

nix run git+https://git.cmu.dev/ScottyLabs/kennel#login

Then start the bot and its database:

devenv up

This starts a local PostgreSQL database and the Discord bot, and loads your .env values through secretspec. Migrations and slash command registration run automatically on startup. You should see your bot go online in Discord if everything worked! To stop your program, hit Ctrl+C.

Dining Api

Visit the api here. Visit the staging api here.

Welcome! We’re excited to have you join CMU Eats! (alternatively spelled cmueats). Documentation can be found on our Notion page. All communication will happen on the ScottyLabs Slack under the cmueats channel.

This Dining API scrapes location data from the CMU dining sites and distributes it as a RESTful API.

To build and deploy the service, you’ll need pnpm, which you should install beforehand.

Then, clone this repository to your computer by running

git clone https://github.com/ScottyLabs/dining-api.git

after making sure you have git downloaded or running

gh repo clone ScottyLabs/dining-api

if you have the Github CLI.

If you already have the node_modules folder or package-lock.json from previous versions of the Dining API, please remove them before continuing.

Now install the API’s dependencies by ‘cd’-ing into the root of the repository and running:

pnpm install

Then start your local database with (assuming you have the correct env variables)

pnpm db:start
pnpm db:push # if this is your first time running the db
pnpm dev

To see the contents of the database, I recommend using DBeaver. You can also run pnpm db:studio to start up drizzle studio

Database schema changes (important!)

When you make changes to the database schema, be sure to run pnpm db:push to keep your local db in sync.

Before merging your PR, be sure to run pnpm db:generate to generate a migration file, which will then be automatically applied to the staging and production databases when deployed. (You should do this before running tests as well!)

To test if the migration files work, you can run pnpm run-prod, which will spin up a production version of the server and a postgres database mounted on a new volume. The server is created using the same Dockerfile used in our Railway deployments, so if it works locally, it (probably) works in production as well.

Extra docker commands

Run bash inside it (for debugging): docker run --rm -it --entrypoint bash dining-api-server Close dockerfile + delete volumes: docker-compose down --volumes

Under the hood

We get the entire list of locations from DINING_URL, fetch location specifics under their corresponding CONCEPT_BASE_LINK, and retrieve soups and specials from DINING_SOUPS_URL and DINING_SPECIALS_URL, respectively. See the process() method in diningParser.ts for more details.

Before submitting a PR

  • Make sure all tests pass with pnpm test or pnpm test --watch for watch mode.

Random notes

the “cheerio” package is pinned at version “1.0.0-rc.12” because newer versions seem to be incompatible with jest.

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://git.cmu.dev/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://git.cmu.dev/ScottyLabs/my-project"
type = "starlight"
docs_dir = "docs"
description = "Documentation for My Project"

Starlight Project Example

[[project]]
slug = "guides"
name = "User Guides"
repo = "https://git.cmu.dev/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://git.cmu.dev/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://git.cmu.dev/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.

Eats

This is the monorepo for CMUEats™, an app that keep track of the statuses of various dining locations across the Pittsburgh campus of Carnegie Mellon University.

Visit the site at [https://cmueats.com], and the staging version at [https://staging.cmueats.com].

Visit the api at [https://api.cmueats.com/], and the staging version at [https://api.staging.cmueats.com/].

Monorepo

This monorepo consists of two projects: the frontend and a backend named Dining API, all written in TypeScript and managed by pnpm.

Install packages using

pnpm install

Other Documentations

Other important documentations about CMUEats are written in markdown and located at ./docs.

TODO: important items to be documented:

  1. testing
  2. monorepo
  3. backend (rating system?)
  4. db management
  5. deploying
  6. frontend code structure
  7. everything else on notion

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. The only one that is optional is Codeberg.
  2. Add your git.cmu.dev username to the members array in the desired team .toml file under data/. You can find the repo here
  3. Open a PR using a conventional PR title. You will likely need to first setup your SSH key as described in ssh-setup.md.

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

See Enabling Features in the kennel docs.

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
    • Groups a repository lists under oidc_client are created with their members left to be managed in Keycloak
  2. OpenBao
    • Keycloak groups are given the appropriate access to secret paths on OpenBao
  3. git.cmu.dev
    • Members are added to their Forgejo teams, which gives them appropriate access to the team’s repositories
    • Forgejo 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)
    • Members of teams with a Play Console app are given appropriate access to it
  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

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

Ssh Setup


title: SSH Setup

This will probably take only about 3-5 minutes.

1: Registering a git.cmu.dev account

Head to https://git.cmu.dev/ and register an account by signin in with your Andrew ID email through the CMU login.

Follow their registration process until you get signed in.

2: Generate an SSH key

Open up a terminal and enter this command:

ssh-keygen -t ed25519 -C "<email>"

Where <email> is the same email you used to register your account on git.cmu.dev and have in GitHub.

It will prompt you to set the location of a file. Leave it blank (press Enter) to use the default path. The rest of this guide assumes you left this blank.

Set a password for the file – do not leave it blank like the location.

If you forgot to set a password, add one with:

ssh-keygen -p -f ~/.ssh/id_ed25519

Now display your public key:

cat ~/.ssh/id_ed25519.pub
# Should look like: ssh-ed25519 <SOME HASH> <YOUR EMAIL>

Copy what it prints in the terminal.

3: Adding it to git.cmu.dev

  1. Go to your settings page
  2. Go to your SSH / GPG keys page
  3. Click Add key under SSH keys. Enter your name and then paste your public key.
  4. Hit Add key below.
  1. Go to Settings
  2. Go to SSH and GPG Keys
  3. Click Add SSH Key. Enter a name and paste your public key.

Make one copy as a Signing Key, and another copy of the same public key as an Authentication Key.

3.6: Adding to Command Line Git

Run these in terminal:

eval "$(ssh-agent -s)"
# Should show "Agent pid <some numbers>"
# If not, restart your terminal.
# This is just a check that your agent is running.

ssh-add ~/.ssh/id_ed25519

If you’re working with Forgejo, you’re done!

4: (Optional) Testing if it worked for GitHub

# If you did steps 3.5 and 3.6:
ssh -T git@github.com
# Should show: "Hi <username>! You've successfully authenticated, but GitHub does not provide shell access."
# Let a devops member know if this returns some variation of "permission denied".

The output should be something like Hi <USERNAME>! You've successfully authenticated, but <GitHub/Forgejo> does not provide shell access.

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!

Adding Yourself to Governance

  1. Make sure you’ve correctly setup your git.cmu.dev account (this should be the same email you’d use for GitHub). Don’t have an account? Just click “register” in the top right and follow those instructions to make an account.

  2. Make sure you link all available accounts in Keycloak. You’ll want to link at least CMU SAML, Discord, GitHub, and Slack.

  3. Open governance.

  4. Fork the repository. Press the fork button in the top right. Then you’ll be prompted with details of the fork. You’ll want to just keep all as given and press the “Fork repository” button.

  5. Take a look at the file system below. This is all of governances file as displayed on a web view. Follow this file path on git.cmu.dev: data -> teams -> cmu-housing.toml

  6. This is our team’s file and once your name is added to the list of members governance will give you access to the housing repository! So you’ll want to press the edit/pencil icon on the top right of the code block which will let you make edits to the file.

  7. Don’t edit any other line than to add your name to the list. Go to the last name select the end of the line and add a line with your git.cmu.dev’s username (case-sensitive) in quotations then a comma.

  8. After that scroll down to where it says commit signed changes. There is two text box inputs. The first is the commit name and the second is the commit description. Add to the commit name “feat: add YOUR-GIT.CMU.DEV-USERNAME to housing”. If you’re curious why it is like that take a look at Conventional Commits.

  9. Now go back to the root of the forked repository. You can do this by clicking it says “your-user/Governance”. Then go to Pull Requests and open a new Pull Request.

  10. Change the destination (left address) to Scottylabs:main then press New Pull Request.

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 or these housing specific instructions (they lead you to the same result). 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
devenv shell
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: Emma (@sakura888) and 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.

Filter scoring

scoreBuilding in scoring.ts scores a building against the filter panel. Set controls add points; missing data adds nothing and never subtracts:

Filter controlScored from
Air conditioningamenities.ac.level is Window or Central
Laundry on each flooramenities.laundry.location is EachFloor
En suite bathroomamenities.bathrooms.types has SharedSuite or Private
Single roomamenities.roomTypes has a single-occupant type
Service Animalaccessibility.serviceAnimalFriendly
Wheelchair accessibleaccessibility.wheelchairAccessible
Socialness, Noise Levelatmosphere.socialness, atmosphere.noiseLevel
Distance fromlocation.closeBuildings on either building

atmosphere, coordinates, and accessibility flags are empty so those score 0. groupBuildings sorts by score into bestFit, decentFit, and wildCard, three per row.

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 Nix 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, all running NixOS. infra-02 is a Mac mini running nix-darwin. 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, and modules meant for a nix-darwin host go under flake.modules.darwin. Home-manager configuration that both platforms share goes under flake.modules.homeManager and is imported by each platform’s shell module. 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> (or config.flake.modules.darwin.<name> for darwin modules) 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, or on darwin the host architecture and power behaviour. campus-cloud covers the CMU Campus Cloud VMware guests used by infra-01 and deploy-01, mele-cyber-x1 covers the signage hardware, computer-club covers snoopy, and mac-mini covers infra-02.

Systems and deployment

modules/systems.nix produces the nixosConfigurations and darwinConfigurations used for local builds, and the colmenaHive used to deploy every host. Each configuration comes from a per-host module list that combines the host role with the matching global role:

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

nixosConfigurations calls nixpkgs.lib.nixosSystem for each NixOS host and darwinConfigurations calls nix-darwin.lib.darwinSystem for each Mac. colmenaHive holds both kinds of node, each reached over SSH at <hostname>.scottylabs.org as the deploy user, with LAN-only hosts jumping through the FQDN in scottylabs.bastion. A darwin node also carries deployment.systemType = "darwin", so Colmena builds it with darwinSystem and activates it via darwin-rebuild. Both host kinds get their scottylabs.org A record from the same scottylabs.ipAddress, which modules/terranix/ reads across the whole flake.

Colmena’s nix-darwin support is unreleased, so flake.nix pins the colmena input to the nix-community/colmena#319 branch until it merges.

Every form receives 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 host IP address (scottylabs.ipAddress) 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.

Secrets

agenix and OpenBao each own a distinct set of secrets. agenix keeps encrypted files in the git tree and decrypts them onto a host during activation. OpenBao serves secrets over the network for a service to fetch at startup.

agenix holds bootstrap and externally-sourced secrets, such as third-party API tokens, mail and tunnel credentials, and the credentials a host uses to reach OpenBao. OpenBao holds secrets that Terraform provisions and services read at runtime, such as Keycloak OIDC client secrets and Garage S3 keys.

agenix

secrets.nix, the catalog, maps each encrypted file to the public keys that may decrypt it:

"secrets/infra-01/keycloak.age".publicKeys = admins ++ [ infra-01 ];
"secrets/cloudflare-api-token.age".publicKeys = admins ++ hosts;

admins is the set of SSH keys from users.nix, so any admin can re-encrypt any secret. The trailing key is the host that decrypts the file. A file under secrets/<host>/ lists that host’s SSH host key, and the shared files at the top of secrets/ add every host in the hosts set. A new secret needs an entry here before agenix will manage it.

A module declares age.secrets.<name> with the file to decrypt and its permissions. agenix decrypts it to /run/agenix/<name> during activation, and the module reads that path through config.age.secrets.<name>.path. modules/global/acme.nix uses the Cloudflare token this way:

age.secrets.cloudflare-api-token = {
  file = ../../secrets/cloudflare-api-token.age;
  mode = "0400";
};

security.acme.defaults.environmentFile = config.age.secrets.cloudflare-api-token.path;

AppRole bootstrap

Each host stores two agenix secrets, bao-role-id and bao-secret-id, under secrets/<host>/. modules/global/systemd-vaultd.nix runs a Vault agent that reads them from /run/agenix/ and authenticates to OpenBao with the AppRole method:

auto_auth.method = [{
  type = "approle";
  config = {
    role_id_file_path = "/run/agenix/bao-role-id";
    secret_id_file_path = "/run/agenix/bao-secret-id";
  };
}];

The agent connects to http://127.0.0.1:8200 on the host that runs OpenBao and to https://secrets.scottylabs.org from every other host. Its token carries the infra policy, which grants read access to secret/data/infra/*. These credentials cannot come from OpenBao, since they are what lets a host reach OpenBao, so they stay in agenix.

Runtime secrets from OpenBao

A service that needs an OpenBao secret declares it under systemd.services.<name>.vault.infraSecrets, keyed by credential name, with the OpenBao path and the field to read:

systemd.services.forgejo.vault.infraSecrets = {
  oidc = { path = "forgejo-oidc"; key = "CLIENT_SECRET"; };
  storage_access = { path = "forgejo-storage"; key = "MINIO_ACCESS_KEY_ID"; };
};

systemd-vaultd fetches secret/data/infra/<path> and writes each field to a systemd credential at /run/credentials/<name>.service/<credential>. The service reads it from that path:

environment."FORGEJO__storage__MINIO_ACCESS_KEY_ID__FILE" =
  "/run/credentials/forgejo.service/storage_access";

Provisioning OpenBao

OpenBao runs on infra-01, stores its data in PostgreSQL, and is reached at secrets.scottylabs.org. Terraform provisions both its structure and its contents. The openbao terranix configuration defines the KV v2 mount at secret/, OIDC login through Keycloak, and an AppRole backend with a per-host role bound to the infra policy. Each service’s terranix configuration writes its own runtime secrets to secret/infra/<name>, usually the client secret of the Keycloak OIDC client it creates alongside them. A few base values, such as the third-party identity-provider client secrets, are seeded into OpenBao by hand and read by Terraform rather than generated.

Insta

A project to help posting to CMU’s annual prefrosh insta for admitted stduents. Provide image manipulation tools/scripts for internal use, possibly adding a web application for direct submission to instagram.

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

This is the code for the CMU Lost and Found Page.

cd api
npm install
npm start (or npm run dev)

In a separate terminal window, run

cd client
npm install
npm start

Now, the website should be running at localhost:3000. The API currently runs on port 3080. The client (development only) runs on port 3000. For production, build the client and deploy it with the API on port 3080.

Afterwards, you will need to create a new .env file. The .env file stores secret credentials. Run

cp .env.config .env

and fill out the fields.

To check for linting errors, in the root directory run

npx eslint .

Credits to Bhargav Bachina for template: https://medium.com/bb-tutorials-and-thoughts/how-to-develop-and-build-react-app-with-nodejs-backend-typescript-version-27a6a283a7c5 https://github.com/bbachi/react-nodejs-typescript-example

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.

Communication

We use ScottyLabs Slack for communication, specifically the CMU Maps channel.

Slack in the CMU Maps channel in advance if you are interested in coming to a ScottyLabs work session! You will need to have at least a draft pull request open.

Data Flow

Overview

You can regenerate this diagram by pasting the linked code into Excalidraw.

Note: Production is the only environment with the serializer.

Files

Building.json

  • buildings.json: The main ESIM building dataset. It is produced by osm_building_to_json.py, which combines the existing building metadata with OpenStreetMap geometry, and then updated by add_fms_id.py to add FMS IDs.

    Intermediary Files

    • query.json: A raw snapshot of CMU building data from the public ArcGIS campus layer. It is generated by arc_gis_query.py and serves as the source for official building names, abbreviations, and IDs.
    • export.osm: A local OpenStreetMap export for the CMU campus area. It is generated by fetch_osm_data.py and used by osm_building_to_json.py to extract building geometry.
    • sign_abbrev_mapping.json: A small lookup file that maps building abbreviations to FMS building IDs. It is generated by sign_abbrev_mapping.py from query.json.
    • building_info_map.json: A simplified lookup keyed by building code, mainly for basic building info like name and default floor. It is generated by generate_building_info_map.py from buildings.json.

Osm-outside.json

  • osm-outside.json: A file containing all nodes outside of buildings, their neighbors, and positions by their OSM IDs. Note that this does not connect to any nodes inside of buildings.

Sources

CMU ArcGIS

  • Provides the official building metadata used for names, abbreviations, and building identifiers.

OpenStreetMap

  • Provides the building geometry that is used to rebuild the final ESIM buildings.json.
  • Provides the nodes and connections for roads, sidewalks, and any other passages outside of buildings in the osm-outside.json

Steps

Step 1: Scraping

Scraper code

S3 bucket link: https://minio.scottylabs.org/browser/cmumaps

Data sources:

Step 2: Generation

The generator takes in the scraped data and the serialized data as input and generates

  • Rescale the svgs to fit in a 1920x1080 rectangle and converts them to floorplans.json file.

  • Generates the inside graph from the floorplans.json file.

Step 3: Deserialization

The S3 bucket json files are deserialized locally.

Step 4: Visualization

The visualizer is used to place the data in geo-coordinates. It can also be used to add new data, such as connections between rooms and POIs.

Step 5: Serialization

The updated data are serialized to the S3 bucket.

Step 6: Deployment

The S3 bucket files are deserialized in staging and production.

Data Storage

Database

We use Postgres for the database. The database is hosted on Railway. Use docker compose for local Postgres instance.

Database Model

---
config:
  layout: elk
---
erDiagram
	direction LR
	Building {
		string buildingCode PK ""  
		string name  ""  
		int defaultOrdinal  ""  
		string osmId  ""  
		float labelLatitude  ""  
		float labelLongitude  ""  
		string shape  ""  
		string hitbox  ""  
	}
	Floor {
		string buildingCode PK ""  
		int floorLevel PK ""  
		bool isDefault  ""  
		float centerX  ""  
		float centerY  ""  
		float centerLatitude  ""  
		float centerLongitude  ""  
		float scale  ""  
		float angle  ""  
		float altitude  ""  
	}
	FloorConnection {
	}
	Complex {
		string Name PK ""  
	}
	Room {
		string roomId PK ""  
		string name  ""  
		string type  ""  
		float labelLatitude  ""  
		float labelLongitude  ""  
		string polygon  ""  
	}
	Alias {
		string alias PK ""  
		bool isDisplayAlias  ""  
	}
	Node {
		string nodeId PK ""  
		float latitude  ""  
		float longitude  ""  
	}
	Edge {
	}
	Poi {
		string poiId PK ""  
		string type  ""  
	}

	Building||--o{Floor:"has"
	Floor||--o{FloorConnection:"are connected by"
	Floor||--o{Room:"has"
	%% Floor}o--||Complex:"belongs_to"
	Floor||--o{Node:"has"
	Complex||--o{Room:"has"
	Room||--o{Alias:"has"
	Room||--o{Node:"has"
	Node||--o{Edge:"are connected by"
	Node||--||Poi:"is"

How to access the database

Use PgAdmin, which can connect to the DB through Railway’s internal network and limits login retries for safety.

Passwords are stored in Railway. Hosted in the following locations for each environment

  • Dev: https://pgadmin.maps.slabs-dev.org/browser/
  • Staging: to be set up
  • Prod: to be set up

S3 Bucket

Will created schemas in the dataflow folder.

Data Visualizer Wiki

Notion Documentation

Environments

Production

The production environment is version used by the public users.

Vercel: production

Railway: production

Clerk: production

Staging

The staging environment is version used by the development team for testing before merging to main.

Vercel: staging

Railway: staging

Clerk: development

Development

The development environment is used for verifying a PR before merging to staging.

Vercel: preview

  • CMU Maps: ^https://cmumaps-[0-9a-zA-Z]{9}-scottylabs.vercel.app$
  • Visualizer: none since staging is more than enough testing for this internal tool.

Railway: development (default connected to the staging branch, but can be changed to a specific branch as needed for api testing)

Clerk: development

Note:

Using Railway PR environment probably could achieve automation of the development environment for every PR, but would be unnecessarily costly since most PRs won’t affect the server. Might be worth it to revisit in the future.

Manual Integration Tests

Building Info Card Test

  1. Go to website

  2. Click on building (CUC)

  3. Check that it generates a info card and contains direction button

  4. Check that dining locations are listed (Revolution Noodle)

  5. Click on directions and input the starting location

  6. Hit enter and check that a path is generated

  7. Click on CUC again and click on food location (Revolution Noodle)

  8. Check that an info card for food location is generated

  9. Click on direction, input starting location and check that a path has been generated

  10. Check that it generates a info card and contains direction button

  11. Also make sure that menu button exist and can jump to a new page

Repeat for another building without food location

Room Info Card Test

  1. Go to website

  2. Click on building (CUC)

  3. Check that it generates a info card and contains direction button

  4. Check that dining locations are listed (Revolution Noodle)

  5. Click on directions and input the starting location

  6. Hit enter and check that a path is generated

  7. Click on CUC again and click on food location (Revolution Noodle)

  8. Check that an info card for food location is generated

  9. Click on direction, input starting location and check that a path has been generated

  10. Check that it generates a info card and contains direction button

  11. Also make sure that menu button exist and can jump to a new page

  12. Input room name in search bar (TEP 2611)

  13. Click on room and check that info card is generated with the schedule

  14. Check that button has generated and click on it

  15. Check that a path is generated

Repeat for rooms without schedule

Room Schedule Test

  1. Go to website

  2. Click on building (CUC)

  3. Check that button has generated and click on it

  4. Check that a path is generated

Repeat for rooms without schedule

Schedule Upload Test

  1. Click on schedule

  2. Upload a new schedule

  3. Check that courses are generated

  4. Click on course and check that it jumps to room

Can search for rooms

  1. Generates info card

  2. Generates schedule (rooms like TEP 2611)

  3. Click arbitrary rooms generate the directions button

  4. Click on the button is able to generate a path

Miscellaneous

Tech Stack

Web: React + Vite + TanStack Router + TanStack Query + Zustand + Tailwind CSS

Server: Express.js + TSOA

DB: PostgreSQL + Prisma

Linting: EditorConfig + markdownlint + syncpack + tsc + biome + ruff + mypy + ty

Monolith vs Microservice

CMU Maps server is a monolith application. You might wonder why we chose to not use a microservice architecture. Indeed, the Path Finding service needs to construct an in-memory graph, which is a very good use case for a microservice architecture. However, with our scope of CMU community, we would never need to scale to more than one server for the path finding service, so it is just simpler to have a monolith.

README vs WIKI

Put functionality overview of each directory in README. Otherwise use the Wiki for easy and quick updating.

(I am so tired of syncing between staging and main for README changes… 😮‍💨)

Perks

Food

Free* dinner after work session at 98K Halal Fried Chicken & Sandwiches!

*Sponsored by @Yuxiang-Huang (because he wants to cosplay team manager 🙃) and open to five CMU Maps contributors each week. If there are too many sign-ups in the CMU Maps Slack channel, we will prioritize based on contribution.

Enjoy a crispy chicken sandwich, the snack of the week, and some Chi Forest to share :>

Resume

DM your team lead and Yuxiang on Slack for help on Resume bullet points for CMU Maps. Maybe you want to put our MAU on there idk ¯\_(ツ)_/¯

Project Board

Triage

no:type,label -type:Design,Maintenance,Task means that every issue needs a type. Also for issues that are not Design, Maintenance, or Task, they need labels.

Priority

  • P0: Work on this issue immediately, even if it means stopping your current issue.

  • P1: Prioritize this issue when selecting your next issue.

  • P2: Default priority.

  • No priority assigned: Avoid picking this issue when selecting your next issue.

Display

We display fields in the order of priority, type, labels, so it is clear when an issue has a status.

For view filtered by type, we don’t display types.

We sort in the order of priority, types/labels.

Field sum by count.

The “slice by” should be empty. Use any slice by your need but don’t save the change.

Resources

Figma

https://www.figma.com/design/4hihRwPsE7JDWI21q72B03/CMU-Maps?node-id=3255-25885&p=f&t=QzKsFXqKYAwqGK2D-0

Google Drive

https://drive.google.com/drive/folders/1LGpwWyhdGxHOpMC7f-YwUw0lOslIpFC7

Setup

See https://github.com/ScottyLabs/ScottyStack/wiki/Setup-Guide.

Style Guide

Case Styles

Typescript

variableNames/functionName: camelCase

.tsx file names: PascalCase

.ts files names: camelCase

JSON

File names: kebab-case

Fields: camelCase

Python

Everything: snake_case

Constants: SCREAM_CASE

When importing from modules whose names start with an underscore, it is considered an anti‑pattern if the import is not relative within the same package, since such modules are meant to be private implementation details.

Everything Else

kebab-case: (github-branch-name, image-name, folder-name)

Function Declarations

Typescript

Use const over function for function declarations.

const myFunction = () => {
  return "Hello, world!";
};

Special Words

floorplan is one word!

Team

See team structure at https://github.com/ScottyLabs/governance/blob/main/teams/cmumaps.toml.

Troubleshooting

Dev Container

If you see the following error when opening the Dev Container, check this issue.

2024-01-29 17:48:50.785 [error] Error: unable to get issuer certificate
  at TLSSocket.onConnectSecure (node:_tls_wrap:1543:34)
  at TLSSocket.emit (node:events:513:28)
  at TLSSocket._finishInit (node:_tls_wrap:962:8)
  at ssl.onhandshakedone (node:_tls_wrap:746:12) remote-containers.createDevContainerFile {"value":"ms-vscode-remote.remote-containers","_lower":"ms-vscode-remote.remote-containers"}

Committing Tips

If you aren’t allowed to commit, try the following:

  1. Make sure you are using a conventional commit message.
  2. Run bun run check and try to fix the errors.
  3. If you believe the errors are not related to your code changes, try syncing with
    • git pull
    • bun run secrets:pull all all
    • bun run sync

If you really believe that it is a local issue, use the --no-verify flag to bypass the precommit hook. All the tests will be run again on PR as GitHub workflows.

Optionally, you can also comment out lines in https://github.com/ScottyLabs/cmumaps/blob/main/.husky/pre-commit to disable some precommit checks to speed up committing. For example, there is no need to run uv run lint if you didn’t make any change to Python files. Make sure to not commit these changes!

Git Push Troubleshooting

If you clone a Git repository using SSH and your SSH key has a passphrase, VS Code’s pull and sync features may hang when running remotely. Either use an SSH key without a passphrase, clone using HTTPS, or run git push from the command line to work around the issue.

Source: VS Code Remote Containers – Known Limitations

The easiest option is to run git push from command line outside the dev container.

Extension Issue in Editor

Try reloading the window or restarting the extension, applies to Biome, MyPy, etc.

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
  • CMU Bus Sign: Query live bus predictions and CMU PRT rider guidance

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

CMU Bus Sign Service (bus)

  • Get live predictions for the configured Forbes/Morewood stops
  • Find the next bus by stop, route, direction, or place
  • Search current predictions by stop, route, destination, or capacity
  • Explain how eligible CMU students use the PRT Ready2Ride benefit

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

# Run only the bus service
python src/mcp_server/services/bus/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

Bus Tools (prefix: bus)

  • get_bus_predictions(): Get live predictions for both configured bus stops
  • get_stop_predictions(stop_id): Get predictions for one configured stop
  • get_next_bus(stop_id, route, place, direction): Find the next matching bus
  • search_bus_predictions(query): Search current predictions by stop, route, destination, or capacity
  • get_cmu_prt_rider_guide(): Explain how CMU PRT Ready2Ride works

Configuration

API Endpoints

  • Dining API: https://dining.apis.scottylabs.org
  • Maps API: https://rust.api.maps.scottylabs.org
  • Bus Sign API: https://bus-sign.scottylabs.org

Endpoints are configured in:

  • src/mcp_server/services/eats/constants.py
  • src/mcp_server/services/maps/app.py
  • src/mcp_server/services/bus/constants.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

Testing

Run the test suite with:

uv run pytest

Add focused tests under tests/ as services are developed.

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 (or pnpm --filter @apps/backend 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

Slai Rfcs

Request for Comments (RFC) documents for ScottyLabs AI (SLAI). RFCs propose and record significant technical decisions across the team’s projects, and they stay here as a record of why things are the way they are.

Repository: https://git.cmu.dev/ScottyLabs/slai-rfcs

Repository structure

RFCs are grouped by project. Each project has its own folder and its own numbering, starting at 0001.

slai-rfcs/
├── README.md                      # this file: structure, process, RFC index
├── template.md                    # copy this to start an RFC
├── bark/                          # Bark, the CMU campus assistant
│   ├── 0001-system-architecture.md
│   ├── 0002-agent.md              # cmugpt-agent
│   ├── 0003-surface.md            # cmugpt-surface
│   └── 0004-mcp-server.md         # mcp-server
├── integration/                   # Integration (no RFCs yet)
├── internal/                      # Internal (no RFCs yet)
├── openbark/                      # OpenBark (no RFCs yet)
├── slc/                           # SLC (no RFCs yet)
└── slrp/                          # SLRP (no RFCs yet)

Projects

Bark

Bark spans three repositories. RFC 0001 describes how they fit together, and each service has its own RFC.

RepositoryRole
cmugpt-surfaceWeb app and backend-for-frontend API: sign-in, chat history, preferences
cmugpt-agentLLM agent: tool selection, answer checks, per-user memory
mcp-serverMCP server publishing CMU campus data as tools
NumberTitleAffectsStatus
0001System ArchitectureallDraft
0002Agentcmugpt-agentDraft
0003Surfacecmugpt-surfaceDraft
0004MCP Servermcp-server, cmugpt-agent, cmugpt-surfaceDraft

Integration

No RFCs yet.

Internal

No RFCs yet.

OpenBark

No RFCs yet.

SLC

No RFCs yet.

SLRP

No RFCs yet.

When to write an RFC

Write an RFC when you want to propose:

  • A change to a contract between services, such as an HTTP API, the MCP tool interface, or a shared identifier like a tool group id
  • A new service, tool group, or external data source
  • A change to how user data is stored, retained, or sent to third parties (models, embeddings, moderation)
  • A change to authentication, secrets, or deployment
  • A change to development processes or tooling that affects more than one repository

Bug fixes, documentation improvements, prompt tweaks, and changes contained in one service that don’t alter a contract don’t need an RFC. Open an issue or a pull request in the relevant repository instead.

Process

  1. Draft: copy template.md into the project’s folder as ####-short-title.md, using the next free number in that folder, add it to the project’s table under Projects, and open a pull request.
  2. Review: the team discusses in PR comments, and the author revises. An RFC that affects a repository should be reviewed by at least one person who works on it.
  3. Accepted or Rejected: an accepted RFC is merged with its status set to Accepted. A rejected RFC is closed, or merged with status Rejected if the reasoning is worth keeping.

When implementation details change, update the RFC and its Updated date. When a later RFC replaces an earlier one, set the earlier one’s status to Superseded by RFC ####.

Naming

Filenames are the number (zero-padded to 4 digits) and a kebab-case title, for example bark/0005-conversation-export.md. Branches use the rfc/ prefix followed by the project and the filename, for example rfc/bark/0005-conversation-export. Within a project, refer to other RFCs by number (“RFC 0002”). Across projects, include the folder (“bark RFC 0002”).

Questions

Ask in the SLAI Slack channel (listed in governance) or open an issue in this repository.

Study

Find and manage CMU study groups.

A Next.js app deployed by kennel.

One-time setup

Install devenv and its shell hooks (stop before devenv init), then sign in to OpenBao so secretspec can resolve this project’s secrets:

nix run git+https://git.cmu.dev/ScottyLabs/kennel#login

Running the app

Make sure you run devenv allow the first time you open the shell so the environment loads properly!

Starting services (postgres, ricochet):

devenv up

Once you see postgres ready and ricochet running, open a devenv shell:

devenv shell
npm ci
migrate               # applies prisma migrations

Run the app in the same devenv shell:

npm run dev

Keep devenv up running in its own terminal.

Database

devenv up runs PostgreSQL on a unix socket and exports DATABASE_URL. There is no password and no port to configure.

CommandDoes
migrateapplies pending migrations (prisma migrate deploy)
migrationcreates a migration from schema changes (prisma migrate dev)
studioopens Prisma Studio

Deployed services migrate themselves on startup, so a migration ships with the commit that needs it.

Environment

Secrets live in OpenBao, declared in secretspec.toml and resolved per environment by profile. Kennel supplies DATABASE_URL, PORT, and APP_URL at runtime, so none of those are declared as secrets.

Only src/env.js may read the environment at runtime. Next inlines any direct process.env.FOO at build time, so a value read that way in a component or route handler is whatever it was during the build, not during the request.

You do not need a .env anymore, unless you are working on a feature that requires adding new environment variables that do not exist in OpenBao yet. You should delete your .env (or clear it) so it doesn’t overwrite the OpenBao variables.

Deployment

Pushing main, staging, or dev deploys that branch. Any other branch is deployed as a preview once a pull request is open, at study-web-pr-<number>.scottylabs.net. Progress shows up as the kennel/build and kennel/deploy commit statuses, and production is served at cmustudy.com.

Project structure

  • src/app contains Next.js routes and route layouts.
  • src/features/groups contains study-group components, hooks, services, filters, and constants.
  • src/features/profile contains profile components, hooks, services, and profile-specific types.
  • src/components contains shared layout, provider, and UI components.
  • src/helpers contains external integration helpers such as calendar/date utilities.
  • src/server/api contains the Hono API application and route composition.
  • src/styles contains global and component-level CSS.
  • flake.nix builds the deployable package
  • devenv.nix declares the development environment and what kennel deploys.

Google Calendar

Google Calendar

Calendar uses server-side OAuth authorization-code flow through the same Ricochet callback relay as Keycloak. Google sends a code to OAUTH_RELAY_URL; Ricochet forwards it to the originating app’s /api/calendar/oauth/callback. The app exchanges the code using the relay URL as its redirect URI. Google credentials are encrypted in PostgreSQL and never returned to the browser.

Why we switched from the browser token flow

The original integration used Google Identity Services’ initTokenClient to obtain an access token in the browser and call Calendar from the frontend. It worked on the registered production origin, but each new PR preview (study-web-pr-<number>.scottylabs.net) needed its own authorized JavaScript origin in Google Console. Google does not allow wildcard origins, so production working did not mean an arbitrary new preview would work.

Authorization-code flow through Ricochet removes that per-preview configuration. Google redirects to one registered relay URL for deployed environments. Ricochet forwards the code and state to the app that started authorization; that app validates the session-bound, single-use state and exchanges the code with Google. Ricochet is a callback relay, not the component that stores or refreshes tokens. Switching to code flow alone would not solve the preview problem if every preview still used its own Google-registered redirect URI.

The browser token model also provides no refresh token. Once its short-lived access token expired, the browser had to request another token. The new backend requests offline access and stores encrypted refresh credentials, allowing later group actions to refresh access tokens without opening another authorization popup while the connection remains valid. Calendar credentials no longer live in browser storage or frontend API calls. See Google’s token model and server authorization-code flow.

Future changes must preserve these properties:

  • New PR previews must work without adding their origins or callback URLs to Google Console. Keep the shared relay and its host allowlist.
  • Exchange codes, store encrypted tokens, refresh credentials, and call Calendar on the server. Do not restore initTokenClient or browser token storage.
  • Keep PKCE and session-bound, expiring, single-use state validation. A return URL in state is not sufficient authorization or CSRF protection by itself.
  • Keep connections isolated by user and application origin. Reuse a valid connection for later actions; request consent again only when connecting or reconnecting, not for every group.

Configuration and rollout

Before implementation or rollout, confirm that the deployed client belongs to the intended Google Cloud project. The reported production setup is External, In Production, with both current Calendar scopes approved; verify these exact scopes on Google Auth Platform’s Data Access and verification pages:

  • https://www.googleapis.com/auth/calendar.events.owned
  • https://www.googleapis.com/auth/calendar.freebusy

Publishing alone does not approve sensitive scopes. Unapproved scopes can show an unverified-app warning and impose a lifetime user cap. External projects in Testing allow up to 100 listed test users and expire Calendar authorizations, including refresh tokens, after seven days. Use that mode only for limited pilots. Internal projects restrict Google accounts to the owning organization and do not support the current user-selected-account audience. See Google’s audience rules.

Setup:

  1. Enable the Google Calendar API. Use a Web application OAuth client and register the exact OAUTH_RELAY_URL as an authorized redirect URI. Do not register individual PR preview origins for this server-initiated flow.
  2. Ensure Ricochet allows the production and preview hosts. Local development uses its loopback configuration and a Google-registered local relay URL.
  3. Keep the existing NEXT_PUBLIC_CALENDAR_CLIENT_ID; the server reuses it. CALENDAR_CLIENT_ID is an optional override if a separate server client is needed. Set CALENDAR_CLIENT_SECRET for that same OAuth client and CALENDAR_TOKEN_ENCRYPTION_KEY through secretspec/OpenBao for each environment. The encryption key is 32 random bytes encoded as 64 hexadecimal characters; generate it with openssl rand -hex 32. Keep it stable across redeployments. Changing it requires users to reconnect. All three settings must be provided for Calendar to be enabled. The existing public client ID alone leaves Calendar disabled without disabling study groups. NEXT_PUBLIC_CALENDAR_API_KEY is not needed by the server flow and cannot replace the OAuth client secret.
  4. Apply the additive Prisma migration before starting the new application. Existing Calendar event IDs are retained.

Existing users connect again to establish server credentials. Later requests refresh access tokens automatically while Google’s refresh credentials remain valid. Expiration or revocation requires reconnection; this is not a permanent authorization guarantee. Each preview has its own connection, isolated by StudyStarter user and application origin. Repeated connections across many PRs can also reach Google’s refresh-token issuance limits.

Authorization opens a popup without navigating the current form. The page polls its session-bound attempt, so Google’s opener isolation does not prevent completion. Denial, failure, and a five-minute timeout settle the request. If the browser severs the popup reference, manually closing it may be detected only by the timeout. Calendar failures retain the existing group-action behavior.

Run npm run test:calendar, npx tsc --noEmit, and npm run lint. Before broad rollout, manually connect and create, edit, and delete events on two PR previews using the same registered relay URL, then check production and local settings. Do not log callback query strings, authorization codes, or decrypted tokens in application or reverse-proxy access logs.

The PostgreSQL integration test also runs when CALENDAR_TEST_DATABASE_URL points to a dedicated, migrated test database. It creates and removes its own test user; do not point this setting at production.

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

  • You are added to the Tartan-Vote team on governance
  • devenv provides Cargo, Deno, Node, PostgreSQL, and other tooling via Nix

Quick Setup

For detailed setup instructions, see SETUP.md.

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

nix run git+https://git.cmu.dev/ScottyLabs/kennel#login

Allow devenv (or enter the shell manually):

devenv 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

# in a separate terminal if you didn't add -d
cd frontend && deno task build

cargo run

Then open http://localhost:8080.

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 = {
    proxy: boolean;
    proxy_for_user_id: number | null;
    vote_response: string[];
};

Field Descriptions

  • 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 single-choice motion, this array typically contains a single value.
    • For a ranked-choice ballot, 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

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

motion.data

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

Type Definition

type MotionData = {
    description: string;
    session_code: string;
    visibility: {
        participants: string;
    };
    proxy: bool;
    vote_options: string[];
};

Field Descriptions

  • description: A textual description of the motion.
  • session_code: A code used for joining or identifying the session.
  • visibility.participants: Defines what participants can see during the motion. Example values include "hidden_until_release" and "live".
  • proxy: Indicates whether proxy voting is enabled for the motion.
  • vote_options: Lists the selectable voting options for the motion.

Example Usage

{
    "description": "Motion description goes here",
    "session_code": "code",
    "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).

Motions

Schema Definition

#![allow(unused)]
fn main() {
struct Motion {
  id: i32,
  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 motion. Autogenerated.
  • name: The name for the motion.
  • status: The status of the motion. Is an enum type. Can be “active” or “inactive”.
  • start_time: The time when voting starts.
  • end_time: The time when voting ends, if ever.
  • data: The other data related to the motion. See motion.data.
  • created_by_user_id: The id of the user who created the motion. References Users.
  • session_id: The id for the session this motion 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,
  motion_id: i32,
  user_session_id: i32,
  cast_time: DateTimeWithTimeZone,
  data: Json,
}
}

Field Descriptions

  • id: Primary Key. The id for the vote. Autogenerated.
  • motion_id: Foreign Key. The motion this vote belongs to. References Motions.
  • 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 (motion_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 proxy✓✓2Votes 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 Setup → NEW 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:

  • join → proxySetup → waiting (was directly join → waiting)
  • 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.svelte — NEW 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 unchanged — POST /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.

Pull Requests

Direct pushes to main are blocked. You should create a branch, 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 and pre-commit checks 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 and pre-commit checks 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.

Slightly More 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 devenv 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, so there’s no need to create a .env. Secrets are pulled from OpenBao via secretspec, so authenticate once:

nix run git+https://git.cmu.dev/ScottyLabs/kennel#login

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 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-build, since it rebuilds the frontend on file change.

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.