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
title: Getting Started description: Learn how to use and contribute to ScottyLabs projects project: documentation repo: https://codeberg.org/ScottyLabs/documentation
Welcome! This documentation hub aggregates documentation from multiple ScottyLabs repositories.
For Users
Each project has its own section in the sidebar with:
- Guides: Step-by-step tutorials
- API Docs: Interactive API references (for projects with APIs)
- Rustdoc: Generated documentation for Rust code
Start with ScottyLabs for organization-wide guides on contributing, communication, and credentials.
Use search (⌘K) or browse the sidebar to find what you need.
For Contributors
Repositories registered in governance are included in this hub by default. To exclude a repo, set docs = false in its team entry.
The build system automatically:
- Clones your repository
- Copies its
docs/directory into this site - Generates API references (if applicable)
- Builds and deploys to docs.scottylabs.org
See the Documentation Hub page for the full workflow.
ScottyLabs
The single source of truth for all ScottyLabs documentation.
Replaces Notion, Discord pins, Google Drive docs, and scattered README files with a unified, searchable, automatically-updated documentation platform. Integrates with ScottyLabs governance to automatically pull documentation from projects marked with the docs flag.
Vision
Every ScottyLabs project, guide, process, and resource in one place:
- Project Documentation: Automatically aggregated from repos with
docs: 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://codeberg.org/scottylabs/documentation.git
cd documentation
# Install dependencies
bun install
# Enter development shell (Nix users)
nix develop
Governance Integration
Projects are automatically discovered from the ScottyLabs governance repository. When a repository has docs = true in its governance entry (same pattern as kennel and sentry flags), it’s included in the documentation hub.
To add your project’s documentation:
-
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://codeberg.org/scottylabs/my-project"
type = "starlight"
docs_dir = "docs"
description = "Documentation for My Project"
Starlight Project Example
[[project]]
slug = "guides"
name = "User Guides"
repo = "https://codeberg.org/scottylabs/guides"
type = "starlight"
docs_dir = "docs"
description = "Comprehensive guides for all ScottyLabs services"
Rust Project Example
[[project]]
slug = "common-lib"
name = "Common Library"
repo = "https://codeberg.org/scottylabs/common-lib"
type = "rust"
docs_dir = "docs"
description = "Shared Rust utilities and types"
OpenAPI Project Example
[[project]]
slug = "courses-api"
name = "Courses API"
repo = "https://codeberg.org/scottylabs/courses-backend"
type = "openapi"
docs_dir = "docs"
openapi_spec = "openapi.json"
export_command = "cargo run --bin export-openapi"
description = "Course scheduling and registration API"
Development
# Build documentation from all projects
bun run build
# Start development server
bun run dev
# Clean build artifacts
bun run scripts/build.ts clean
Build Pipeline
The build process follows these steps:
- 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…
Codeberg 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@codeberg.orgYou should see a message with your username, not
Permission denied.
If ssh-add complains later, run ssh-add ~/.ssh/id_ed25519 and enter your passphrase.
(Optional) GitHub: add the same public key at GitHub SSH and GPG keys if you push there too.
Commit signing (optional)
Add your public key under SSH / GPG keys on Codeberg and click Verify to prove ownership.
SSH signing (Git 2.34+). You can use your auth key or a separate signing-only key:
git config --global gpg.format ssh
git config --global user.signingKey '~/.ssh/id_ed25519.pub'
git config --global commit.gpgSign true
GPG signing:
git config --global user.signingkey <key-id>
git config --global commit.gpgSign true
Next steps
After SSH is working, follow Contributing to request access through Governance. See GitHub Organizations for how ScottyLabs uses GitHub and Codeberg together.
Contributing
To contribute to a ScottyLabs project, follow the README instructions in Governance to join a team and obtain the necessary permissions.
You can join anytime of the year!
Did you find a bug?
-
Do not open up a GitHub issue if the bug is a security vulnerability, and instead send an email to ops@scottylabs.org.
-
Ensure the bug was not already reported by searching on GitHub under Issues.
-
If you’re unable to find an open issue addressing the problem, open a new one. Be sure to include a title and clear description and as much relevant information as possible.
Do you have an idea for a new feature?
-
Ensure the feature was not already proposed by searching on GitHub under Issues.
-
If you’re unable to find an open issue addressing the problem, open a new one. Be sure to include a title and description of the feature you want to add.
Do you want to contribute to the codebase?
Find an Issue
Start by looking through the open issues to find something you are interested in working on. Please avoid picking issues that are already assigned to someone.
- Look for issues labeled good first issue. These are great entry points for new contributors.
If you don’t see something that interests you, feel free to open a new issue with your idea.
Note that a new contributor won’t be assigned to the issue until their PR is merged. This helps keep issues open for others who might also want to work on them, but we will try to not assign the same issue to multiple contributors over a short period of time.
Request Permission
Follow the README instructions in Governance to join a team and obtain the necessary permissions.
When opening your Governance PR, make sure to link the corresponding issue that you will be working on.
Setup and Develop
Sign up on Codeberg if you do not have an account. That page has SSH setup and optional commit signing. Repository-specific setup is in each project’s docs.
Submit a Pull Request and Get Credited as a Contributor
See PR Process.
Do you have questions?
Ask any question in the ScottyLabs Slack by messaging in the corresponding channel or DMing any maintainer of the project. You can find information about the Slack channels and maintainers of a project in Governance.
Join Us!
We encourage you to get involved and join the team!
Thanks!
ScottyLabs Team
Acknowledgments
This document was adapted from the Ruby on Rails contributing guide.
Labrador To Tech
Labrador vs Tech
New projects starts in Labrador and move to Tech after they are deployed. The Tech team typically requires more experience, as you will be working with an existing codebase.
Process of moving from Labrador to Tech
Make a PR in Governance with a video demo? Will be formally documented after CMU Study launches in Spring 2026.
Why should you move to Tech?
Reasons include but not limited to:
-
Domain name (e.g: cmumaps.com, cmucal.com, cmustudy.com)
-
Access to our internal documentation on Notion
-
Access to our services, including:
-
Apple Developer
-
Clerk
-
Cloudflare
-
Mailgun
-
Mailman
-
MinIO
-
MongoDB
-
OpenRouter
-
PostHog
-
Ops Email
-
Railway
-
Sentry
-
Sevalla
-
Slack
-
Vercel
-
Zapier
-
Projects
Public Documentation
See governance teams and each repository’s README and wiki.
Internal Documentations
See https://www.notion.so/wiki-scottylabs/Projects-23296192554c8005bbc0eb91a2888129
Project Wikis
-
CMU Maps Wiki: https://github.com/ScottyLabs/cmumaps/wiki
-
Governance Wiki: https://github.com/ScottyLabs/governance/wiki
Credentials
Hashicorp Vault
UI Login
You can login to the vault by pressing the “Sign in with OIDC Provider” button with Method “oidc”. Press “ScottyLabs” listed under “Secrets Engines” and navigate to the file you have permissions to access in your team’s folder to view the secrets. If you see the following error, it means that you are not in any ScottyLabs Vault group, so you are not able to log into the vault.
Well we don’t want any CMU student to use our Vault, right?
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
Prerequisites
- Bun - JavaScript runtime and package manager
- Cargo - Rust package manager and build system
- PRT API Key - Obtained from creating a TrueTime account here
Setup
Setting up your environment variables
# Copy env variables from .env.example
$ cp .env.example .env
# Add your PRT_API_KEY to the .env file
Running the backend
$ cd backend
# Install dependencies and start the backend
backend $ cargo run
Running the frontend
$ cd frontend
# Install dependencies
frontend $ bun install
# Start the frontend
frontend $ bun dev
Cal
A unified web calendar for all CMU academic events
Cmugpt Agent
This project makes use of several excellent tools from Astral, including uv, ruff, and ty.
Setup
- Once you have installed
uv, install dependencies with
uv sync
- Install the pre-commit hooks using
uv run pre-commit autoupdate
uv run pre-commit install --install-hooks
- VS Code will prompt you to install the recommended extensions, which you should accept. If you mistakenly closed it, you can find them in
.vscode/extensions.json.
Usage
- Format:
uv run ruff format - Typecheck:
uv run ty check - Lint:
uv run ruff check
To run the FastAPI app locally with uv (the project uses uv for task execution), run:
uv run python src/main.py
You can set the PORT environment variable to change the listening port (defaults to 5000):
PORT=8080 uv run python src/main.py
Deployment (Kennel)
Production runs on Kennel via devenv and secretspec. Pushes to Codeberg main trigger deploys (GitHub mirror pushes do not).
URLs:
- https://api.cmugpt-agent.scottylabs.org (custom domain)
- https://cmugpt-agent-agent-main.scottylabs.net (default Kennel URL)
Validate locally before pushing:
SECRETSPEC_PROVIDER=dotenv://.env devenv build scottylabs.kennel.config
nix build .#packages.x86_64-linux.agent
Set production secrets (requires cmugpt-agent-admins group and bao login -method=oidc):
secretspec set -P prod OPENROUTER_API_KEY
secretspec set -P prod MCP_SERVER_URL
secretspec set -P prod AGENT_SHARED_SECRET
secretspec check -P prod
Guidelines
You should not globally disable rules enforced by ruff or ty. If absolutely necessary, you can ignore them on a line-by-line basis:
For ty, use ignore directives in the following order of precedence, based on what is strictly necessary.
# ty: ignore[<rule>]for ignoring single rules# ty: ignore[rule1, rule2, ...]for ignoring multiple rules# type: ignoreor# type: ignore[<rule>]for ignoring all violations on that line (even if a rule is specified!)- The decorator
@typing.no_type_checkto suppress all violations inside a function
For ruff, follow the same pattern.
# noqa: <rule>for ignoring single rules# noqa: rule1, rule2, ...for ignoring multiple rules# noqafor ignoring all violations on that line# ruff: noqa: <rule>for ignoring a specific rule across an entire file# ruff: noqafor ignoring all violations across an entire file
Cmugpt Sms Surface
SMS/iMessage companion feature for the CMUGPT Agent. Students send iMessages via BlueBubbles, authenticate with their CMU Andrew ID, and chat with the CMU campus assistant for event notifications, schedule planning, reminders, and orientation-week help.
Local setup
- 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
ScottyStack (ScottyLabs Tech Stack) is a full-stack typesafe template for building web applications.
Quickstart
See Quickstart Guide.
Documentation
See ScottyStack Wiki.
Components
This monorepo is ScottyLabs’ design system, providing design tokens and components for React and Svelte built from a shared Figma source.
The library is split across six packages:
@scottylabs/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.
Dalmatian
Dalmatian is a Discord bot designed for CMU students, providing easy access to campus resources like CMU Courses and CMU Eats right at your fingertips!
Getting Started
Prerequisites
(You’ll need Nix as well, but devenv gives you that command.)
Setup
For detailed setup instructions including creating a Discord bot, obtaining API credentials, and configuring your development environment, see docs/CONTRIBUTING.md.
Quick setup:
- Install devenv and direnv (see links above)
- Create a Discord bot at [https://discord.com/developers/applications]
- Get your
DISCORD_TOKENandDISCORD_CLIENT_ID
Running the Bot
# Set up environment variables (see CONTRIBUTING.md for details)
cp .env.example .env
# Edit .env with your Discord bot credentials
# Start the environment
devenv up
Deployment
Production runs on Kennel via devenv and secretspec.
Contributing
Please read CONTRIBUTING.md before you contribute to this project!
Contributing to Dalmatian
Thank you for your interest in contributing to Dalmatian! This guide will help you get started.
How to Contribute
- 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
Warning
This section is outdated and in need of attention. Please use this information with caution, and consider sending patches to update it.
The bot uses PostgreSQL for storing polls and reaction redirect configurations. The database runs in Docker for local development.
-
Start the PostgreSQL database using Docker:
docker-compose up -d postgres -
Run database migrations to create the tables:
deno run db:migrate -
(Optional) Open Drizzle Studio to inspect the database:
deno run db:studio
The database will persist data in a Docker volume. To completely reset the database, run:
docker-compose down -v
docker-compose up -d postgres
deno run db:migrate
Before Submitting
Before you commit and open a pull request, make sure to:
- Run
deno run 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:
- Visit the Dalmatian Development project
- 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:
- Open an issue on GitHub
- Check existing issues and pull requests for similar questions
Remember to follow conventional committing guidelines while contributing!
Setting up Dalmatian
Code Editor Setup
We recommend using VSCode, and the following setup guide will assume you are using VSCode.
Recommended VSCode extensions:
- VSCode has builtin TypeScript language support
- Dependi
- Biome
- Oxc
- Typescript (Native Preview)
You will also need git installed.
Creating your .env file
Rename or copy .env.example into .env.
Create a new Discord bot or use one of your current ones.
In your application, under the Bot tab, reset your token and copy the token for DISCORD_TOKEN
Under the OAuth2 tab, grab the client ID for DISCORD_CLIENT_ID
The bot will work fine with only the DISCORD_TOKEN and DISCORD_CLIENT_ID keys.
Optionally, add GOOGLE_MAPS_API_KEY to .env to debug formatLocation in dining.ts (falls back from Vault to .env).
- 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.
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://codeberg.org/scottylabs/documentation.git
cd documentation
# Install dependencies
bun install
# Enter development shell (Nix users)
nix develop
Governance Integration
Projects are automatically discovered from the ScottyLabs governance repository. When a repository has docs = true in its governance entry (same pattern as kennel and sentry flags), it’s included in the documentation hub.
To add your project’s documentation:
-
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://codeberg.org/scottylabs/my-project"
type = "starlight"
docs_dir = "docs"
description = "Documentation for My Project"
Starlight Project Example
[[project]]
slug = "guides"
name = "User Guides"
repo = "https://codeberg.org/scottylabs/guides"
type = "starlight"
docs_dir = "docs"
description = "Comprehensive guides for all ScottyLabs services"
Rust Project Example
[[project]]
slug = "common-lib"
name = "Common Library"
repo = "https://codeberg.org/scottylabs/common-lib"
type = "rust"
docs_dir = "docs"
description = "Shared Rust utilities and types"
OpenAPI Project Example
[[project]]
slug = "courses-api"
name = "Courses API"
repo = "https://codeberg.org/scottylabs/courses-backend"
type = "openapi"
docs_dir = "docs"
openapi_spec = "openapi.json"
export_command = "cargo run --bin export-openapi"
description = "Course scheduling and registration API"
Development
# Build documentation from all projects
bun run build
# Start development server
bun run dev
# Clean build artifacts
bun run scripts/build.ts clean
Build Pipeline
The build process follows these steps:
- 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.
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.
- Add your Codeberg username to the
membersarray in the desired team.tomlfile underdata/. - Open a PR using a conventional PR title.
Note that only team leads are allowed to modify other people’s memberships.
Creating a team
Teams are groups of leads, members, repositories, and channels. They can nest sub-projects recursively, each with the same shape. Copy an existing file in data/teams/ for a working starting point.
Reference the team schema for an authoritative list of fields and their constraints.
Features
Each repository opts into capabilities through its features table. Presence enables a feature, an empty table enables it with defaults, and features with settings take them as keys:
[[team.repos]]
name = "collie"
features = { kennel = {}, sentry = {} }
[team.repos.features.ai_gateway]
prod_monthly_budget = 20.0
kenneladds a Forgejo webhook that connects the repository to kennel for builds and deploymentssentrycreates a Sentry project and writes its DSN to Vaultposthogcreates a PostHog project and writes its key and host to Vaultcdncreates a public-read Garage bucket for the repository and writes its S3 credentials and public URL to Vaultoidc_clientprovisions prod and staging Keycloak OIDC clients with a fixed redirect URI and writes their credentials to Vault per profile; setadmin = trueto also provision a service-account client with user-management roles and write its credentials to Vaultai_gatewayprovisions LiteLLM API keys with monthly budgets, a prod key and a lower-budget key shared by staging, preview, and dev, and writes the key and gateway URL to Vault per profiledocsregisters the repository’sdocs/directory with the documentation hub
How a project declares and consumes what these provision lives in the kennel docs: Deploying a Project and Secrets.
Description
The following is a list of platforms Governance manages:
- 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
- OpenBao
- Keycloak groups are given the appropriate access to secret paths on OpenBao
- Codeberg
- Members are added to their Codeberg teams, which gives them appropriate access to the team’s repositories
- Codeberg repositories are set up to automatically sync to GitHub for visibility
- Google
- Members are automatically added to ScottyLabs’ and Tech’s mailing lists (Google Groups)
- 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
- Vaultwarden
- Members are given the appropriate access to account credentials on Vaultwarden
Here, “appropriate access” serves to delineate between member permissions and team lead permissions.
Groupme Mirror
Mirror messages from a GroupMe to a Discord webhook.
Usage
The following should be in the environment:
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz
GROUPME_ACCESS_TOKEN=abcdefghijklmnopqrstuvwxyz1234567890
GROUP_ID=1234567890
You can get your access token from Access Token button at the top right of the the GroupMe developer portal. You can get the group ID from the share URL of the group.
Housing
The CMU Housing project hosted at https://cmuhousing.com serves as the obvious choice for CMU students to look for Housing. Search for the perfect dorm, explore ratings and reviews from real students, and find your roommate all in one website.
Contributing
Please read CONTRIBUTING.md before you contribute to this project!
Contributing to CMU Housing
Thank you for your interest in contributing to CMU Housing! This guide will help you get started.
Setup
Developers should add themselves to the cmu-housing team in governance following the instructions in its README. This gives you access to secrets and permission to create branches on the repo.
How to Contribute
-
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
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: 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.
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 NixOS configuration for every ScottyLabs machine. infra-01 runs the shared services (identity, git, secrets, monitoring), deploy-01 runs the Kennel deployment platform, signage-01 is a display kiosk, and snoopy is a Computer Club virtual machine. Each host’s full system is built from a set of small modules that live under modules/.
A service is defined in one file and then listed by name on the hosts that should run it.
Module namespace
Three inputs in flake.nix provide the module system:
imports = [
inputs.flake-parts.flakeModules.modules
(inputs.import-tree ./modules)
inputs.terranix.flakeModule
];
import-tree ./modules imports every .nix file under modules/ as a flake-parts module, merging their definitions into one flake-wide configuration.
flake-parts.flakeModules.modules adds the flake.modules option that those files write into. Modules meant for a NixOS host go under flake.modules.nixos. A typical file declares one named entry:
# modules/global/base.nix
{
flake.modules.nixos.base = { config, lib, ... }: {
# NixOS options for the base system
};
}
Each file names its entry after where the file lives:
- A module under
modules/global/takes a bare name, 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> in a single flat namespace, regardless of how deeply its file is nested.
Roles
A host is built from a list of module names living in modules/roles/.
modules/roles/global.nix defines the baseline shared by every host: the base system, networking, secrets, and the observability agents, together with external inputs such as home-manager, agenix, and disko.
Each host has its own role. Its imports are grouped into a platform, the common modules it shares with other hosts, and the host’s own services, with each group in a labelled section and sorted within the section:
flake.modules.nixos.infra-01.imports = with config.flake.modules.nixos; [
# Platform
campus-cloud
# Common
postgresql
# Services
infra-01-forgejo
# ...
];
Platforms
Each host role includes a platform. Platforms live in modules/platforms/ and hold the machine-dependent parts of a configuration, such as the boot loader, kernel modules, and disk layout via disko. campus-cloud covers the CMU Campus Cloud VMware guests used by infra-01 and deploy-01, mele-cyber-x1 covers the signage hardware, and computer-club covers snoopy.
Systems and deployment
modules/systems.nix produces the nixosConfigurations used for local builds and the colmenaHive used for deployment. Both come from the same per-host module list, which combines the host role with the global role:
modulesFor = hostname: [ nixos.${hostname} nixos.global ];
nixosConfigurations calls nixpkgs.lib.nixosSystem for each host with that module list. colmenaHive passes the same list to Colmena and adds deployment settings, so each host is reached over SSH at <hostname>.scottylabs.org as the deploy user. Both forms receive the same specialArgs (inputs and the contents of users.nix), so a module behaves identically whether it is built locally or deployed.
Imported modules and enable flags
Most modules apply their configuration as soon as a role imports them. Others expose an option under the scottylabs.* namespace and apply nothing until it is set. modules/global/node-exporter.nix declares such an option and guards its config behind it:
options.scottylabs.nodeExporter = {
enable = lib.mkEnableOption "Prometheus node_exporter";
port = lib.mkOption {
type = lib.types.port;
default = 9100;
};
};
Importing the module does nothing until scottylabs.nodeExporter.enable is set, which modules/global/observability-agents.nix does for all the agents.
The scottylabs.* namespace is the repository’s own settings surface, alongside the upstream NixOS options. It carries options such as the public IP (scottylabs.publicIp) and the observability-agent toggles (scottylabs.nodeExporter.enable). A module’s options.scottylabs.* block shows what it exposes and whether another module must switch it on.
Flake-level configuration
Some files under modules/ contribute configuration at the flake level. These values are collected across every host before they are used.
Files under modules/terranix/ declare entries under flake.modules.terranix, which are assembled into terranixConfigurations and import one another by name, as on the NixOS side.
A service declares its Grafana dashboards and alerts through the flake-level scottylabs.observability option, in the same file as the service. modules/hosts/deploy-01/kennel.nix defines both the Kennel module and its dashboard. Grafana runs only on infra-01 and reads the scottylabs.observability values from the whole flake, so a dashboard declared beside a service on deploy-01 is rendered by the Grafana on infra-01.
Internet Archive
Script that automatically saves a page of your choice to the internet archive.
Archived websites may take a few minutes before appearing.
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 V2
Official Carnegie Mellon University platform for reporting, managing, and finding lost items
Maps
Overview
CMU Maps is a web application that provides a map of the Carnegie Mellon University campus, allowing users to easily access information about campus locations.
Key features include:
- View floorplans
- Room level navigation
- Search for buildings and rooms
- View building and room information
Wiki
Please check out our wiki for more information about the project.
Contributing
Please check out our Contributing Guide for more information about how to contribute to the project.
Mcp Server
A unified Model Context Protocol (MCP) server providing access to ScottyLabs’s Projects for CMU Students services through FastMCP. This server combines multiple CMU-related services into a single, composable MCP interface.
Overview
This project provides MCP tools for:
- CMU Dining (Eats): Query dining locations, hours, menus, and real-time availability
- CMU Maps: Search buildings, get directions, and calculate distances on campus
Built with FastMCP, this server uses a modular architecture that allows mounting multiple sub-services with namespace prefixes.
Features
CMU Dining Service (eats)
- Get all dining locations with details (cuisine, hours, location)
- Search locations by name
- Find locations currently open
- Check locations open at specific times
- Query locations by cuisine type
- Get detailed hours and specials for specific locations
- Real-time open/closed status
- Online ordering availability indicators
CMU Maps Service (maps)
- Search buildings and locations by name
- Get paths between two locations
- Calculate distances between locations
- List possible location matches for queries
Installation
Prerequisites
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
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
Configuration
API Endpoints
- Dining API:
https://dining.apis.scottylabs.org - Maps API:
https://rust.api.maps.scottylabs.org
Endpoints are configured in:
src/mcp_server/services/eats/constants.pysrc/mcp_server/services/maps/app.py
Server Configuration
The main server configuration is in src/mcp_server/__init__.py:
- Host:
0.0.0.0 - Port:
8000 - Transport:
http(streamable HTTP transport)
CORS Configuration
CORS (Cross-Origin Resource Sharing) is enabled by default in src/mcp_server/core/app.py:
- Allow Origins:
*(all origins - adjust for production) - Allow Methods: All HTTP methods including OPTIONS
- Allow Headers: All headers
- Allow Credentials: Enabled
This configuration enables the server to handle OPTIONS preflight requests and accept requests from any origin. For production deployments, consider restricting allow_origins to specific domains.
Development
Architecture
The project uses a compositional architecture:
- 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
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 first to create your local database. (Note that you should have the Docker CLI installed). For frontend .env variables, check the latest Vercel deployment. For backend .env variables, check the latest Railway deployment. If you don’t have access to either deployment, ask Eric Xu on Slack.
To run everything: pnpm -r --parallel run dev
To run a specific module, pnpm -F @apps/backend dev, for example
Frontend: React + Vite + Typescript + Tanstack Query
Backend: Fastify + ts-rest + Drizzle + octokit (Slack API) + Node runtime
Note: if you make any db schema changes, please run pnpm db:generate before committing to generate migration files
Tartan Vote
Tartan Vote is a CMU Undergraduate Senate-commissioned, ScottyLabs-developed voting app, to help the Senate and other student organizations manage attendance and host elections and motions. Currently, the app is still under development, but we strongly hope to get it completed very soon!
Built With
- SvelteKit
- Rust
- PostgreSQL
Assumptions about the reader
Hello, reader! For the remainder of this README, and other documentation, we will assume that you are a developer or contributor, using WSL or a Unix development system, and have some familiarity with the command line. If you need any help, you are free to contact one of the codeowners found in CODEOWNERS, or join the discord.
Getting Started
Prerequisites
Quick Setup
For detailed setup instructions, see SETUP.md. Configuration and secrets are documented in secrets-and-config.md.
Authenticate once per machine with OpenBao so secretspec can read dev secrets:
export BAO_ADDR=https://secrets2.scottylabs.org
bao login -method=oidc
Allow direnv (or enter the shell manually):
direnv allow
# or: devenv shell
Run the app (inside the devenv shell, from the repo root):
devenv up
# add --detach or -d to run it in the background
# devenv processes down to shut it down
cd frontend && deno task build
cargo run
Then open http://localhost:8080. Inside the devenv shell, DATABASE_URL and secrets are provided automatically.
Deployment
Production runs on Kennel via devenv and secretspec.
Contributing
Please check CONTRIBUTING.md before you contribute to this project!
Licenses
Voting App is distributed under the Apache 2.0 and MIT Licenses, found in the files LICENSE-APACHE-2.0 and LICENSE-MIT respectively.
Auth
Authentication is OIDC via Ricochet/Keycloak: /auth/login, /auth/callback,
/auth/logout. OIDC secrets are provided by secretspec.
Backend flow
- 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 = {
vote_type: string;
proxy: boolean;
proxy_for_user_id: number | null;
vote_response: string[];
};
Field Descriptions
vote_type: Specifies the type of vote represented by this response. Examples include"motion"and"election".proxy: Whether this vote was cast as a proxy vote instance.proxy_for_user_id: Ifproxyis true, this stores the proxied user’s id; otherwisenull.vote_response: Stores the participant’s submitted response as an array.- For a standard motion, this array typically contains a single value.
- For a ranked-choice election, this array stores the ranked selections in order of preference.
- A lower array index indicates a higher preference (e.g., index 0 = first choice, index 1 = second choice).
Example Usage
{
"vote_type": "motion",
"proxy": false,
"proxy_for_user_id": null,
"vote_response": ["choice1", "choice2"]
}
event.data
The event.data field stores event-specific configuration and metadata.
Type Definition
type EventData = {
description: string;
session_code: string;
vote_type: string;
threshold: number;
visibility: {
participants: string;
};
proxy: bool;
vote_options: string[];
};
Field Descriptions
description: A textual description of the event.session_code: A code used for joining or identifying the session.vote_type: Specifies whether the event is a motion or an election.threshold: A floating-point value representing the approval threshold required for the vote. This value in the range [0, 1].visibility.participants: Defines what participants can see during the event. Example values include"hidden_until_release"and"live".proxy: Indicates whether proxy voting is enabled for the event.vote_options: Lists the selectable voting options for the event.
Example Usage
{
"description": "Event description goes here",
"session_code": "code",
"vote_type": "motion",
"threshold": 0.75,
"visibility": {
"participants": "hidden_until_release"
},
"proxy": true,
"vote_options": ["option1", "option2"]
}
Notes
- For now, all users are treated as having the same role.
organization.data
The organization.data field stores organization-level metadata.
Type Definition
type OrganizationData = {
description: string;
};
Field Descriptions
- description: A textual description of the organization.
Example Usage
{
"description": "Organization Description"
}
log.data
The log.data field stores audit log information for system actions. Logging has not been implemented yet, so this section is not in use currently.
Type Definition
type LogData = {
action: string;
target: {
table: string;
id: number;
};
actor:
| {
user_id: number;
role: string;
}
| "system";
event_id: number;
changes: {
before: {};
after: {};
};
};
Field Descriptions
action: A description of the action being logged.target: Identifies the record affected by the action.table: The name of the affected table.id: The primary key of the affected record.actor: Identifies the user responsible for the action.user_id: The ID of the acting user.role: The role of the acting user.event_id: The related event identifier, if applicable.changes: Stores the state transition caused by the action.before: The state before the change.after: The state after the change.
Example Usage
{
"action": "Action Description",
"target": {
"table": "tablename",
"id": 0
},
"actor": {
"user_id": 0,
"role": "Role String"
},
"event_id": 0,
"changes": {
"before": {},
"after": {}
}
}
Generating migrations
Using sea-orm, we follow the standard migration-first approach. We recommend using sea-orm-cli to generate migrations and entities.
Migrations
To create a new migration file, navigate to the crates folder and run
backend/crates $ sea-orm-cli migrate generate MIGRATION_NAME
To migrate the database, follow the steps in SETUP.md or for short, run
backend/crates/voting-app $ cargo run
(PostgreSQL is managed automatically by devenv when you enter the shell.)
Entities
Generating entities requires the database to be migrated, so that the entities can be built off the structure of the database. After migrating the database, run either
backend/crates/entity/src $ sea-orm-cli generate entity
or
backend/crates $ sea-orm-cli generate entity -o entity/src
Schema Information
Users
The Users table stores information on users.
Schema Definition
#![allow(unused)]
fn main() {
struct User {
id: i32,
name: string,
andrew_id: string,
oidc_subject: string,
created_at: DateTimeWithTimeZone
}
}
Field Descriptions
id: Primary Key. The id for the user. Autogenerated.name: The (full) name of the user, as fetched from auth.andrew_id: The andrew_id of the user, as fetched from auth.oidc_subject: The OIDC subject of the user, as fetched from auth. This is for authentication, and not for the frontend.created_at: The timestamp when the user was created (when the entry was created). Autogenerated.
Organizations
Schema Definition
#![allow(unused)]
fn main() {
struct Organization {
id: i32,
name: string,
data: Json
}
}
Field Descriptions
id: Primary Key. The id for the organization. Autogenerated.name: The name of the organization.data: Other data related to the organization. See organization.data.
Organization Members
The OrganizationMembers table is a join table between the Organizations and Users table to describe the many-to-many relationship users are allowed to have with organizations. It also stores information on what the user is allowed to do in the organization.
Schema Definition
#![allow(unused)]
fn main() {
struct OrganizationMember {
organization_id: i32,
user_id: i32,
user_role: string,
joined_at: DateTimeWithTimeZone
}
}
Field Descriptions
organization_id: Primary Key. The id for an organization. References Organizations.user_id: Primary Key. The id for the user in the organization. References Users.user_role: The role of the user in the organization. Specifies their permissions in the organization and creating events.joined_at: The timestamp when the user joined the organization (when the entry was created). Autogenerated.
Sessions
Schema Definition
#![allow(unused)]
fn main() {
struct Session {
id: i32,
join_code: String
status: SessionStatus,
created_by_user_id: i32
}
}
Field Descriptions
id: Primary Key. The id for the session. Autogenerated.join_code: The join code for the session. Capital alphabet and digits, 6 characters.status: The status of the session, can be “open”, “locked”, “closed”.created_by_user_id: The id of the user who created the session. Reference Users.
User Sessions
Schema Definition
#![allow(unused)]
fn main() {
struct UserSession {
id: i32,
user_id: i32,
session_id: i32,
proxy: Option<string>,
join_left: JoinLeft,
timestamp: DateTimeWithTimeZone
}
}
Field Descriptions
id: Primary Key. Autogenerated.user_id: Foreign Key. The id for the user in the session. References Users.session_id: Foreign Key. The id for the session. References Sessions.proxy: Nullable string. If set, stores the user id (as a string) of the participant this user is proxying for.join_left: Whether the user joined or left the session. Can be “joined” or “left”.timestamp: The timestamp when the user joined the session (when the entry was created).
Events
Schema Definition
#![allow(unused)]
fn main() {
struct Event {
id: i32,
event_type: EventType,
name: String,
status: StatusOption,
start_time: DateTimeWithTimeZone,
end_time: Option<DateTimeWithTimeZone>,
data: Json,
created_by_user_id: i32,
session_id: i32,
}
}
Field Descriptions
id: Primary Key. The id for the event. Autogenerated.event_type: The type of the event. Is an enum type. Can be “motion” or “election”.name: The name for the event.status: The status of the event. Is an enum type. Can be “active” or “inactive”.start_time: The time when the event(voting) starts.end_time: The time when the event(voting) ends, if ever.data: The other data related to the event. See event.datacreated_by_user_id. The id of the user who created the event. References Users.session_id: The id for the session this event belongs to. References Sessions.
Votes
This table probably breaks a normalization rule or two, but that doesn’t really matter right now.
Schema Definition
#![allow(unused)]
fn main() {
struct Vote {
id: i32,
event_id: i32,
user_session_id: i32,
cast_time: DateTimeWithTimeZone,
data: Json,
}
}
Field Descriptions
id: Primary Key. The id for the vote. Autogenerated.event_id: Foreign Key. The event this vote belongs to. References Events.user_session_id: Foreign Key. 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 (event_id, user_session_id).
ProxySetup Endpoint - Debugging Guide
What I’ve Updated
I’ve enhanced the ProxySetup.svelte component with comprehensive logging and debugging to help identify why the backend request isn’t working correctly.
Changes Made:
-
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.
Documentation
When making a change, it would be wonderful if you could update the corresponding documentation. If you cannot or are unsure how to, please leave an issue or let Yiyoung Liu know so that the documentation does not lag behind. If the documentation does not exist, don’t worry about it! (or write the documentation yourself, that would be greatly appreciated.)
Pull Requests
Direct pushes to main are blocked. You should create a branch (if you are a contributor in ScottyLabs) or fork the repository, make your changes, then create a PR to main.
Style Guide
- All Rust code should be formatted using
cargo fmtand linted withcargo clippy. The CI/CD will check that all PR’ed code passescargo fmtandcargo clippy. - All Svelte code should be checked with
deno task check. The CI/CD will automatically check this too.
Commit Guidelines
I am a firm believer in the kernel commit style. Not all of the sections in that document are useful, such as the fact that we do not mail patches (unfortunately), but most of the pieces of advice are helpful nonetheless. Good commit habits reflect on the developer. Being able to clearly reflect upon your changes and describe the impact of them means you are able to reason about your code and about why you are making the changes you are.
Commit Subjects
Commit subjects should be styled in the following method:
system: subsystem (if applicable): short description
A list of possible commit types, but not exhaustive:
backend: auth: created migrations for token 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.
Extensive Guide to Running Tartan Vote
Prerequisites
This project uses devenv to provide Cargo, Deno, Node, PostgreSQL, and all other development dependencies. Follow the devenv installation instructions.
Starting up
Now, we will get your own instance of Tartan Vote running!
Setup
You will need git.
Clone the repository from Codeberg:
git clone https://codeberg.org/ScottyLabs/tartan-vote.git
cd tartan-vote
Run direnv allow (or devenv shell) to enter the development environment. This exposes Cargo, Deno, Node, PostgreSQL, and other tooling.
Secrets
Configuration is provided automatically inside devenv shell — you do not
need to create a .env. Secrets are pulled from OpenBao via secretspec, so
authenticate once:
export BAO_ADDR=https://secrets2.scottylabs.org
bao login -method=oidc
If devenv shell reports missing secrets or you get 403 permission denied, see
secrets-and-config.md, which documents the full secrets
model and troubleshooting.
Run everything
From the repo root, inside the devenv shell:
# 1. Start the managed services (Postgres, OAuth relay)
devenv up
# 2. In another terminal: build the frontend into frontend/dist
cd frontend && deno task build && cd ..
# 3. Run the backend; it serves the API and the built frontend on :8080
cargo run
Then open http://localhost:8080.
When working on the frontend, run deno task build:watch in a separate
terminal instead of the one-off build; it rebuilds frontend/dist on save, and
a browser refresh picks up the changes.
Terrier
Open-source hackathon management platform for universities and organizations.
Features
- Registration and team management. Customizable application forms, team formation, and attendee management.
- Live judging. Real-time expo-style judging with support for multiple scoring systems.
- Multiple distribution methods. Docker(-compose), Nix flakes, and standalone binaries are supported.
- Enterprise SSO. OIDC and SAML support for institutional authentication, available to everyone.
- Mobile app. Native iOS and Android app for attendees, organizers, and judges.
- Documentation. Comprehensive documentation site with deployment guides and usage instructions.
- AI-enabled. MCP server integration and tasteful AI features for quality-of-life improvements.
- Self-hosted. You have full control over your data and infrastructure.
Canonical Deployment Domains
Terrier uses the following production custom domains:
| 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.