Skip to content
Closed
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
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,16 @@ GITTENSORY_REVIEW_DRAFT=false
# --- Gittensory Orb (#1255; opt-in fleet-calibration export) ---
# Orb is the central collector + analytics that aggregates anonymized gate-calibration data UP from
# self-hosted instances. There is NO separate Orb GitHub App and NO setup wizard: your existing main App
# already records de-noised outcomes (merged/closed + reversals) locally — flip ORB_ENABLED to ship an
# anonymized signal to gittensory's collector. That's it: no second App, no extra secret, no wizard.
# already records de-noised outcomes (merged/closed + reversals) locally — set ORB_ENABLED plus a
# stable per-instance ORB_WEBHOOK_SECRET to ship an anonymized signal to gittensory's collector.
#
# SECURITY MODEL (this image is self-hosted by many independent maintainers):
# • The image bakes NO secrets. repo/PR identifiers are HMAC-anonymized with YOUR own ORB_WEBHOOK_SECRET
# (a stable per-instance string), so even gittensory (running the collector) can never de-anonymize them.
# • Export carries NO shared key. 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 any App.
# ORB_ENABLED=false # master switch: set to true to export fleet-calibration signal (default off)
# ORB_WEBHOOK_SECRET=<stable-random-string> # the per-instance HMAC key used to anonymize repo/PR identifiers
# ORB_WEBHOOK_SECRET=<32+ char stable random string> # required when ORB_ANONYMIZE=true; per-instance HMAC key
# ORB_AIR_GAP=false # set to true to compute locally but never send to the collector
# ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names)
# ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own)
4 changes: 4 additions & 0 deletions src/selfhost/orb-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
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";
if (anonymize && secret.trim().length < 32) {
incr("gittensory_orb_export_errors_total", { reason: "missing_anonymization_secret" });
return 0;
}
const instance = instanceId();

// Read this instance's export watermark (resumes where the last run left off).
Expand Down
29 changes: 26 additions & 3 deletions test/unit/selfhost-orb-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-awa
beforeEach(() => {
resetMetrics();
process.env.ORB_ENABLED = "true";
process.env.ORB_WEBHOOK_SECRET = "test-secret";
process.env.ORB_WEBHOOK_SECRET = "test-secret-at-least-32-bytes-long";
process.env.ORB_APP_ID = "555";
process.env.ORB_ANONYMIZE = "true";
delete process.env.ORB_AIR_GAP;
Expand Down Expand Up @@ -164,9 +164,32 @@ describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-awa
expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/);
});

it("falls back to GITHUB_APP_ID for the instance id and applies secret/anonymize defaults when ORB_* are unset", async () => {
it("fails closed when anonymized export has no strong per-instance secret", async () => {
delete process.env.ORB_WEBHOOK_SECRET;
delete process.env.ORB_ANONYMIZE; // → defaults to "true"
const db = makeDb();
await audit(db, "owner/repo", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z");
await audit(db, "owner/repo", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z");
let called = false;
const n = await exportOrbBatch(db, 200, async () => { called = true; return new Response(null, { status: 200 }); });
expect(n).toBe(0);
expect(called).toBe(false);
expect(await renderMetrics()).toContain(`gittensory_orb_export_errors_total{reason="missing_anonymization_secret"} 1`);
});

it("fails closed when anonymized export has a weak per-instance secret", async () => {
process.env.ORB_WEBHOOK_SECRET = "short-secret";
const db = makeDb();
await audit(db, "owner/repo", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z");
await audit(db, "owner/repo", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z");
let called = false;
const n = await exportOrbBatch(db, 200, async () => { called = true; return new Response(null, { status: 200 }); });
expect(n).toBe(0);
expect(called).toBe(false);
});

it("falls back to GITHUB_APP_ID for the instance id while using a configured anonymization secret", async () => {
delete process.env.ORB_APP_ID; // → falls through to GITHUB_APP_ID
delete process.env.ORB_WEBHOOK_SECRET; // → secret defaults to ""
delete process.env.ORB_ANONYMIZE; // → defaults to "true"
(process.env as NodeJS.Dict<string>).GITHUB_APP_ID = "999";
const db = makeDb();
Expand Down
Loading