ScottyLabs Docs
Unified documentation for all ScottyLabs projects. Repositories are included by default; opt out with docs = false in governance.
How it works
- Governance registers repositories (docs hub inclusion is on by default)
- At build time, CI resolves each repo (monorepo sibling or shallow clone) and copies its
docs/directory into this site - 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: truein 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: trueflag 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
-
Central Org Docs (
scottylabs-docsrepo)- Onboarding guides
- Organization processes and policies
- Meeting notes and decision records
- Event planning guides
- Infrastructure documentation
-
Project Docs (from repos with
docs: truein 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:
- Add access token secret:
DOCS_TRIGGER_TOKEN - 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
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:
-
In the governance repository (
data/directory), adddocs = trueto 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) -
Ensure your repository has a
docs/directory with markdown files -
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:
- Parse manifest - Read
projects.tomlto get project list - Clone repos - Parallel
git cloneinto.repos/{slug}/ - Process by type:
- Starlight: Copy markdown to
src/content/docs/{slug}/ - Rust: Run
cargo doc, copy topublic/{slug}/api/ - OpenAPI: Export spec, generate Scalar page
- Starlight: Copy markdown to
- Generate nav - Build dynamic Starlight sidebar
- 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:
- Direct commits to the documentation repository
- Governance changes via repository dispatch (when governance
data/changes) - 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:
- Checks out the repository
- Installs dependencies with Bun
- Runs the build script
- Uploads artifacts
- Deploys to Garage S3 (on main branch)
Required Secrets
Configure these in your Forgejo repository settings:
GARAGE_ENDPOINT- S3 endpoint URLGARAGE_ACCESS_KEY- S3 access keyGARAGE_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 slugprojectType: The project type (starlight/rust/openapi)
OpenAPI Export
For OpenAPI projects, ensure your export command:
- Runs without starting a server
- Writes to the path specified in
openapi_spec - 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_specis setexport_commandis provided (if spec isn’t pre-generated)
Rustdoc not appearing
Ensure:
- Project
typeis set to"rust" - Repository contains a valid Cargo workspace/package
cargo docruns successfully in the project
Check build logs for cargo errors.
Navigation not updating
The navigation is regenerated on each build. If changes aren’t appearing:
- Clean build artifacts:
bun run scripts/build.ts clean - Rebuild:
bun run build - Check that markdown files have correct file extensions (
.mdor.mdx)
Contributing
Adding Your Project
- Fork this repository
- Add your project to
projects.toml - Ensure your project has documentation in the specified
docs_dir - Test locally:
bun run build && bun run dev - 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.
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.
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."

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:
featfor new featuresfixfor bugfixesdocsfor changes to documentationchorefor maintenance and routine tasksrefactorfor refactors if it does not change behavior (e.g. a library version update)revertif 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
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.
-
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. -
Copy the public key:
cat ~/.ssh/id_ed25519.pubCopy the whole line (
ssh-ed25519 …). -
Add it on Codeberg: open SSH / GPG keys, click Add key, paste, save.
-
Test it:
ssh -T git@git.cmu.devYou 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?
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:
-
ScottyLabs: for projects that want to use standardized infrastructure.
-
ScottyLabs Labrador: for projects that want to explore its own tooling.
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
- Be a member of Community-Based Projects
- devenv - provides Cargo, Deno, and other tooling via Nix
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 it on your own machine.
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
- Sign up on git.cmu.dev and add an SSH key.
- Ask the tech lead to add you to the CMUCal team in governance.
- 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 loginagain. If it still fails, governance has not added you to the CMUCal team yet. - Sign-in says Keycloak login is not configured:
npm run devwas started withoutsecretspec run -P dev --, or (manual setup) a value inweb/.env.localis empty. Next.js reads env files only at startup, so restartnpm run devafter 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_URLis missing: the API was started withoutsecretspec run -P dev --, or (manual setup)api/.env.developmentdoes not exist or you did not start the API fromapi/. - 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.
- 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.
- Planning.
planning.pydetermines what the turn requires. It decides which tool groups to bind, whether therememberandforgettools 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. - Execution.
graph.pyruns 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. - Verification.
guards.pyand themaps/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. - 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):
- Git.
- uv. It installs Python 3.12 if no suitable interpreter is present.
- PostgreSQL with the pgvector extension.
- An OpenRouter API key and an OpenAI API key.
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.
-
Clone the repository and install its dependencies.
git clone https://git.cmu.dev/ScottyLabs/cmugpt-agent.git cd cmugpt-agent uv sync -
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 indevenv.nixprovides a database with pgvector already set up (see Installation with devenv). -
Create the environment file and add the API keys.
cp .env.example .envThen set the two keys in
.env:OPENROUTER_API_KEY=<your OpenRouter key> OPENAI_API_KEY=<your OpenAI key>MCP_SERVER_URLandDATABASE_URLare prefilled. They point at the production MCP server and at thecmugpt_agentdatabase 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:
-
Nix, with the Determinate Systems installer on macOS, Linux, or WSL:
curl -fsSL https://install.determinate.systems/nix | sh -s -- install -
devenv, from a new terminal:
nix profile install nixpkgs#devenv -
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.
| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY | Chat, memory extraction, and chat titles |
OPENAI_API_KEY | Embeddings for memory search and the moderation endpoint. Unset, recall orders facts by recency and moderation is skipped |
DATABASE_URL | PostgreSQL connection string. Unset, memory lives in an in-memory store that is cleared on restart |
MCP_SERVER_URL | Base URL of the CMU MCP server, including the /mcp path |
AGENT_SHARED_SECRET | Bearer token the Surface presents on every request. Unset, requests are unauthenticated. Set in production |
AGENT_ENV | production 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_ORIGINS | Comma-separated browser origins for CORS. Default https://cmugpt.com |
PORT | Listening port. Default 5055 |
TITLE_MODEL | Model for chat titles. Default qwen/qwen3.7-flash |
MEMORY_EXTRACTION_MODEL | Model for background fact extraction. Default qwen/qwen3.7-flash |
TOKEN_USAGE_DB | SQLite 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>.
| Route | Description |
|---|---|
POST /agent/respond | Returns the complete answer as a JSON object |
POST /agent/respond/stream | Returns the answer as Server-Sent Events |
POST /agent/title | Generates 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/health | Service 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:
| Field | Description |
|---|---|
query | The user’s message. Required. At most 8,000 characters |
user_id | Identifier for memory and the token budget. The Surface sends a hash of the authenticated user |
message_history | Prior turns as {"role", "content"} objects. The last 40 are used |
model | OpenRouter model identifier. Default openai/gpt-5.6-luna |
disabled_tools | Tool 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.
- https://api.cmugpt-agent.scottylabs.org (custom domain)
- https://cmugpt-agent-agent-main.scottylabs.net (default Kennel URL)
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.
Related repositories
- 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
- Install dependencies with
uv:
uv sync
- Copy the example environment file and fill in your values:
cp .env.example .env
- 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
- Should SMS call the agent API or import the agent package directly?
- What database for production? (SQLite for MVP, then Postgres?)
- What “basic student info” should be saved?
- How does the CMU Keycloak auth flow work?
- Where does orientation/week event data come from?
- 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
| Layer | Tech |
|---|---|
| Runtime / package manager | Deno (workspace of two deno.json projects, npm deps via npm: specifiers) |
| API server | Express 5, tsoa (decorators to OpenAPI + routes), Zod |
| Database | PostgreSQL + pgvector (semantic memory search), Drizzle ORM + drizzle-kit |
| Auth | OIDC (Andrew ID SSO) via openid-client/jwks-rsa, plus lightweight server-side sessions (better-auth-shaped tables) |
| Frontend | React 19, Vite, TanStack Router + TanStack Query, Tailwind CSS v4 |
| API contract | Auto-generated OpenAPI spec, consumed via openapi-fetch + openapi-react-query |
| Testing | Vitest (unit, web), Playwright (e2e, apps/web/e2e) |
| Dev environment | devenv + Nix, via ScottyLabs’ internal kennel devenv modules |
| Secrets | secretspec - local .env or ScottyLabs’ OpenBao vault |
| CI/CD | Forgejo Actions, reusing ScottyLabs/kennel’s shared CI workflow |
Before you start
Complete the ScottyLabs setup on docs.scottylabs.org first:
- A git.cmu.dev account with SSH set up. See Forgejo Setup.
- Membership in the
slaiteam. Contributing explains how to join a team through governance: add your git.cmu.dev username tomembersindata/teams/slai.tomland 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. - Nix and devenv (below). The shell provides Deno, Postgres with pgvector,
the local login relay,
bao, andsecretspec, so you don’t need to install those yourself. You only need Git.
Installing Nix and devenv
-
Nix, with the Determinate Systems installer on macOS, Linux, or WSL:
curl -fsSL https://install.determinate.systems/nix | sh -s -- install -
devenv, from a new terminal:
nix profile install nixpkgs#devenv -
Optionally, direnv with its shell hook, so the environment loads when you
cdinto 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
With devenv (recommended)
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 exportsDATABASE_URL)ricochet: the local OAuth relay on127.0.0.1:8090. Andrew ID login won’t work locally without it.api: the server on:3001web: 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 ...#logincommand above. If login works but secrets still fail, you’re probably not in theslaiteam 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_URLis unset because Postgres wasn’t started throughdevenv up. - See full startup logs:
devenv --no-tui shell
Without devenv (not recommended)
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.
-
Install:
- Git
- Deno
- PostgreSQL and the
pgvector extension
(
brew install postgresql@17 pgvectoron macOS) - OpenBao (
brew install openbaoon macOS) - secretspec
(
curl -sSL https://install.secretspec.dev | sh)
-
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 -
Log in to OpenBao (needs
slaiteam 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:
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | yes | Postgres connection string |
OIDC_ISSUER_URL | yes | OIDC issuer for Andrew ID SSO |
OIDC_CLIENT_ID | yes (default sl-ai-local) | OIDC client ID |
OIDC_CLIENT_SECRET | yes | OIDC client secret |
ALLOWED_ORIGINS_REGEX | yes | Regex of allowed CORS origins |
AGENT_API_URL | yes (default https://api.cmugpt-agent.scottylabs.org) | Base URL of the cmugpt-agent service |
AGENT_SHARED_SECRET | prod only, but strongly recommended locally | App-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_URL | no | Shared “ricochet” OAuth relay callback, used so preview deployments can authenticate without registering their own redirect URI |
PORT / SERVER_PORT | no (default 80; devenv sets PORT=3001) | API server port. PORT takes precedence. |
APP_URL | no (default http://localhost:4173) | Public URL of the web app, used for OIDC callback construction |
ADMIN_GROUP | no | Admin 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):
| Command | Description |
|---|---|
deno task dev | Run server + web dev servers concurrently (outside devenv up, prefix with PORT=3001) |
deno task generate | Regenerate the OpenAPI spec/routes from server controllers (also runs automatically before build/dev) |
deno task build | Build both apps for production |
deno task check | Type-check both apps (deno check) |
deno task test:e2e | Run Playwright e2e tests against the web app |
deno task db:generate | Generate a new Drizzle migration from schema changes |
deno task db:migrate | Apply 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,checkapps/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:e2efrom the repo root, powered by playwright.config.ts. It boots the web dev server automatically and runs specs inapps/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.
Related repos
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/tokensgenerates the design tokens from Figma via Terrazzo, exposing them as CSS variables and TypeScript constants with both Light and Dark mode values@scottylabs/stylescontains the pre-compiled component CSS that references those tokens, using thesl-class prefix throughout@scottylabs/variantsholds the sharedtailwind-variantsconfigurations consumed by both framework packages@scottylabs/typesholds the shared TypeScript prop types so the React and Svelte public APIs stay in lockstep@scottylabs/reactis the React component layer, built on Radix UI primitives@scottylabs/svelteis the Svelte component layer, built on Bits UI primitives
Four apps cover the publication surface:
apps/docsis the narrative documentation site, built with Astro Starlightapps/storybookis a Storybook composition host that pulls in the framework-specific Storybooks viarefsapps/storybook-reactandapps/storybook-svelteare 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
| Command | Run from | Purpose |
|---|---|---|
deno task check | sites/web | Check Svelte and TypeScript |
build-wasm | Repository root | Rebuild browser bindings after Rust index changes |
direnv reload | Repository root | Reload the environment after configuration changes |
devenv --no-tui shell | Repository root | Show 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
- Be a member of Community-Based Projects
- devenv - provides Deno and other tooling via Nix
Setup
For detailed setup instructions including creating a Discord bot, obtaining API credentials, and configuring your development environment, see docs/SETUP.md.
Quick setup:
- Install devenv (see link above)
- Create a Discord bot at https://discord.com/developers/applications and invite it to a server you can test in
- Get your
DISCORD_TOKENandDISCORD_CLIENT_IDand 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
- Fork the repository or create a new branch if you have write access
- Create a new branch from
mainwith a descriptive name:git checkout -b your-feature-name # or git checkout -b bug-description - Make your changes following the code style and conventions
- Test your changes locally by running the bot
- Commit using conventional commits (see below)
- Push to your fork or branch
- Open a Pull Request with a clear description of your changes
Conventional Commits
This project follows Conventional Commits.
Examples:
feat: add course search by instructorfix: resolve dining hall location formatting issuedocs: update README installation stepsrefactor: simplify embed pagination logicchore: update dependencies to latest versionsstyle: 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 lintand fix any errors/warnings - Run
deno run formatto format your code - Run
deno run testto 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.
- Head to Google Cloud Console and create a new project.
- In
APIs & Services, enable theMaps JavaScript APIandMaps Static APIproducts. - Get a key from
Keys & Credentialsto input intoGOOGLE_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 testorpnpm test --watchfor 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: truein 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: trueflag 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
-
Central Org Docs (
scottylabs-docsrepo)- Onboarding guides
- Organization processes and policies
- Meeting notes and decision records
- Event planning guides
- Infrastructure documentation
-
Project Docs (from repos with
docs: truein 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:
- Add access token secret:
DOCS_TRIGGER_TOKEN - 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
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:
-
In the governance repository (
data/directory), adddocs = trueto 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) -
Ensure your repository has a
docs/directory with markdown files -
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:
- Parse manifest - Read
projects.tomlto get project list - Clone repos - Parallel
git cloneinto.repos/{slug}/ - Process by type:
- Starlight: Copy markdown to
src/content/docs/{slug}/ - Rust: Run
cargo doc, copy topublic/{slug}/api/ - OpenAPI: Export spec, generate Scalar page
- Starlight: Copy markdown to
- Generate nav - Build dynamic Starlight sidebar
- 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:
- Direct commits to the documentation repository
- Governance changes via repository dispatch (when governance
data/changes) - 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:
- Checks out the repository
- Installs dependencies with Bun
- Runs the build script
- Uploads artifacts
- Deploys to Garage S3 (on main branch)
Required Secrets
Configure these in your Forgejo repository settings:
GARAGE_ENDPOINT- S3 endpoint URLGARAGE_ACCESS_KEY- S3 access keyGARAGE_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 slugprojectType: The project type (starlight/rust/openapi)
OpenAPI Export
For OpenAPI projects, ensure your export command:
- Runs without starting a server
- Writes to the path specified in
openapi_spec - 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_specis setexport_commandis provided (if spec isn’t pre-generated)
Rustdoc not appearing
Ensure:
- Project
typeis set to"rust" - Repository contains a valid Cargo workspace/package
cargo docruns successfully in the project
Check build logs for cargo errors.
Navigation not updating
The navigation is regenerated on each build. If changes aren’t appearing:
- Clean build artifacts:
bun run scripts/build.ts clean - Rebuild:
bun run build - Check that markdown files have correct file extensions (
.mdor.mdx)
Contributing
Adding Your Project
- Fork this repository
- Add your project to
projects.toml - Ensure your project has documentation in the specified
docs_dir - Test locally:
bun run build && bun run dev - 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:
- testing
- monorepo
- backend (rating system?)
- db management
- deploying
- frontend code structure
- 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
- Link all available accounts in Keycloak. The only one that is optional is Codeberg.
- Add your git.cmu.dev username to the
membersarray in the desired team.tomlfile underdata/. You can find the repo here - 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:
- 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_clientare created with their members left to be managed in Keycloak
- OpenBao
- Keycloak groups are given the appropriate access to secret paths on OpenBao
- 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
- 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
- Sentry
- Projects are provisioned under Sentry
- 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
- LiteLLM
- Repositories with the AI gateway enabled receive budgeted API keys under their team, written to OpenBao per profile
- Kennel
- Repositories automatically receive a deploy webhook that authorizes them to be deployed by kennel
- Website
- Groups with a
public_urlare published to the scottylabs.org project catalog
- Groups with a
- 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
- Go to your settings page
- Go to your SSH / GPG keys page
- Click Add key under SSH keys. Enter your name and then paste your public key.
- Hit Add key below.
3.5: (Optional but Recommended) Adding it to GitHub
- Go to Settings
- Go to SSH and GPG Keys
- 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
-
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.
-
Make sure you link all available accounts in Keycloak. You’ll want to link at least CMU SAML, Discord, GitHub, and Slack.
-
Open governance.
-
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.
-
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
-
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.
-
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.
-
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.
-
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.
-
Change the destination (left address) to
Scottylabs:mainthen 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
-
Create a new branch from latest
mainwith 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 -
Make your changes following the code style and conventions
-
Test your changes locally by running the project. See README.md for more instructions on running the project.
-
Commit using conventional commits (see below)
git add . # then git commit -m "Your commit message" -
Push to your fork or branch
# if first branch push git push --set-upstream origin your-branch-name # otherwise git push -
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 instructorfix: resolve dining hall location formatting issuedocs: update README installation stepsrefactor: simplify embed pagination logicchore: update dependencies to latest versionsstyle: 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:
- VS Code has builtin TypeScript language support
- Dependi
- Oxc
- Typescript (Native Preview)
- Rust Analyzer
- GitLens
- Auto Close Tag
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 upand thendeno task devinapps/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
| # | Member | Meaning |
|---|---|---|
| 0 | TradSingle | Traditional-style single, shared hallway bathroom. |
| 1 | TradDouble | Traditional-style double, shared hallway bathroom. |
| 2 | TradTriple | Traditional-style triple, shared hallway bathroom. |
| 3 | SemiSuiteSingle | Semi-suite single, bathroom shared with an adjacent suite. |
| 4 | SemiSuiteDouble | Semi-suite double, bathroom shared with an adjacent suite. |
| 5 | SemiSuiteTriple | Semi-suite triple, bathroom shared with an adjacent suite. |
| 6 | SemiSuiteQuad | Semi-suite, four occupants. |
| 7 | ApartmentTriple | Apartment-style triple. |
| 8 | StudioApartmentSingle | Studio apartment, single occupant. |
| 9 | StudioApartmentDouble | Studio apartment, two occupants. |
BathroomType
| # | Member | Meaning |
|---|---|---|
| 0 | Communal | Shared per floor/wing, traditional style. |
| 1 | SharedSuite | Shared with one adjacent suite, semi-suite style. |
| 2 | Private | Truly en-suite / in-room, apartment style only. |
ACLevel
| # | Member | Meaning |
|---|---|---|
| 0 | None | No AC. |
| 1 | ByNecessity | Accommodation, triple, or lottery-only AC. |
| 2 | Window | Window units, not central. |
| 3 | Central | Full central AC. |
LaundryLocation
| # | Member | Meaning |
|---|---|---|
| 0 | None | No laundry. |
| 1 | Basement | Basement only. |
| 2 | EachFloor | Laundry on every floor. |
| 3 | InUnit | In-unit washer/dryer. |
KitchenScope
| # | Member | Meaning |
|---|---|---|
| 0 | None | No kitchen access. |
| 1 | Shared | Communal, building or floor level; details says which. |
| 2 | InUnit | Kitchenette in the room (“en suite kitchen”). |
Floor-vs-building distinctions for Shared live in the details string, not as a separate enum
value.
GenderHousing
| # | Member | Meaning |
|---|---|---|
| 0 | CoEd | Co-ed housing. |
| 1 | WomenOnly | Women only. |
| 2 | MenOnly | Men only. |
| 3 | GenderInclusive | Gender-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.typesis an array so a building with more than one bathroom style lists all of them.CommonAreas.hasLoungebacks the “Common areas” filter.Gym.availableis a plain boolean for filtering;detailscarries the description.
Grouped types
AmenityDataholds everything the filter/survey/comparison UI reads: room types, bathrooms, AC, kitchen, laundry, common areas, gym, gender housing.AccessibilityholdswheelchairAccessible,serviceAnimalFriendly,groundFloorRooms,strobeAlarm(strobe fire alarm & doorbell). Filled in per building as data becomes available.Atmosphereholdssocialness/noiseLevel, 1-5 scales matching the survey sliders and review table. Both are optional since a building may not have data yet.Locationholdslatitude/longitudefor 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 optionalnotefor a human-written blurb.GalleryImageislink+description, one per photo.FloorPlanislink+description+category("roomType" | "floor") + optionalvirtualTourLink, which points to a walkthrough for that specific floor plan.MediaholdsmainImage, optionalicon,photos[], andfloorPlans[].Buildingis the top-level shape:id,name,media,amenities,accessibility,atmosphere,location, and optionaleditorialTagsfor 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 control | Scored from |
|---|---|
| Air conditioning | amenities.ac.level is Window or Central |
| Laundry on each floor | amenities.laundry.location is EachFloor |
| En suite bathroom | amenities.bathrooms.types has SharedSuite or Private |
| Single room | amenities.roomTypes has a single-occupant type |
| Service Animal | accessibility.serviceAnimalFriendly |
| Wheelchair accessible | accessibility.wheelchairAccessible |
| Socialness, Noise Level | atmosphere.socialness, atmosphere.noiseLevel |
| Distance from | location.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 id | Derived from |
|---|---|
noKitchen | amenities.kitchen.scope === KitchenScope.None |
limitedAC | amenities.ac.level === ACLevel.ByNecessity |
noCentralAC | amenities.ac.level is neither None nor Central |
basementLaundry | amenities.laundry.location === LaundryLocation.Basement |
gymAccess | amenities.gym.available |
girlsOnly | amenities.genderHousing === GenderHousing.WomenOnly |
lgbtqInclusive | amenities.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, andinvitationare not yet defined inapps/backend/src/db/schema.tsor 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 toschema.ts, this doc will be updated with their column/type details too.
Enums
roommate_status
Backing type for roommate_profile.status.
| Value | Meaning |
|---|---|
searching | User is actively looking for a roommate. |
committed | User has locked in a roommate/room situation. |
inactive | User is not currently participating in roommate matching. |
Tables
user
Core account record. Every other table hangs off of user.id.
| Column | DB type | Nullable | Notes |
|---|---|---|---|
id | serial | No (PK) | Auto-incrementing primary key. |
andrew_id | text | Yes | CMU AndrewID for the account. |
created_time | timestamp | Yes | When the account was created. |
name | text | Yes | Display name. |
oidc_subject | text | Yes | Subject 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.
| Column | DB type | Nullable | Notes |
|---|---|---|---|
id | serial | No (PK) | Auto-incrementing primary key. |
user_id | integer | No (FK -> user.id) | Owning user. |
accommodations | text[] | Yes | List of accessibility/accommodation needs. |
cooking_frequency | integer | Yes | Self-reported frequency scale (e.g. times per week). |
goals | text[] | Yes | Free-text goals for housing/roommate search. |
gym_frequency | integer | Yes | Self-reported frequency scale. |
major | text | Yes | Academic major. |
needs_alone_time | integer | Yes | Self-reported scale of how much alone time is needed. |
preferred_amenities | text[] | Yes | Desired building/room amenities. |
preferred_gender_housing | text | Yes | Preferred gender composition for housing. |
productive_around_others | integer | Yes | Self-reported scale of productivity with others present. |
social_frequency | integer | Yes | Self-reported social activity scale. |
updated_at | timestamp | Yes | Last time preferences were edited. |
year | text | Yes | Class year (e.g. Freshman, Sophomore). |
roommate_profile
One-to-one extension of user holding the public-facing roommate-matching profile.
| Column | DB type | Nullable | Notes |
|---|---|---|---|
id | serial | No (PK) | Auto-incrementing primary key. |
user_id | integer | No (FK -> user.id) | Owning user. |
alcohol | boolean | Yes | Whether the user drinks alcohol. |
assigned_sex | text | Yes | Assigned sex, used for housing-eligibility matching. |
bathroom_preference | text | Yes | Preferred bathroom arrangement. |
committed | boolean | Yes | Whether the user has already committed to a roommate. |
drugs | boolean | Yes | Whether the user uses drugs. |
extras | json | Yes | Free-form additional profile data not modeled as columns. |
intended_major | text | Yes | Intended/declared major shown on the profile. |
is_visible | boolean | Yes | Whether the profile is visible in roommate search. |
morning_prep_time | text | Yes | How long the user takes to get ready in the morning. |
neatness | integer | Yes | Self-reported tidiness scale. |
party_frequency | integer | Yes | Self-reported partying frequency scale. |
preferred_roommate_school | text | Yes | Preferred school/college affiliation of a roommate. |
preferred_shower_time | text | Yes | Preferred time of day to shower. |
pronouns | text | Yes | User’s pronouns. |
school | text | Yes | User’s own school/college affiliation. |
sleep_time | text | Yes | Typical bedtime. |
snores | boolean | Yes | Whether the user snores. |
social_energy | integer | Yes | Self-reported social energy scale. |
status | roommate_status enum | Yes | One of searching, committed, inactive. |
updated_at | timestamp | Yes | Last time the profile was edited. |
volume_preference | integer | Yes | Preferred noise/volume level scale. |
wake_time | text | Yes | Typical wake-up time. |
where_from | text | Yes | Hometown/origin. |
dorm
Reference data for CMU residence halls, shared across all users (not tied to a user_id).
| Column | DB type | Nullable | Notes |
|---|---|---|---|
id | serial | No (PK) | Auto-incrementing primary key. |
ac_details | text | Yes | Description of air conditioning setup. |
bathroom_details | text | Yes | Description of bathroom facilities. |
bathroom_type | text | Yes | Category of bathroom (e.g. shared, private, communal). |
close_buildings | text[] | Yes | Nearby buildings of interest. |
has_ac | boolean | Yes | Whether the dorm has air conditioning. |
image_url | text | Yes | Primary/cover image for the dorm. |
kitchen_description | text | Yes | Description of kitchen facilities. |
latitude | numeric | Yes | Geographic latitude. |
longitude | numeric | Yes | Geographic longitude. |
lounge_description | text | Yes | Description of lounge/common space. |
name | text | Yes | Dorm name. |
photo_gallery | json | Yes | Array/object of additional photo URLs. |
room_types | text[] | Yes | Room configurations offered (e.g. single, double). |
tags | text[] | Yes | Freeform tags for filtering/search. |
updated_at | timestamp | Yes | Last time the dorm record was edited. |
review
User-submitted reviews of a dorm. Many-to-one against both user and dorm.
| Column | DB type | Nullable | Notes |
|---|---|---|---|
id | serial | No (PK) | Auto-incrementing primary key. |
dorm_id | integer | No (FK -> dorm.id) | Dorm being reviewed. |
user_id | integer | No (FK -> user.id) | Author of the review. |
body | text | Yes | Free-text review content. |
lived_term | text | Yes | Term the reviewer lived there (e.g. Fall). |
lived_year | text | Yes | Year the reviewer lived there. |
rating_amenities | integer | Yes | Amenities rating. |
rating_atmosphere | integer | Yes | Atmosphere rating. |
rating_overall | integer | Yes | Overall rating. |
rating_room_quality | integer | Yes | Room quality rating. |
submitted_at | timestamp | Yes | When the review was submitted. |
Relationships
user (1) -> (1) user_preferencesviauser_preferences.user_iduser (1) -> (1) roommate_profileviaroommate_profile.user_iduser (1) -> (many) reviewviareview.user_iddorm (1) -> (many) reviewviareview.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:
numericcolumns (dorm.latitude,dorm.longitude) are typed asstring, notnumber. Drizzle/postgres-js don’t coerce them, to avoid floating-point precision loss. Parse withNumber()before doing math.jsoncolumns (roommate_profile.extras,dorm.photo_gallery) type asunknownunless 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, somodules/global/caddy.nixdeclarescaddy - A platform under
modules/platforms/takes its directory name, somodules/platforms/campus-cloud/default.nixdeclarescampus-cloud - A module under
modules/hosts/<host>/takes the host as a prefix, somodules/hosts/infra-01/forgejo.nixdeclaresinfra-01-forgejoandmodules/hosts/deploy-01/kennel.nixdeclaresdeploy-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.
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 daemoncrates/kennel-config: shared types and the devenv config contractcrates/kennel-provision: resource provisioning (PostgreSQL, Valkey, Garage)crates/entity,crates/migration: SQLite schema and SeaORM entitiesnix/modules: the shared devenv modules projects build onnix/lib: Nix build helpers (mkLib)nix/nixos.nix: NixOS module to run the daemon on a hostsites/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 byosm_building_to_json.py, which combines the existing building metadata with OpenStreetMap geometry, and then updated byadd_fms_id.pyto add FMS IDs.Intermediary Files
query.json: A raw snapshot of CMU building data from the public ArcGIS campus layer. It is generated byarc_gis_query.pyand 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 byfetch_osm_data.pyand used byosm_building_to_json.pyto extract building geometry.sign_abbrev_mapping.json: A small lookup file that maps building abbreviations to FMS building IDs. It is generated bysign_abbrev_mapping.pyfromquery.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 bygenerate_building_info_map.pyfrombuildings.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
S3 bucket link: https://minio.scottylabs.org/browser/cmumaps
Data sources:
-
OSM Scraper uses the Overpass API to scrape outside graph from OpenStreetMaps.
-
ESIM Scraper scrapes the CMU Building ArcGIS layer.
-
FMS Scraper scrapes the svgs from the CMU FMS website.
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.jsonfile. -
Generates the inside graph from the
floorplans.jsonfile.
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
Environments
Production
The production environment is version used by the public users.
Vercel: production
- CMU Maps: maps.scottylabs.org
- Visualizer: floorplans.scottylabs.org
Railway: production
Clerk: production
Staging
The staging environment is version used by the development team for testing before merging to main.
Vercel: staging
- CMU Maps: maps.slabs-staging.org
- Visualizer: floorplans.slabs-staging.org
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
-
Go to website
-
Click on building (CUC)
-
Check that it generates a info card and contains direction button
-
Check that dining locations are listed (Revolution Noodle)
-
Click on directions and input the starting location
-
Hit enter and check that a path is generated
-
Click on CUC again and click on food location (Revolution Noodle)
-
Check that an info card for food location is generated
-
Click on direction, input starting location and check that a path has been generated
-
Check that it generates a info card and contains direction button
-
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
-
Go to website
-
Click on building (CUC)
-
Check that it generates a info card and contains direction button
-
Check that dining locations are listed (Revolution Noodle)
-
Click on directions and input the starting location
-
Hit enter and check that a path is generated
-
Click on CUC again and click on food location (Revolution Noodle)
-
Check that an info card for food location is generated
-
Click on direction, input starting location and check that a path has been generated
-
Check that it generates a info card and contains direction button
-
Also make sure that menu button exist and can jump to a new page
-
Input room name in search bar (TEP 2611)
-
Click on room and check that info card is generated with the schedule
-
Check that button has generated and click on it
-
Check that a path is generated
Repeat for rooms without schedule
Room Schedule Test
-
Go to website
-
Click on building (CUC)
-
Check that button has generated and click on it
-
Check that a path is generated
Repeat for rooms without schedule
Schedule Upload Test
-
Click on schedule
-
Upload a new schedule
-
Check that courses are generated
-
Click on course and check that it jumps to room
Can search for rooms
-
Generates info card
-
Generates schedule (rooms like TEP 2611)
-
Click arbitrary rooms generate the directions button
-
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:
- Make sure you are using a conventional commit message.
- Run
bun run checkand try to fix the errors. - If you believe the errors are not related to your code changes, try syncing with
git pullbun run secrets:pull all allbun 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.
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
Using UV (recommended)
# 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 locationssearch_dining_locations(name_query): Search by nameget_locations_open_now(): Find currently open locationsget_locations_open_at_time(day, hour, minute): Check availability at specific timeget_location_hours(location_name): Get detailed info for a locationget_locations_by_cuisine(cuisine_query): Find locations by cuisine type
Maps Tools (prefix: maps)
search_buildings(query): Search for buildings/locationsget_path(start_id, end_id): Get path between two locationslist_possible_locations(query): List location name matchesdistance_between(start_id, end_id): Calculate distance in meters
Bus Tools (prefix: bus)
get_bus_predictions(): Get live predictions for both configured bus stopsget_stop_predictions(stop_id): Get predictions for one configured stopget_next_bus(stop_id, route, place, direction): Find the next matching bussearch_bus_predictions(query): Search current predictions by stop, route, destination, or capacityget_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.pysrc/mcp_server/services/maps/app.pysrc/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:
- Base App (
core/app.py): Defines the main FastMCP instance - Services (
services/): Individual MCP services with their own tools - 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
- Create a new directory under
src/mcp_server/services/ - Implement your service with FastMCP tools
- 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 frameworkaiohttp>=3.12.15: Async HTTP clienthttpx: HTTP client for async requestspydantic: Data validation and models
Data Models
DiningLocation
concept_id: Unique identifiername: Location nameshort_description: Brief descriptiondescription: Full descriptionlocation: Physical location on campusaccepts_online_orders: Online ordering availabilityurl: Location websitemenu_url: Menu linkcurrent_status: Open/closed status
TimeSlot
day: Day of week (0=Sunday, 6=Saturday)start_hour: Opening hour (24-hour format)start_minute: Opening minuteend_hour: Closing hourend_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.
| Repository | Role |
|---|---|
| cmugpt-surface | Web app and backend-for-frontend API: sign-in, chat history, preferences |
| cmugpt-agent | LLM agent: tool selection, answer checks, per-user memory |
| mcp-server | MCP server publishing CMU campus data as tools |
| Number | Title | Affects | Status |
|---|---|---|---|
| 0001 | System Architecture | all | Draft |
| 0002 | Agent | cmugpt-agent | Draft |
| 0003 | Surface | cmugpt-surface | Draft |
| 0004 | MCP Server | mcp-server, cmugpt-agent, cmugpt-surface | Draft |
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
- 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. - 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.
- 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.
| Command | Does |
|---|---|
migrate | applies pending migrations (prisma migrate deploy) |
migration | creates a migration from schema changes (prisma migrate dev) |
studio | opens 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/appcontains Next.js routes and route layouts.src/features/groupscontains study-group components, hooks, services, filters, and constants.src/features/profilecontains profile components, hooks, services, and profile-specific types.src/componentscontains shared layout, provider, and UI components.src/helperscontains external integration helpers such as calendar/date utilities.src/server/apicontains the Hono API application and route composition.src/stylescontains global and component-level CSS.flake.nixbuilds the deployable packagedevenv.nixdeclares 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
initTokenClientor 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.ownedhttps://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:
- Enable the Google Calendar API. Use a Web application OAuth client and
register the exact
OAUTH_RELAY_URLas an authorized redirect URI. Do not register individual PR preview origins for this server-initiated flow. - Ensure Ricochet allows the production and preview hosts. Local development uses its loopback configuration and a Google-registered local relay URL.
- Keep the existing
NEXT_PUBLIC_CALENDAR_CLIENT_ID; the server reuses it.CALENDAR_CLIENT_IDis an optional override if a separate server client is needed. SetCALENDAR_CLIENT_SECRETfor that same OAuth client andCALENDAR_TOKEN_ENCRYPTION_KEYthrough secretspec/OpenBao for each environment. The encryption key is 32 random bytes encoded as 64 hexadecimal characters; generate it withopenssl 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_KEYis not needed by the server flow and cannot replace the OAuth client secret. - 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
- The frontend links to
/auth/login, which sits behindOidcLoginLayer; axum-oidc redirects the browser to Keycloak via the Ricochet relay. The OAuthstatecarries a CSRF token plus the app callback ({APP_URL}/auth/callback). - Keycloak authenticates the user; the relay forwards the code to
/auth/callback, served byaxum_oidc::handle_oidc_redirect. - The callback exchanges the code for tokens and stores them in a server-side
session (Valkey, via
tower-sessions). OidcAuthLayerestablishes the claims on each request;sync_user_middlewareupserts a localuserkeyed on the OIDC subject and exposes it asSyncedUser.- 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, theSessionWrapperbridge fromtower-sessionsto axum-oidc’s session contract, the relaystategenerator, and the discoveredOidcClientbuilder.src/core/auth/middleware.rs:SyncedUserand its extractors, plussync_user_middleware.src/domain/auth/handlers.rs:GET /auth/status, the/auth/loginand/auth/logouthandlers, 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: Ifproxyis true, this stores the proxied user’s id; otherwisenull.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. Theuser_sessionrow 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:
-
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 (🔵, 🟢, 🔴)
-
Debug Info Display - The ProxySetup screen now shows:
- The
VITE_API_BASEvalue currently being used - The full URL being constructed for the request
- The
-
Better Error Handling:
- Detects response content-type and parses accordingly
- Shows user-friendly error messages
- Validates that response is JSON before parsing
-
Session Code Validation - Shows error if session code is missing
How to Debug
Step 1: Check Console Logs
When the ProxySetup screen appears:
- Open browser DevTools (F12)
- Go to “Console” tab
- The component will show:
Debug Infobox 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”:
- Look for 🔵 (blue circle) logs showing the request being sent
- 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_BASEenvironment 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:
- Open DevTools
- Go to “Network” tab
- Click “Continue” button
- Look for the
proxyrequest - 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:
- Run backend with verbose logging:
cd /home/yy/repos/voting-app/backend
RUST_LOG=debug cargo run --bin backend
- Look for logs about:
- Incoming requests
- Database queries
- User authentication
- Session lookups
Expected Behavior When Working
When everything is working correctly:
-
ProxySetup screen appears with:
- The correct session code
- Debug info showing the API URL
- Senator dropdown and optional proxy input
-
You select options and click Continue:
- Senator: Yes/No (required)
- Proxying for: Name (optional)
-
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 } -
Success message appears:
- “You now have 2 vote instances (your own vote + one proxy vote).”
- Or appropriate message based on your configuration
-
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.):
- Method: POST
- URL:
http://localhost:8000/session/{SESSION_CODE}/proxy - Headers:
Content-Type: application/jsonCookie: <auth_session_cookie>
- 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:
SetSessionProxyRequestis_senator: bool(required)proxy_for: Option<String>(optional, null if not proxying)
- Response Type:
SetSessionProxyResponsevote_instance_count: numberis_senator: booleanhas_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:
- If it’s an auth issue: Ensure auth service is running and cookies are being set
- If it’s a URL issue: Update
VITE_API_BASEenvironment variable - If it’s a backend issue: Check database, migrations, and server logs
- 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
/healthendpoint) - ✅ 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 Type | Base Instance | Proxy Instance | Total Instances | Notes |
|---|---|---|---|---|
| Senator, no proxy | ✓ | - | 1 | Votes as self |
| Senator, with proxy | ✓ | ✓ | 2 | Votes as self + proxies for someone |
| Non-senator, no proxy | - | - | 0 | Cannot vote |
| Non-senator, with proxy | - | ✓ | 1 | Can only proxy for a senator |
Architecture
User Flow
- Authentication → User logs in via
SignIn.svelte - Join Session → User selects voter role via
Home.svelte(provides session code) - Proxy Setup → NEW User declares senator status + optional proxy target via
ProxySetup.svelte - Waiting Page → User waits for motion to become active; sees participation confirmation banner
- Voting → User casts vote(s) on active motion
- 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_sessionrows 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
Voterstable (no longer needed; participation tracked viauser_sessionrows)
Frontend Components
ProxySetup.svelte (NEW)
Pre-voting-page screen that captures participation configuration.
Props:
sessionCode: string | null— session codeonBack: () => void— callback to return to join pageonNext: (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:
- User must select “Yes” or “No” for senator question (no skip option)
- User optionally enters proxy target name
- On submit, calls
POST /session/{code}/proxywith parsed payload - 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.”
- Passes notice to
App.svelteviaonNext(notice)callback
WaitingPage.svelte (ENHANCED)
Updated to display participation confirmation banner.
New Props:
notice: string | null— confirmation message fromProxySetup
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 fromProxySetupto persist through route
Updated Routes:
join→proxySetup→waiting(was directlyjoin→waiting)proxySetup.onNext(notice)setswaitingNoticebefore transitioning towaitingwaiting.onEventFound()clearswaitingNoticebefore 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:
- Validate session exists and is open
- Trim and filter proxy name (null if empty)
- Fetch all existing joined sessions for user
- Separate base vs proxy instances
- Senator logic:
- If
is_senator=true: Ensure one base instance exists (create if missing) - If
is_senator=false: Delete all base instances
- If
- Proxy logic:
- If
proxy_for=Some(name): Update first proxy or create new if missing - If
proxy_for=None: Delete all proxy instances
- If
- 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
Voterstable (no longer exists) - Updated
UserSessiontable to documentproxy: Option<String>field - Updated
Votetable to showevent_id + user_session_idkeys + uniqueness constraint
docs/db/db-json.md
- Updated vote data structure to include:
proxy: boolean— whether this vote instance is a proxyproxy_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
-
Non-senator proxy flow:
- User selects “No” + proxy name “Jane”
- Verify: 1 vote instance created (proxy only)
-
Senator proxy flow:
- User selects “Yes” + proxy name “John”
- Verify: 2 vote instances created (base + proxy)
-
Senator no-proxy flow:
- User selects “Yes” + no proxy name
- Verify: 1 vote instance created (base only)
-
Non-senator no-proxy flow:
- User selects “No” + no proxy name
- Verify: 0 vote instances created
-
Idempotency:
- Call same endpoint twice with same payload
- Verify: Same instance count returned both times
-
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
- User calls with
-
Attendance display:
- Confirm host sees correct
is_proxy_holderandproxy_forarrays
- Confirm host sees correct
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;
proxyfield 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— AddedSetSessionProxyRequest,SetSessionProxyResponse, rewroteset_session_proxy()
Frontend
src/screens/ProxySetup.svelte— NEW participation declaration screensrc/screens/WaitingPage.svelte— Enhanced to display noticesrc/screens/SessionCreation.svelte— Participant cards updated with proxy infosrc/App.svelte— Updated routing + state threading
Documentation
docs/db/db-schema.md— Removed voters table, updated schemadocs/db/db-json.md— Updated vote payload structure
Notes for Integration
- No breaking changes to existing endpoints; new
ProxySetupscreen is additive - Session creation unchanged —
POST /session/createreturns same response - Voting unchanged — Vote casting still uses
POST /events/{id}/vote - Participation is optional — Non-senator non-proxies simply get 0 instances and see “no voting options”
- Proxy names are flexible — Any string accepted (not validated against user roster)
Proxy Voting System - Testing Guide
Pre-Testing Checklist
- Backend compiled successfully:
cargo checkpasses - Frontend compiled successfully:
npm run checkpasses - 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:
- Log in to voter interface
- Enter session code and join
- On
ProxySetupscreen:- Select “No” for senator
- Leave proxy name empty
- Click “Continue”
- Should see notice: “You currently have 0 vote instances for this session.”
- Verify on
WaitingPagethat notice is displayed - When motion becomes active, verify user sees “No voting options available”
Verification:
-
/session/{code}/proxyreturnsvote_instance_count: 0 - Database check: 0
user_sessionrows for this user+session withjoin_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:
- Log in to voter interface
- Enter session code and join
- On
ProxySetupscreen:- Select “No” for senator
- Enter proxy name: “Jane Doe”
- Click “Continue”
- Should see notice: “You now have 1 proxy vote instance.”
- When motion becomes active, verify user sees 1 voting option labeled “Jane Doe”
- Cast vote on that option
- Verify vote is recorded with
is_proxy: true,proxy_for_name: "Jane Doe"
Verification:
-
/session/{code}/proxyreturnsvote_instance_count: 1, is_senator: false, has_proxy: true - Database check: 1
user_sessionrow withproxy = 'Jane Doe' -
/events/{id}/vote-instancesreturns 1 instance withis_proxy: true, proxy_for_name: "Jane Doe" - Vote in database has
proxy: truein 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:
- Log in to voter interface
- Enter session code and join
- On
ProxySetupscreen:- Select “Yes” for senator
- Leave proxy name empty
- Click “Continue”
- Should see notice: “You now have 1 vote instance.”
- When motion becomes active, verify user sees 1 voting option (unnamed, or labeled “Yourself”)
- Cast vote on that option
- Verify vote is recorded with
is_proxy: false
Verification:
-
/session/{code}/proxyreturnsvote_instance_count: 1, is_senator: true, has_proxy: false - Database check: 1
user_sessionrow withproxy = NULL -
/events/{id}/vote-instancesreturns 1 instance withis_proxy: false, proxy_for_name: null - Vote in database has
proxy: falsein 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:
- Log in to voter interface
- Enter session code and join
- On
ProxySetupscreen:- Select “Yes” for senator
- Enter proxy name: “John Smith”
- Click “Continue”
- Should see notice: “You now have 2 vote instances (your own vote + one proxy vote).”
- When motion becomes active, verify user sees 2 voting options: one unnamed (self) + one labeled “John Smith”
- Cast votes on both options (can be same or different)
- Verify both votes are recorded correctly
Verification:
-
/session/{code}/proxyreturnsvote_instance_count: 2, is_senator: true, has_proxy: true - Database check: 2
user_sessionrows (one withproxy = NULL, one withproxy = 'John Smith') -
/events/{id}/vote-instancesreturns 2 instances: one withis_proxy: false, one withis_proxy: true, proxy_for_name: "John Smith" - Both votes in database with appropriate
proxyfields - 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:
- On
ProxySetupscreen:- Select “Yes” for senator
- Enter “Jane Doe”
- Click “Continue” → notice shows 2 instances
- (Hypothetically) Call same endpoint again with identical payload
- 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_sessionrows created - Database remains consistent
Scenario 6: Re-Submission with Changes
Expected Behavior: Changing configuration updates instance set correctly
Steps:
- User goes through flow as senator with proxy “Jane” → gets 2 instances
- On wait page, user realizes they entered wrong name → goes “Back”
- Re-enters proxy setup, changes to senator with proxy “John”
- Should see notice: “You now have 2 vote instances…” (same count, updated proxy)
- 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:
- User initially selects “Yes” for senator with proxy “Jane” → 2 instances
- User changes mind on proxy setup, selects “No” for senator + proxy “Jane”
- Should see notice: “You now have 1 proxy vote instance.”
- 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:
- Host creates session and starts waiting for attendees
- 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”
- Host views attendance (in
SessionCreationhover cards) - Verify each user shows correct proxy status
Verification:
- GET
/session/{code}/attendancereturns:- 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"]
- User A:
- Host UI displays these correctly in participant hover cards
Scenario 9: Proxy Name Whitespace Handling
Expected Behavior: Leading/trailing spaces trimmed server-side
Steps:
- User enters proxy name: “ Jane Doe “ (with extra spaces)
- Backend should trim and store as “Jane Doe”
- 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:
- User enters proxy name: “” (empty string)
- Backend should treat as null
- If senator, should get 1 base instance
- 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:
- Alice: “Yes” senator, no proxy → expects 1 instance
- Bob: “No” non-senator, proxy “Alice” → expects 1 instance
- Charlie: “Yes” senator, proxy “David” → expects 2 instances
- Admin starts a motion
- Each user casts votes on all available options
- 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
-
Session creation still works:
POST /session/createreturns expected response- No proxy fields in response
-
Regular voting still works (non-proxy case):
- Users without proxy can still vote normally
- Vote structure unchanged
-
Results endpoints unchanged:
/events/{id}/resultsreturns same structure- (Proxy data is supplementary in vote metadata)
-
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 Type | Input | Expected Instances | Base | Proxy | Notice |
|---|---|---|---|---|---|
| Senator | No proxy | 1 | ✓ | - | “You now have 1 vote instance.” |
| Senator | Proxy “Jane” | 2 | ✓ | ✓ | “You now have 2 vote instances…” |
| Non-senator | No proxy | 0 | - | - | “You currently have 0 vote instances.” |
| Non-senator | Proxy “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 fmtand linted withcargo clippy. The CI/CD and pre-commit checks will check that all PR’ed code passescargo fmtandcargo 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 storagebackend: session: ensures user must exist before joiningdocs: process: add section on code reviewdevenv: update to latest scottylabs versionfrontend: 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:
| Component | Kennel key | Domain |
|---|---|---|
| Frontend site | N/A | terrier.scottylabs.org |
| API service | scottylabs.kennel.services.terrier | api.terrier.scottylabs.org |
| Documentation site | scottylabs.kennel.sites.docs | docs.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.