Static-apps deploy proxy for the freeCodeCamp Universe platform. Public hostname: uploads.freecode.camp.
Staff devs and CI run universe deploy and the artifact lands on R2 behind a Caddy r2_alias upstream. Zero R2 tokens leak into staff hands or CI secrets — Artemis is the sole holder of the admin S3 token. Identity is GitHub team membership.
POST /api/deploy/init { site, sha, files? } → { deployId, jwt, expiresAt }
PUT /api/deploy/{deployId}/upload multipart stream → { received }
POST /api/deploy/{deployId}/finalize { mode } → { url }
POST /api/site/{site}/promote → { url }
POST /api/site/{site}/rollback { to } → { url }
GET /api/site/{site}/deploys → [{ deployId, ts, sha, size }]
GET /api/whoami → { login, authorizedSites }
GET /healthz → { ok: true }
Auth headers (/api/* except /healthz):
| Endpoint | Bearer |
|---|---|
POST /api/deploy/init, POST /api/site/*, GET /api/* |
GitHub token (PAT / OIDC) |
PUT /api/deploy/{deployId}/upload, POST /api/deploy/{deployId}/finalize |
Deploy-session JWT (HS256, ≤15 min, scoped to one (login, site, deployId)) |
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP listen port |
R2_ENDPOINT |
(required) | https://<account>.r2.cloudflarestorage.com |
R2_ACCESS_KEY_ID |
(required) | Admin S3 key |
R2_SECRET_ACCESS_KEY |
(required) | Admin S3 secret |
R2_BUCKET |
universe-static-apps-01 |
Single shared bucket (prefix-scoped per site) |
GH_CLIENT_ID |
(required) | GitHub OAuth app client ID (CLI device flow) |
GH_ORG |
freeCodeCamp |
GitHub org for team probes |
GH_API_BASE |
https://api.github.com |
GitHub REST API base |
VALKEY_ADDR |
(required) | Valkey host:port for the sites registry |
VALKEY_PASSWORD |
(required) | Valkey AUTH password |
REGISTRY_AUTHZ_TEAM |
staff |
GH team allowed to mutate the sites registry |
JWT_SIGNING_KEY |
(required) | 32-byte random; mounted from k8s Secret |
JWT_TTL_SECONDS |
900 |
Deploy-session JWT TTL (15 min) |
GH_MEMBERSHIP_CACHE_TTL |
300 |
GH /user + team membership cache TTL (5 min) |
ALIAS_PRODUCTION_KEY_FORMAT |
<site>/production |
R2 alias key for production env |
ALIAS_PREVIEW_KEY_FORMAT |
<site>/preview |
R2 alias key for preview env |
DEPLOY_PREFIX_FORMAT |
<site>/deploys/<ts>-<sha>/ |
R2 prefix per immutable deploy |
LOG_LEVEL |
info |
debug, info, warn, error |
<bucket>/
└── <site>/
├── deploys/
│ ├── 20260420-141522-abc1234/ # immutable
│ │ ├── index.html
│ │ └── ...
│ └── 20260421-091807-def5678/
├── preview # alias → "deploys/20260421-091807-def5678"
└── production # alias → "deploys/20260420-141522-abc1234"
Atomic alias semantics: PutObject is atomic per-key in R2. Old deploy keeps serving until the alias PUT lands. Verify-then-PUT order means a partial deploy never becomes live.
Authoritative store: Valkey (VALKEY_ADDR, namespace valkey). Each entry maps a site slug to the list of GitHub teams whose members may deploy to that site. Mutations go through the registry endpoints:
POST /api/site/register { slug, teams? } → 201 SiteRow
GET /api/sites [?slug=…] → { count, sites: [SiteRow] }
PATCH /api/site/{slug} { teams } → 200 SiteRow
DELETE /api/site/{slug} → 204
Write endpoints are gated on REGISTRY_AUTHZ_TEAM (default staff). The read endpoint is open to any GitHub bearer.
Operator-facing CLI surface (universe-cli ≥ 0.5.0):
universe sites register <slug> --team <team>[,<team>...]
universe sites update <slug> --team <team>[,<team>...]
universe sites rm <slug>
universe sites ls [--mine]Mutations propagate to every artemis replica via the registry.changed pub-sub channel within seconds, or ≤ 60 s on the TTL fallback.
See config/sites.yaml.example for the on-disk schema shape. The live registry is Valkey; the on-disk YAML form is not consumed at runtime.
cp .env.example .env # then fill values
make run # boots HTTP server on $PORT
make test # go test ./... -cover (unit only)
make image # docker buildEnd-to-end suite under internal/integration/. Build-tagged behind integration so it stays out of make test. Hits a live, deployed artemis over HTTPS and exercises the full deploy lifecycle:
healthz → whoami → init → upload → finalize(preview) → curl preview
→ promote → curl production → list deploys → rollback
Plus negative-path coverage (bad token → 401, missing token → 401, unknown site → 403, missing required field → 400).
ARTEMIS_URL=https://uploads.freecode.camp \
GH_TOKEN=$(gh auth token) \
SITE=test ROOT_DOMAIN=freecode.camp \
make integrationmake integration-help prints the full env-var reference. The suite is safe to run against production — it writes only under the test site (a staff-only smoke target registered in the artemis registry) and relies on the cleanup cron (7-day retention) for prefix GC.
Suite-level (TestMain in setup_teardown_test.go):
| Phase | Action |
|---|---|
| Setup | Pre-flight GET /healthz — abort with exit 2 if artemis unreachable |
| Setup | Capture baseline production deploy id for SITE from /api/site/{site}/deploys |
| Run | m.Run() — execute every test in the package |
| Teardown | Restore production alias to the captured baseline via /rollback |
Per-test (t.Cleanup in tests that mint deploys):
| Test | Cleanup |
|---|---|
TestDeployFlow |
Logs the new deploy id at end (success or failure) so the artifact is visible in test output. R2 prefix sweep is owned by the cleanup cron — the suite intentionally does not call a delete API (none exists; deploys are immutable by design). |
TestRollback |
None per-test — suite teardown handles prod alias restore |
If teardown's restore call fails, TestMain logs the manual fix:
[teardown] WARN: restore prod alias failed: ...
[teardown] manual fix: POST /api/site/test/rollback {"to":"<baselineDeployID>"}
Edge cases:
- Fresh site (no deploys): baseline capture returns empty; teardown is a no-op.
- Env unset:
TestMainskips capture/teardown; testsSkipthemselves. - Healthz down:
TestMainaborts before any test runs (exit 2).
| Variable | Default | Purpose |
|---|---|---|
ARTEMIS_URL |
(required) | Live artemis base URL, no trailing slash |
GH_TOKEN |
(required) | GitHub bearer authorized for SITE |
SITE |
test |
Registered site slug |
ROOT_DOMAIN |
freecode.camp |
Root domain for preview/production URL derive |
PROD_SLO |
2m |
Production-alias serve SLO |
PREVIEW_SLO |
90s |
Preview-alias serve SLO |
HTTP_TIMEOUT |
30s |
Per-request HTTP timeout |
# init a deploy
curl -X POST https://uploads.freecode.camp/api/deploy/init \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"site":"www","sha":"abc1234"}'
# → { "deployId": "20260420-141522-abc1234", "jwt": "<deploy-session-jwt>", "expiresAt": "..." }
# upload a file (deploy-session JWT)
curl -X PUT "https://uploads.freecode.camp/api/deploy/20260420-141522-abc1234/upload?path=index.html" \
-H "Authorization: Bearer $DEPLOY_JWT" \
--data-binary @index.html
# finalize → atomic alias
curl -X POST https://uploads.freecode.camp/api/deploy/20260420-141522-abc1234/finalize \
-H "Authorization: Bearer $DEPLOY_JWT" \
-H "Content-Type: application/json" \
-d '{"mode":"preview"}'
# → { "url": "https://www.preview.freecode.camp" }
# promote preview → production
curl -X POST https://uploads.freecode.camp/api/site/www/promote \
-H "Authorization: Bearer $GITHUB_TOKEN"
# rollback production
curl -X POST https://uploads.freecode.camp/api/site/www/rollback \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"to":"20260419-110000-old1234"}'
# whoami
curl https://uploads.freecode.camp/api/whoami -H "Authorization: Bearer $GITHUB_TOKEN"
# → { "login": "octocat", "authorizedSites": ["www","learn"] }BSD-3-Clause — see LICENSE.