- TypeScript 93.4%
- CSS 6.5%
Mechanical sweep — single-line removal across 104 items. The related_designs: [] field is dropped from the items convention; root's designs/ tier was retired in the same pass with the §releases/ schema absorbed into item-convention.md. |
||
|---|---|---|
| .claude | ||
| .memory | ||
| apps | ||
| deploy | ||
| docs | ||
| liquidsoap | ||
| packages/shared | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| bun.lock | ||
| Caddyfile.dev | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| docker-compose.claude.yml | ||
| docker-compose.yml | ||
| ecosystem.config.cjs | ||
| garage.toml | ||
| LICENSE | ||
| LICENSE-DOCS | ||
| package.json | ||
| README.md | ||
| srs.conf | ||
| tsconfig.json | ||
S/NC
A worker-cooperative content publishing platform. Creators publish video, audio, and written content behind a subscription gate, sell merchandise via a headless Shopify storefront, embed Bandcamp players, and offer bookable creative services — all governed democratically by cooperative members.
Built as an alternative to extractive platforms.
Live: s-nc.org
Tech Stack
| Layer | Technology |
|---|---|
| API | Hono on Node.js 24+ with OpenAPI 3.1 docs |
| Frontend | TanStack Start (React 19, SSR, file-based routing) |
| Database | PostgreSQL 16 via Drizzle ORM |
| Auth | Better Auth (email/password, multi-role) |
| Payments | Stripe (subscriptions, checkout, webhooks) |
| Merch | Shopify Storefront API (headless) |
| Validation | Zod 4 (API + shared), zod/mini (frontend) |
| Testing | Vitest, Testing Library, Playwright (e2e) |
| Object Storage | Garage (S3-compatible, production) |
| Logging | pino (structured JSON, request-scoped via hono-pino) |
| Styling | CSS Modules + CSS custom properties (design tokens) |
| Package Manager | Bun 1.3+ with workspaces |
| Reverse Proxy | Caddy (dev + production) |
Repository Structure
snc/
├── apps/
│ ├── api/ # Hono API server
│ │ ├── src/
│ │ │ ├── app.ts # Hono instance, middleware, route registration
│ │ │ ├── index.ts # Server entry point, graceful shutdown
│ │ │ ├── config.ts # Zod-validated env config (60+ fields)
│ │ │ ├── auth/ # Better Auth instance, role service, OIDC seeding
│ │ │ ├── db/ # Drizzle connection + schema definitions
│ │ │ ├── email/ # SMTP sending + inquiry templates
│ │ │ ├── lib/ # Shared helpers (cursor, file utils, OpenAPI errors)
│ │ │ ├── logging/ # Structured pino logger
│ │ │ ├── middleware/ # Auth, CORS, error handling, rate limiting, request logging
│ │ │ ├── routes/ # Domain-grouped route handlers
│ │ │ ├── scripts/ # Seed scripts (admin, demo data)
│ │ │ ├── services/ # Business logic (Stripe, Shopify, SRS, emissions, creators)
│ │ │ └── storage/ # Pluggable StorageProvider (local or S3)
│ │ ├── tests/ # API tests
│ │ └── drizzle/ # SQL migrations
│ ├── e2e/ # Playwright end-to-end tests
│ └── web/ # TanStack Start frontend
│ ├── src/
│ │ ├── routes/ # File-based routes
│ │ ├── components/ # React components by domain
│ │ ├── config/ # Navigation links + route config
│ │ ├── contexts/ # Audio player + upload contexts
│ │ ├── hooks/ # Shared hooks (pagination, auth, media, forms)
│ │ ├── lib/ # API clients, auth, formatting utilities
│ │ └── styles/ # Global CSS + shared CSS modules
│ └── tests/ # Web tests
├── packages/
│ └── shared/ # Zod schemas, types, error classes, Result<T,E>
│ ├── src/
│ └── tests/
├── deploy/ # Example production configs (Caddy, systemd)
├── docs/ # Platform documentation
├── docker-compose.yml # PostgreSQL 16
├── ecosystem.config.cjs # PM2 process config
├── tsconfig.json # Root TypeScript config
├── Caddyfile.dev # Dev reverse proxy (localhost ports)
├── .env.example # Environment template
└── CLAUDE.md # Coding conventions
CI workflows live in .forgejo/workflows/. test-and-build.yml is project-generic; deploy workflows (deploy-prod.yml, deploy-demo.yml) are environment-specific and require Forgejo secrets to be configured.
Prerequisites
- Node.js >= 24.0.0
- Bun >= 1.3.0
- Docker (for PostgreSQL)
- Caddy (optional — reverse proxy for unified
:3080entry point; without it, access API on:3000and web on:3001directly)
Getting Started
1. Install dependencies
bun install
2. Start PostgreSQL
docker compose up -d
This starts a PostgreSQL 16 container (snc-postgres) on port 5432 with user snc, password snc, database snc.
3. Configure environment
cp .env.example .env
Edit .env and add the required secrets:
# Required — copy these and fill in real values
DATABASE_URL=postgres://snc:snc@localhost:5432/snc
PORT=3000
CORS_ORIGIN=http://localhost:3080
# Auth — generate a secret with: openssl rand -base64 32
BETTER_AUTH_SECRET=<your-32+-char-secret>
BETTER_AUTH_URL=http://localhost:3080
# Storage (local for dev, s3 for production)
STORAGE_TYPE=local
STORAGE_LOCAL_DIR=./uploads
# S3-compatible storage (required when STORAGE_TYPE=s3)
# S3_ENDPOINT=https://your-s3-endpoint
# S3_REGION=garage
# S3_BUCKET=your-bucket
# S3_ACCESS_KEY_ID=your-access-key
# S3_SECRET_ACCESS_KEY=your-secret-key
# Stripe (optional) — get from https://dashboard.stripe.com/apikeys
# Subscription/billing features return 503 when not set. The rest of the app works.
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_...
# Shopify (optional) — get from Shopify Admin → Apps → Headless
# Merch features return 503 when not set. The rest of the app works.
# SHOPIFY_STORE_DOMAIN=your-store.myshopify.com
# SHOPIFY_STOREFRONT_TOKEN=your_storefront_access_token
The frontend reads VITE_API_URL from apps/web/.env.local (defaults to http://localhost:3000 if not set):
# apps/web/.env.local (optional)
VITE_API_URL=http://localhost:3000
4. Run database migrations
bun run --filter @snc/api db:migrate
4b. Seed demo data (optional)
To populate the database with demo creators, content, users, and subscription plans:
bun run --filter @snc/api seed:demo
This creates three demo users (admin, stakeholder, subscriber) with sample content — useful for seeing the full app working. Skip this if you prefer to start with an empty database and seed manually (see Seeding Data below).
5. Start development servers
Start all three processes — API, web, and Caddy reverse proxy:
# Terminal 1: API server (port 3000)
bun run --filter @snc/api dev
# Terminal 2: Web server (port 3001)
bun run --filter @snc/web dev
# Terminal 3: Caddy reverse proxy (port 3080)
caddy run --config Caddyfile.dev
Or start API + web together with bun run dev, plus Caddy in a separate terminal. There's also bun run start which runs Docker, installs dependencies, migrates, and starts dev servers in one command (still needs .env configured first, and doesn't start Caddy).
The API runs with --watch for automatic restarts. The web server uses Vite HMR. Caddy routes /api, /health, and /uploads to the API and everything else to the web server.
6. Verify it's working
- App: http://localhost:3080 (through Caddy — use this in the browser)
- API health: http://localhost:3080/health (or directly at http://localhost:3000/health)
- API docs: http://localhost:3000/api/docs (Scalar UI — direct access)
- OpenAPI spec: http://localhost:3000/api/openapi.json
Running Tests
# All unit tests
bun run test:unit
# By workspace
bun run --filter @snc/api test
bun run --filter @snc/web test
bun run --filter @snc/shared test
# Watch mode
bun run --filter @snc/api test -- --watch
Unit tests mock all external services (Stripe, Shopify, database). No running services are needed.
E2E tests use Playwright against a running dev environment. Install browsers first (one-time):
bunx playwright install
bun run --filter @snc/e2e test
Available Scripts
| Command | Description |
|---|---|
bun run dev |
Start API + web dev servers |
bun run test:unit |
Run all unit tests |
bun run build |
Build all workspaces |
bun run typecheck |
TypeScript type checking |
bun run lint |
Lint all workspaces (via tsc --noEmit) |
bun run --filter @snc/api db:generate |
Generate new Drizzle migration |
bun run --filter @snc/api db:migrate |
Apply pending migrations |
Environment Variables Reference
Required
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
BETTER_AUTH_SECRET |
Auth secret, min 32 characters (openssl rand -base64 32) |
Optional (with defaults)
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
API server port |
LOG_LEVEL |
info |
pino log level (debug, info, warn, error) |
CORS_ORIGIN |
http://localhost:3080 |
Allowed CORS origin(s), comma-separated |
BETTER_AUTH_URL |
http://localhost:3080 |
Auth service base URL |
STORAGE_TYPE |
local |
Storage backend (local or s3) |
STORAGE_LOCAL_DIR |
./uploads |
Upload directory for local storage |
S3_ENDPOINT |
— | S3-compatible endpoint URL (required when STORAGE_TYPE=s3) |
S3_REGION |
garage |
S3 region |
S3_BUCKET |
— | S3 bucket name |
S3_ACCESS_KEY_ID |
— | S3 access key |
S3_SECRET_ACCESS_KEY |
— | S3 secret key |
STRIPE_SECRET_KEY |
— | Stripe API key (billing returns 503 without it) |
STRIPE_WEBHOOK_SECRET |
— | Stripe webhook secret (billing returns 503 without it) |
SHOPIFY_STORE_DOMAIN |
— | Shopify .myshopify.com domain (merch returns 503 without it) |
SHOPIFY_STOREFRONT_TOKEN |
— | Shopify Storefront API token (merch returns 503 without it) |
SRS_API_URL |
— | SRS streaming server API URL (e.g., http://srs-ip:1985) |
SRS_HLS_URL |
— | SRS HLS base URL (e.g., https://stream.s-nc.tv/live) |
PLAYOUT_STREAM_KEY |
— | Stream key for Liquidsoap playout (validated by on_publish callback) |
LIQUIDSOAP_API_URL |
— | Liquidsoap control API URL |
LIQUIDSOAP_RTMP_URL |
rtmp://snc-liquidsoap:1936/live/stream |
Liquidsoap RTMP ingest URL |
SRS_CALLBACK_SECRET |
— | Shared secret for SRS HTTP callback authentication |
MEDIA_TEMP_DIR |
/tmp/snc-media |
Temp directory for media processing jobs |
MEDIA_FFMPEG_CONCURRENCY |
2 |
Max parallel FFmpeg processes for job queue |
FEDERATION_DOMAIN |
s-nc.org |
ActivityPub federation domain |
SEAFILE_OIDC_CLIENT_ID |
— | Seafile OIDC client ID (OIDC provider inactive without it) |
SEAFILE_OIDC_CLIENT_SECRET |
— | Seafile OIDC client secret |
SMTP_HOST |
— | SMTP server (email features degrade gracefully without it) |
SMTP_PORT |
587 |
SMTP port |
SMTP_USER |
— | SMTP username |
SMTP_PASS |
— | SMTP password |
EMAIL_FROM |
S/NC <noreply@s-nc.org> |
Sender address for outbound email |
STUDIO_INQUIRY_EMAIL |
— | Email address for studio booking inquiries |
FEATURE_* |
true |
Feature flags — see Feature Flags |
VITE_API_URL |
http://localhost:3000 |
API URL for the frontend (in apps/web/.env.local) |
API Endpoints
The API serves OpenAPI 3.1 documentation at /api/openapi. Key endpoint groups:
| Group | Base Path | Auth | Description |
|---|---|---|---|
| Health | GET /api/health |
Public | Health check |
| Auth | /api/auth/* |
Public | Sign up, sign in, sign out (Better Auth) |
| Me | GET /api/me |
Authenticated | Current user + session + roles |
| Admin | /api/admin/* |
Admin | User management, role assignment |
| Content | /api/content/* |
Mixed | CRUD, upload, feed, media streaming |
| Creators | /api/creators/* |
Mixed | Profiles, avatar/banner, team members, events |
| Projects | /api/projects/* |
Mixed | Creator project/series management |
| Calendar | /api/calendar/* |
Stakeholder | Cooperative calendar, event types, iCal feed |
| Subscriptions | /api/subscriptions/* |
Mixed | Plans, checkout, cancel |
| Webhooks | POST /api/webhooks/stripe |
Stripe signature | Payment event processing |
| Merch | /api/merch/* |
Public | Product listing, detail, checkout |
| Bookings | /api/bookings/* |
Mixed | Services, booking requests, review |
| Dashboard | /api/dashboard/* |
Stakeholder | Revenue, subscribers, bookings KPIs |
| Studio | /api/studio/* |
Stakeholder | Studio dashboard |
| Emissions | /api/emissions/* |
Stakeholder | Carbon tracking and reporting |
| Streaming | /api/streaming/* |
Mixed | SRS streaming, channels, playout, chat |
| Chat | /api/chat/* |
Mixed | WebSocket live chat for streams |
| Federation | /api/federation/* |
Public | ActivityPub federation endpoints |
| Upload | /api/upload/* |
Authenticated | Multipart/presigned upload handling |
User Roles
Platform roles are assigned via the user_roles join table. Users without a role are registered users; paying users with an active subscription are patrons. "Creator" is an entity type, not a role — users participate in creator teams (see Creators).
| Role | Capabilities |
|---|---|
stakeholder |
Cooperative member — access dashboard, calendar, revenue, booking management |
admin |
User management, role assignment, platform administration |
Seeding Data
After migrations, the database is empty. To test the full flow:
Subscription plans
Create Products and Prices in the Stripe Dashboard, then insert them:
INSERT INTO subscription_plans (id, name, type, interval, price_cents, currency, stripe_price_id, active)
VALUES
(gen_random_uuid(), 'All Access Monthly', 'platform', 'month', 999, 'usd', 'price_xxx', true),
(gen_random_uuid(), 'All Access Yearly', 'platform', 'year', 9999, 'usd', 'price_yyy', true);
Services
INSERT INTO services (id, name, description, pricing_info, active, sort_order)
VALUES
(gen_random_uuid(), 'Recording Session', 'Professional studio recording.', '$50/hour', true, 1),
(gen_random_uuid(), 'Mixing & Mastering', 'Full mix and master.', '$200/track', true, 2),
(gen_random_uuid(), 'Label Services', 'Distribution and licensing.', 'Contact for pricing', true, 3);
Stripe Webhook Testing
To test webhooks locally, install the Stripe CLI and forward events:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The webhook handler processes these events:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
Shopify Merch Setup
Merch is optional. To enable it:
- Set
SHOPIFY_STORE_DOMAINandSHOPIFY_STOREFRONT_TOKENin.env - In Shopify Admin, tag products with
snc-creator:<userId>to associate them with creators - Set the
vendorfield to the creator's display name
Without Shopify credentials, the merch endpoints return 503 "MERCH_NOT_CONFIGURED" — the rest of the app works normally.
Project Conventions
- Named exports only — no default exports
- Strict TypeScript —
strict: true,noUncheckedIndexedAccess,exactOptionalPropertyTypes - File naming —
kebab-case.ts(e.g.,booking-form.tsx,content.routes.ts) - Error handling — Typed
AppErrorsubclasses,Result<T, E>for service functions - CSS — CSS Modules with design tokens from
:rootcustom properties inglobal.css - Validation — Zod schemas on every route handler via
zValidator;zod/minion the frontend - Testing — Vitest with fixture factories (
makeMock*pattern), mocked external services
See CLAUDE.md for the full coding conventions reference.
Architecture Notes
- Monorepo — Bun workspaces, no Turborepo/Nx. Root scripts run across all packages.
- Shared package —
@snc/sharedcontains all Zod schemas, TypeScript types, error classes, and theResult<T, E>type. Both API and web import from it for end-to-end type safety. - Storage abstraction — The
StorageProviderinterface supports local filesystem (dev) and S3-compatible storage (production) without changing route handlers. - Content gating —
checkContentAccess()middleware enforces 5 priority rules (public, unauth, owner bypass, active subscription, reject) for subscription-based access control. - Cursor pagination — Keyset pagination via base64-encoded cursors, with a reusable
useCursorPagination<T>hook on the frontend. - No external charting library — The dashboard revenue chart is pure CSS.
Platform Documentation
Domain-specific documentation lives in docs/:
| Document | Description |
|---|---|
| Admin | User management and role assignment |
| Auth | Authentication, sessions, OIDC provider, roles |
| Calendar | Cooperative calendar, iCal feed |
| Content | Publishing lifecycle, storage backends, access gating |
| Creators | Creator profiles, team membership |
| Feature Flags | Domain-level feature flag system |
License
Code and scripts are licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-or-later).
Documentation (README, CLAUDE.md, and other written materials) is licensed under Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0).