Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,23 @@ GITTENSORY_REVIEW_DRAFT=false
# --- Gittensory Orb (#1219; opt-in outcome signal collection) ---
# Run GET /orb/setup to create the Orb GitHub App (read-only; separate from the main App).
# Credentials are written to /data/gittensory-orb.env on callback — load them here.
#
# SECURITY MODEL (this image is meant to be self-hosted by many independent maintainers):
# • The image bakes NO secrets. Every operator creates their OWN Orb App via /orb/setup, so the
# ORB_* secrets below are unique to YOUR instance and live only in YOUR /data — never shared,
# never sent to the collector. gittensory's own App secrets are never in the image.
# • Export to the central collector uses NO shared key: repo/PR identifiers are HMAC-anonymized
# with YOUR ORB_WEBHOOK_SECRET (so even the collector operator can't de-anonymize them), and the
# collector accepts the batch as untrusted, rate-limited, aggregate-only telemetry. Nothing in the
# container, if leaked, can compromise the collector, other operators, or the main App.
# • Prefer the *_FILE convention for the private key (mount a file, not an inline env value):
# ORB_PRIVATE_KEY_FILE=/run/secrets/orb_private_key → the server reads it into ORB_PRIVATE_KEY.
# ORB_APP_ID= # App ID from /orb/setup callback
# ORB_APP_SLUG= # App slug (human-readable name)
# ORB_WEBHOOK_SECRET= # secret from /orb/setup callback — signs /orb/webhook requests
# ORB_PRIVATE_KEY= # PEM from /orb/setup callback (JSON-stringified)
# ORB_WEBHOOK_SECRET= # secret from /orb/setup callback — signs /orb/webhook + anonymizes export
# ORB_PRIVATE_KEY= # PEM from /orb/setup callback (JSON-stringified); prefer ORB_PRIVATE_KEY_FILE
# ORB_ENABLED=false # master switch: set to true to enable collection (default off)
# ORB_AIR_GAP=false # set to true to keep all data local — never send to the collector
# ORB_ANONYMIZE=true # HMAC-hash repo names before export (default true; false = raw names)
# ORB_COLLECTOR_URL=https://orb.gittensory.app/v1/ingest # central collector URL (set by default; override as needed)
# ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own)
# ORB_SETUP_OUTPUT_PATH=/data/gittensory-orb.env # where /orb/setup/callback writes the credentials file
3 changes: 3 additions & 0 deletions src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export async function enforceRateLimit(c: Context<{ Bindings: Env }>, routeClass

export function routeClassForPath(path: string): RateLimitClass {
if (path === "/v1/github/webhook") return "strict";
// Orb telemetry ingest: unauthenticated + write, accepting anonymized batches from untrusted
// self-host instances. Strict (10/min per IP) caps abuse — legitimate instances export hourly.
if (path === "/v1/orb/ingest") return "strict";
if (path === "/v1/auth/session" || path === "/v1/auth/logout") return "normal";
if (path.startsWith("/v1/auth/")) return "strict";
if (
Expand Down
7 changes: 5 additions & 2 deletions src/selfhost/orb-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//
// Collection is always local (DB only). Export to the central collector is opt-in:
// ORB_ENABLED=true — activates collection (off by default)
// ORB_COLLECTOR_URL=<url> — endpoint to export batches to (default: https://orb.gittensory.app/v1/ingest)
// ORB_COLLECTOR_URL=<url> — endpoint to export batches to (default: gittensory's hosted collector)
// ORB_AIR_GAP=true — keep all events local, never send externally
// ORB_ANONYMIZE=true — HMAC-hash repo/owner before export (default: true)
//
Expand Down Expand Up @@ -92,7 +92,10 @@ export async function exportOrbBatch(
if (!orbEnabled()) return 0;
if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0;

const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://orb.gittensory.app/v1/ingest";
// gittensory's hosted collector (the deployed /v1/orb/ingest receiver). No shared secret is sent:
// the batch is anonymized (HMAC of each operator's OWN ORB_WEBHOOK_SECRET) and accepted as untrusted,
// rate-limited telemetry. Override only to point at your own self-hosted collector.
const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://gittensory-api.aethereal.dev/v1/orb/ingest";
const secret = process.env.ORB_WEBHOOK_SECRET ?? "";
const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false";

Expand Down
1 change: 1 addition & 0 deletions test/unit/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe("private-beta auth and rate limiting", () => {

it("classifies rate-limit route costs", () => {
expect(routeClassForPath("/v1/github/webhook")).toBe("strict");
expect(routeClassForPath("/v1/orb/ingest")).toBe("strict"); // open telemetry ingest — abuse-capped per IP
expect(routeClassForPath("/v1/auth/github/device/start")).toBe("strict");
expect(routeClassForPath("/v1/local/branch-analysis")).toBe("expensive");
expect(routeClassForPath("/v1/scoring/preview")).toBe("expensive");
Expand Down
9 changes: 9 additions & 0 deletions test/unit/selfhost-orb-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ describe("exportOrbBatch()", () => {
expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0);
});

it("defaults to gittensory's hosted collector URL when ORB_COLLECTOR_URL is unset (regression: dead orb.gittensory.app)", async () => {
delete process.env.ORB_COLLECTOR_URL;
const db = makeDb();
await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha", outcome: "merged" });
let capturedUrl: string | undefined;
await exportOrbBatch(db, 200, async (url) => { capturedUrl = String(url); return new Response(null, { status: 200 }); });
expect(capturedUrl).toBe("https://gittensory-api.aethereal.dev/v1/orb/ingest");
});

it("exports pending events and marks them as exported", async () => {
const db = makeDb();
await recordOrbEvent(db, { repo: "owner/repo", pr_number: 1, head_sha: "sha1", outcome: "merged", gate_verdict: "approve" });
Expand Down
Loading