Commit055ebc4unknown_key
feat(BLOCK-K): K5 fix agent + K7 deploy watcher + K10 marketplace + K11 cross-product identity
feat(BLOCK-K): K5 fix agent + K7 deploy watcher + K10 marketplace + K11 cross-product identity Wave 3 of the autonomous agent loop: - K5 fix-agent: calls Gatetest runAndRepair on PR failing checks, posts structured repair-proposal PR comment. 3¢/run. - K7 deploy-watcher: watches Crontech deployments, counts error-severity prod signals, auto-rollback + P0 incident issue when threshold hit. 2¢/run. - K10 agent marketplace: publisher-curated directory of installable K-agents, reuses K2/Block H agent-identity for the install flow. Public directory, detail, install/uninstall, publisher dashboard, site-admin publish/unpublish. Migration 0037. - K11 cross-product identity: signs short-lived HS256 JWTs so a single gluecron user token authenticates against Crontech + Gatetest. Audit log every mint, revocation list, settings UI. Migration 0038. Schema.ts: marketplaceAgentListings + crossProductTokens tables. app.tsx: agentMarketplaceRoutes mounted before adminRoutes and marketplaceRoutes to avoid /marketplace/:slug and /admin generic routes swallowing the agent-specific paths. Tests: 1067/1067 pass (+102 from Wave 3 — K5: 17, K7: 23, K10: 27, K11: 35). https://claude.ai/code/session_019KmMAXLy6fvcXjmGyepeCT
14 files changed+4025−0055ebc405d54f4c325d336539c13f3c18c9f54f9
14 changed files+4025−0
Addeddrizzle/0037_marketplace_agent_listings.sql+28−0View fileUnifiedSplit
@@ -0,0 +1,28 @@
1-- Block K10 — Agent marketplace listings.
2-- Publisher-curated directory of installable K-agents. Each listing references
3-- an existing agent app/bot (from Block H/K2) so installs reuse the mature
4-- agent-identity flow (`installAgentForRepo`, `issueAgentToken`, `uninstallAgent`).
5
6CREATE TABLE IF NOT EXISTS marketplace_agent_listings (
7 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
8 slug text NOT NULL UNIQUE,
9 name text NOT NULL,
10 tagline text NOT NULL,
11 description text NOT NULL DEFAULT '',
12 publisher_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
13 app_bot_id uuid NOT NULL REFERENCES app_bots(id) ON DELETE CASCADE,
14 kind text NOT NULL, -- one of: triage, fix, review, heal_bot, deploy_watch, custom
15 homepage_url text,
16 icon_url text,
17 pricing_cents_per_month integer NOT NULL DEFAULT 0,
18 published boolean NOT NULL DEFAULT false,
19 install_count integer NOT NULL DEFAULT 0,
20 created_at timestamptz NOT NULL DEFAULT now(),
21 updated_at timestamptz NOT NULL DEFAULT now()
22);
23
24CREATE INDEX IF NOT EXISTS marketplace_agent_listings_published_idx
25 ON marketplace_agent_listings (published, install_count DESC);
26
27CREATE INDEX IF NOT EXISTS marketplace_agent_listings_publisher_idx
28 ON marketplace_agent_listings (publisher_user_id);
Addeddrizzle/0038_cross_product_tokens.sql+23−0View fileUnifiedSplit
@@ -0,0 +1,23 @@
1-- Block K11 — Cross-product identity
2-- Tracks short-lived JWTs minted by gluecron (IdP) for sibling products
3-- (Crontech, Gatetest). Row per mint is used for:
4-- * revocation (revoked_at IS NOT NULL => fail verify)
5-- * replay audit (jti is the JWT id)
6-- * settings UI listing (active per user)
7
8CREATE TABLE IF NOT EXISTS cross_product_tokens (
9 jti uuid PRIMARY KEY,
10 user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11 audience text NOT NULL,
12 scopes text NOT NULL DEFAULT '[]',
13 issued_at timestamptz NOT NULL DEFAULT now(),
14 expires_at timestamptz NOT NULL,
15 revoked_at timestamptz
16);
17
18CREATE INDEX IF NOT EXISTS cross_product_tokens_user_idx
19 ON cross_product_tokens (user_id, issued_at DESC);
20
21CREATE INDEX IF NOT EXISTS cross_product_tokens_active_idx
22 ON cross_product_tokens (audience, expires_at)
23 WHERE revoked_at IS NULL;
Addedsrc/__tests__/agent-marketplace.test.ts+251−0View fileUnifiedSplit
@@ -0,0 +1,251 @@
1/**
2 * Block K10 — Agent Marketplace tests.
3 *
4 * Pure form-parser tests are fully exercised. Route smokes only assert auth
5 * behaviour — DB-backed flows live in the integration suite.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import {
11 parseListingForm,
12 ALLOWED_LISTING_KINDS,
13 SLUG_RE,
14 TAGLINE_MAX,
15 DESCRIPTION_MAX,
16 PRICING_MAX_CENTS,
17 type ListingFormInput,
18} from "../routes/agent-marketplace";
19
20// ---------------------------------------------------------------------------
21// parseListingForm
22// ---------------------------------------------------------------------------
23
24describe("agent-marketplace — parseListingForm", () => {
25 const validInput: ListingFormInput = {
26 slug: "my-agent",
27 name: "My Agent",
28 tagline: "It does things.",
29 description: "Hello world.",
30 kind: "triage",
31 homepage_url: "https://example.com",
32 icon_url: "https://example.com/icon.png",
33 pricing_cents_per_month: "500",
34 };
35
36 it("accepts a fully valid form", () => {
37 const r = parseListingForm(validInput);
38 expect(r.ok).toBe(true);
39 if (r.ok) {
40 expect(r.data.slug).toBe("my-agent");
41 expect(r.data.name).toBe("My Agent");
42 expect(r.data.kind).toBe("triage");
43 expect(r.data.pricingCentsPerMonth).toBe(500);
44 expect(r.data.homepageUrl).toBe("https://example.com");
45 }
46 });
47
48 it("rejects slug shorter than 3 chars", () => {
49 const r = parseListingForm({ ...validInput, slug: "ab" });
50 expect(r.ok).toBe(false);
51 });
52
53 it("rejects slug starting with digit", () => {
54 const r = parseListingForm({ ...validInput, slug: "1abc" });
55 expect(r.ok).toBe(false);
56 });
57
58 it("rejects slug with uppercase", () => {
59 const r = parseListingForm({ ...validInput, slug: "MyAgent" });
60 expect(r.ok).toBe(false);
61 });
62
63 it("accepts slug with hyphens and digits", () => {
64 const r = parseListingForm({ ...validInput, slug: "my-agent-v2" });
65 expect(r.ok).toBe(true);
66 });
67
68 it("rejects empty name", () => {
69 const r = parseListingForm({ ...validInput, name: " " });
70 expect(r.ok).toBe(false);
71 });
72
73 it("rejects name over 100 chars", () => {
74 const r = parseListingForm({ ...validInput, name: "x".repeat(101) });
75 expect(r.ok).toBe(false);
76 });
77
78 it("rejects empty tagline", () => {
79 const r = parseListingForm({ ...validInput, tagline: "" });
80 expect(r.ok).toBe(false);
81 });
82
83 it("rejects tagline > TAGLINE_MAX", () => {
84 const r = parseListingForm({
85 ...validInput,
86 tagline: "x".repeat(TAGLINE_MAX + 1),
87 });
88 expect(r.ok).toBe(false);
89 });
90
91 it("silently truncates description to DESCRIPTION_MAX", () => {
92 const r = parseListingForm({
93 ...validInput,
94 description: "x".repeat(DESCRIPTION_MAX + 500),
95 });
96 expect(r.ok).toBe(true);
97 if (r.ok) {
98 expect(r.data.description.length).toBe(DESCRIPTION_MAX);
99 }
100 });
101
102 it("rejects unknown kind", () => {
103 const r = parseListingForm({ ...validInput, kind: "hackerman" });
104 expect(r.ok).toBe(false);
105 });
106
107 it("accepts every allowed kind", () => {
108 for (const k of ALLOWED_LISTING_KINDS) {
109 const r = parseListingForm({ ...validInput, kind: k });
110 expect(r.ok).toBe(true);
111 }
112 });
113
114 it("rejects negative pricing", () => {
115 const r = parseListingForm({
116 ...validInput,
117 pricing_cents_per_month: "-5",
118 });
119 expect(r.ok).toBe(false);
120 });
121
122 it("rejects non-numeric pricing", () => {
123 const r = parseListingForm({
124 ...validInput,
125 pricing_cents_per_month: "abc",
126 });
127 // parseInt -> NaN, isFinite false, rejected.
128 expect(r.ok).toBe(false);
129 });
130
131 it("caps pricing at PRICING_MAX_CENTS", () => {
132 const r = parseListingForm({
133 ...validInput,
134 pricing_cents_per_month: "999999999",
135 });
136 expect(r.ok).toBe(true);
137 if (r.ok) expect(r.data.pricingCentsPerMonth).toBe(PRICING_MAX_CENTS);
138 });
139
140 it("nullifies invalid homepage URLs", () => {
141 const r = parseListingForm({
142 ...validInput,
143 homepage_url: "javascript:alert(1)",
144 });
145 expect(r.ok).toBe(true);
146 if (r.ok) expect(r.data.homepageUrl).toBeNull();
147 });
148
149 it("keeps https homepage URLs", () => {
150 const r = parseListingForm({
151 ...validInput,
152 homepage_url: "https://safe.example.com/path",
153 });
154 expect(r.ok).toBe(true);
155 if (r.ok) expect(r.data.homepageUrl).toBe("https://safe.example.com/path");
156 });
157
158 it("default pricing is 0 when omitted", () => {
159 const { pricing_cents_per_month, ...rest } = validInput;
160 void pricing_cents_per_month;
161 const r = parseListingForm(rest as ListingFormInput);
162 expect(r.ok).toBe(true);
163 if (r.ok) expect(r.data.pricingCentsPerMonth).toBe(0);
164 });
165});
166
167// ---------------------------------------------------------------------------
168// Constants are sane
169// ---------------------------------------------------------------------------
170
171describe("agent-marketplace — constants", () => {
172 it("SLUG_RE matches typical slugs", () => {
173 expect(SLUG_RE.test("my-agent")).toBe(true);
174 expect(SLUG_RE.test("abc")).toBe(true);
175 expect(SLUG_RE.test("A")).toBe(false);
176 });
177 it("ALLOWED_LISTING_KINDS contains every expected kind", () => {
178 expect(ALLOWED_LISTING_KINDS).toContain("triage");
179 expect(ALLOWED_LISTING_KINDS).toContain("fix");
180 expect(ALLOWED_LISTING_KINDS).toContain("heal_bot");
181 expect(ALLOWED_LISTING_KINDS).toContain("deploy_watch");
182 });
183});
184
185// ---------------------------------------------------------------------------
186// Route auth smokes
187// ---------------------------------------------------------------------------
188
189describe("agent-marketplace — route auth smokes", () => {
190 it("GET /marketplace/agents (public) → 200", async () => {
191 const res = await app.fetch(new Request("http://test/marketplace/agents"));
192 expect(res.status).toBe(200);
193 });
194
195 it("POST /marketplace/agents/:slug/install without session → 302 /login", async () => {
196 const res = await app.fetch(
197 new Request("http://test/marketplace/agents/any/install", {
198 method: "POST",
199 headers: { "content-type": "application/x-www-form-urlencoded" },
200 body: new URLSearchParams({ repo_id: "x" }),
201 })
202 );
203 expect(res.status).toBe(302);
204 expect(res.headers.get("location")).toMatch(/\/login/);
205 });
206
207 it("POST /marketplace/agents/:slug/uninstall without session → 302 /login", async () => {
208 const res = await app.fetch(
209 new Request("http://test/marketplace/agents/any/uninstall", {
210 method: "POST",
211 })
212 );
213 expect(res.status).toBe(302);
214 expect(res.headers.get("location")).toMatch(/\/login/);
215 });
216
217 it("GET /settings/agent-listings without session → 302 /login", async () => {
218 const res = await app.fetch(
219 new Request("http://test/settings/agent-listings")
220 );
221 expect(res.status).toBe(302);
222 expect(res.headers.get("location")).toMatch(/\/login/);
223 });
224
225 it("POST /settings/agent-listings without session → 302 /login", async () => {
226 const res = await app.fetch(
227 new Request("http://test/settings/agent-listings", { method: "POST" })
228 );
229 expect(res.status).toBe(302);
230 expect(res.headers.get("location")).toMatch(/\/login/);
231 });
232
233 it("POST /settings/agent-listings/:id/publish without session → 302 /login", async () => {
234 const res = await app.fetch(
235 new Request(
236 "http://test/settings/agent-listings/00000000-0000-0000-0000-000000000000/publish",
237 { method: "POST" }
238 )
239 );
240 expect(res.status).toBe(302);
241 expect(res.headers.get("location")).toMatch(/\/login/);
242 });
243
244 it("GET /admin/marketplace/agents without session → 302 /login", async () => {
245 const res = await app.fetch(
246 new Request("http://test/admin/marketplace/agents")
247 );
248 expect(res.status).toBe(302);
249 expect(res.headers.get("location")).toMatch(/\/login/);
250 });
251});
Addedsrc/__tests__/agents/deploy-watcher.test.ts+327−0View fileUnifiedSplit
@@ -0,0 +1,327 @@
1/**
2 * Block K7 — deploy-watcher tests.
3 *
4 * Same shape as heal-bot.test.ts + fix-agent.test.ts: pure helpers exhaustively,
5 * entry-point arg validation, graceful-degradation when DB/Crontech offline.
6 */
7
8import {
9 afterEach,
10 beforeEach,
11 describe,
12 expect,
13 it,
14} from "bun:test";
15import {
16 DEPLOY_WATCHER_BOT_USERNAME,
17 DEPLOY_WATCHER_COST_CENTS,
18 DEPLOY_WATCHER_ERROR_THRESHOLD,
19 DEPLOY_WATCHER_SLUG,
20 DEPLOY_WATCHER_WINDOW_MS,
21 buildDeployWatcherSummary,
22 renderIncidentIssueBody,
23 runDeployWatcher,
24 shouldRollback,
25} from "../../lib/agents/deploy-watcher";
26import type { DeployWatchResult } from "../../lib/crontech-client";
27
28const ENV_KEYS = ["CRONTECH_API_KEY", "CRONTECH_BASE_URL"] as const;
29
30let savedEnv: Record<string, string | undefined> = {};
31let savedFetch: typeof fetch;
32
33beforeEach(() => {
34 savedEnv = {};
35 for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
36 for (const k of ENV_KEYS) delete process.env[k];
37 savedFetch = globalThis.fetch;
38});
39
40afterEach(() => {
41 for (const k of ENV_KEYS) {
42 const v = savedEnv[k];
43 if (v === undefined) delete process.env[k];
44 else process.env[k] = v;
45 }
46 globalThis.fetch = savedFetch;
47});
48
49// ---------------------------------------------------------------------------
50// Identity + constants
51// ---------------------------------------------------------------------------
52
53describe("deploy-watcher — identity constants", () => {
54 it("uses the agent- prefixed slug", () => {
55 expect(DEPLOY_WATCHER_SLUG).toBe("agent-deploy-watcher");
56 });
57 it("uses the [bot] suffixed username", () => {
58 expect(DEPLOY_WATCHER_BOT_USERNAME).toBe("agent-deploy-watcher[bot]");
59 expect(DEPLOY_WATCHER_BOT_USERNAME.endsWith("[bot]")).toBe(true);
60 });
61 it("cost is flat 2¢", () => {
62 expect(DEPLOY_WATCHER_COST_CENTS).toBe(2);
63 });
64 it("error threshold defaults to 5", () => {
65 expect(DEPLOY_WATCHER_ERROR_THRESHOLD).toBe(5);
66 });
67 it("watch window is 15 minutes", () => {
68 expect(DEPLOY_WATCHER_WINDOW_MS).toBe(15 * 60 * 1000);
69 });
70});
71
72// ---------------------------------------------------------------------------
73// shouldRollback
74// ---------------------------------------------------------------------------
75
76function mkWatch(status: DeployWatchResult["finalStatus"]): DeployWatchResult {
77 return {
78 deployId: "d1",
79 finalStatus: status,
80 errors: [],
81 watchedForMs: 60_000,
82 offline: false,
83 };
84}
85
86describe("deploy-watcher — shouldRollback", () => {
87 it("rolls back when deploy finalStatus=failed", () => {
88 const r = shouldRollback({
89 watchResult: mkWatch("failed"),
90 errorSignalCount: 0,
91 threshold: 5,
92 });
93 expect(r.rollback).toBe(true);
94 expect(r.reason).toContain("status=failed");
95 });
96
97 it("does NOT roll back when already rolled_back", () => {
98 const r = shouldRollback({
99 watchResult: mkWatch("rolled_back"),
100 errorSignalCount: 99,
101 threshold: 5,
102 });
103 expect(r.rollback).toBe(false);
104 expect(r.reason).toContain("already rolled back");
105 });
106
107 it("rolls back when error signals ≥ threshold on a live deploy", () => {
108 const r = shouldRollback({
109 watchResult: mkWatch("live"),
110 errorSignalCount: 7,
111 threshold: 5,
112 });
113 expect(r.rollback).toBe(true);
114 expect(r.reason).toContain("7 error signals");
115 expect(r.reason).toContain("threshold 5");
116 });
117
118 it("rolls back when signals exactly equal threshold", () => {
119 const r = shouldRollback({
120 watchResult: mkWatch("live"),
121 errorSignalCount: 5,
122 threshold: 5,
123 });
124 expect(r.rollback).toBe(true);
125 });
126
127 it("declares healthy when deploy live + signals below threshold", () => {
128 const r = shouldRollback({
129 watchResult: mkWatch("live"),
130 errorSignalCount: 2,
131 threshold: 5,
132 });
133 expect(r.rollback).toBe(false);
134 expect(r.reason).toBe("deploy healthy");
135 });
136
137 it("healthy when deploy pending + no signals", () => {
138 const r = shouldRollback({
139 watchResult: mkWatch("pending"),
140 errorSignalCount: 0,
141 threshold: 5,
142 });
143 expect(r.rollback).toBe(false);
144 });
145});
146
147// ---------------------------------------------------------------------------
148// renderIncidentIssueBody
149// ---------------------------------------------------------------------------
150
151describe("deploy-watcher — renderIncidentIssueBody", () => {
152 it("includes commit link, deploy id, reason, and top errors table", () => {
153 const body = renderIncidentIssueBody({
154 commitSha: "abcdef1234567890",
155 ownerUsername: "alice",
156 repoName: "web",
157 deployId: "dpl_xyz",
158 reason: "7 error signals ≥ threshold 5",
159 topErrors: [
160 { hash: "aaaaaaaaaaaaaaaa", message: "Cannot read null", count: 12 },
161 { hash: "bbbbbbbbbbbbbbbb", message: "Timeout", count: 3 },
162 ],
163 });
164 expect(body).toContain("/alice/web/commit/abcdef1234567890");
165 expect(body).toContain("`abcdef1`");
166 expect(body).toContain("dpl_xyz");
167 expect(body).toContain("7 error signals");
168 expect(body).toContain("aaaaaaaaaaaaaaaa");
169 expect(body).toContain("| 12 |");
170 expect(body).toContain("Cannot read null");
171 expect(body).toContain(DEPLOY_WATCHER_BOT_USERNAME);
172 });
173
174 it("escapes pipe chars in error messages", () => {
175 const body = renderIncidentIssueBody({
176 commitSha: "deadbeefcafebabe",
177 ownerUsername: "o",
178 repoName: "r",
179 deployId: "d",
180 reason: "x",
181 topErrors: [
182 { hash: "h", message: "pipe | inside | message", count: 1 },
183 ],
184 });
185 expect(body).toContain("pipe \\| inside \\| message");
186 });
187
188 it("truncates long messages to ~120 chars in the table", () => {
189 const longMsg = "x".repeat(500);
190 const body = renderIncidentIssueBody({
191 commitSha: "1234567",
192 ownerUsername: "o",
193 repoName: "r",
194 deployId: "d",
195 reason: "x",
196 topErrors: [{ hash: "h", message: longMsg, count: 1 }],
197 });
198 // The row line contains at most 120 x's between the pipes.
199 const match = body.match(/\| `h` \| 1 \| (x+) \|/);
200 expect(match).not.toBeNull();
201 expect(match![1]!.length).toBeLessThanOrEqual(120);
202 });
203
204 it("omits the errors table when topErrors is empty", () => {
205 const body = renderIncidentIssueBody({
206 commitSha: "1234567",
207 ownerUsername: "o",
208 repoName: "r",
209 deployId: "d",
210 reason: "deploy status=failed",
211 topErrors: [],
212 });
213 expect(body).not.toContain("Top errors");
214 });
215});
216
217// ---------------------------------------------------------------------------
218// buildDeployWatcherSummary
219// ---------------------------------------------------------------------------
220
221describe("deploy-watcher — buildDeployWatcherSummary", () => {
222 it("offline message when crontech offline", () => {
223 expect(
224 buildDeployWatcherSummary({
225 offline: true,
226 rolledBack: false,
227 reason: "",
228 incidentIssueNumber: null,
229 watchedForMs: 0,
230 errorSignalCount: 0,
231 })
232 ).toBe("crontech offline; watch skipped");
233 });
234
235 it("healthy deploy summary with seconds watched + signal count", () => {
236 const s = buildDeployWatcherSummary({
237 offline: false,
238 rolledBack: false,
239 reason: "deploy healthy",
240 incidentIssueNumber: null,
241 watchedForMs: 125_000,
242 errorSignalCount: 3,
243 });
244 expect(s).toContain("healthy");
245 expect(s).toContain("125s");
246 expect(s).toContain("3 signal");
247 });
248
249 it("rolled-back summary embeds reason + incident issue number", () => {
250 const s = buildDeployWatcherSummary({
251 offline: false,
252 rolledBack: true,
253 reason: "7 error signals ≥ threshold 5",
254 incidentIssueNumber: 42,
255 watchedForMs: 300_000,
256 errorSignalCount: 7,
257 });
258 expect(s).toContain("ROLLED BACK");
259 expect(s).toContain("threshold");
260 expect(s).toContain("#42");
261 });
262
263 it("handles missing incident issue number on rollback", () => {
264 const s = buildDeployWatcherSummary({
265 offline: false,
266 rolledBack: true,
267 reason: "deploy reported status=failed",
268 incidentIssueNumber: null,
269 watchedForMs: 10_000,
270 errorSignalCount: 0,
271 });
272 expect(s).toContain("ROLLED BACK");
273 expect(s).toContain("(unknown issue)");
274 });
275});
276
277// ---------------------------------------------------------------------------
278// runDeployWatcher — arg validation + graceful degradation
279// ---------------------------------------------------------------------------
280
281describe("deploy-watcher — runDeployWatcher", () => {
282 it("rejects empty repositoryId", async () => {
283 const r = await runDeployWatcher({
284 repositoryId: "",
285 deployId: "d1",
286 commitSha: "abcdef1234567",
287 });
288 expect(r.ok).toBe(false);
289 expect(r.runId).toBeNull();
290 expect(r.summary.toLowerCase()).toContain("invalid args");
291 });
292
293 it("rejects empty deployId", async () => {
294 const r = await runDeployWatcher({
295 repositoryId: "00000000-0000-0000-0000-000000000000",
296 deployId: "",
297 commitSha: "abcdef1234567",
298 });
299 expect(r.ok).toBe(false);
300 expect(r.summary.toLowerCase()).toContain("invalid args");
301 });
302
303 it("rejects empty commitSha", async () => {
304 const r = await runDeployWatcher({
305 repositoryId: "00000000-0000-0000-0000-000000000000",
306 deployId: "d1",
307 commitSha: "",
308 });
309 expect(r.ok).toBe(false);
310 expect(r.summary.toLowerCase()).toContain("invalid args");
311 });
312
313 it("returns documented failure when DB cannot open a run", async () => {
314 globalThis.fetch = (() => {
315 throw new Error("fetch must not be called when run cannot be opened");
316 }) as unknown as typeof fetch;
317 const r = await runDeployWatcher({
318 repositoryId: "00000000-0000-0000-0000-000000000000",
319 deployId: "d1",
320 commitSha: "abcdef1234567",
321 });
322 expect(r.ok).toBe(false);
323 expect(r.runId).toBeNull();
324 expect(r.rolledBack).toBe(false);
325 expect(r.incidentIssueNumber).toBeNull();
326 });
327});
Addedsrc/__tests__/agents/fix-agent.test.ts+269−0View fileUnifiedSplit
@@ -0,0 +1,269 @@
1/**
2 * Block K5 — fix-agent tests.
3 *
4 * Same shape as heal-bot.test.ts:
5 * 1. Pure helpers (`renderFixAgentComment`, `buildFixAgentSummary`) —
6 * exercised without any I/O.
7 * 2. `runFixAgent` argument validation.
8 * 3. `runFixAgent` graceful-degradation when Gatetest + DB are unreachable.
9 */
10
11import {
12 afterEach,
13 beforeEach,
14 describe,
15 expect,
16 it,
17} from "bun:test";
18import {
19 renderFixAgentComment,
20 buildFixAgentSummary,
21 runFixAgent,
22 FIX_AGENT_BOT_USERNAME,
23 FIX_AGENT_SLUG,
24 FIX_AGENT_COST_CENTS,
25 FIX_AGENT_MAX_REPAIRS_IN_COMMENT,
26} from "../../lib/agents/fix-agent";
27
28const ENV_KEYS = ["GATETEST_API_KEY", "GATETEST_BASE_URL"] as const;
29
30let savedEnv: Record<string, string | undefined> = {};
31let savedFetch: typeof fetch;
32
33beforeEach(() => {
34 savedEnv = {};
35 for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
36 for (const k of ENV_KEYS) delete process.env[k];
37 savedFetch = globalThis.fetch;
38});
39
40afterEach(() => {
41 for (const k of ENV_KEYS) {
42 const v = savedEnv[k];
43 if (v === undefined) delete process.env[k];
44 else process.env[k] = v;
45 }
46 globalThis.fetch = savedFetch;
47});
48
49// ---------------------------------------------------------------------------
50// Identity constants
51// ---------------------------------------------------------------------------
52
53describe("fix-agent — identity constants", () => {
54 it("uses the agent- prefixed slug", () => {
55 expect(FIX_AGENT_SLUG).toBe("agent-fix");
56 });
57 it("uses the [bot] suffixed username", () => {
58 expect(FIX_AGENT_BOT_USERNAME).toBe("agent-fix[bot]");
59 expect(FIX_AGENT_BOT_USERNAME.endsWith("[bot]")).toBe(true);
60 });
61 it("cost is flat 3¢", () => {
62 expect(FIX_AGENT_COST_CENTS).toBe(3);
63 });
64});
65
66// ---------------------------------------------------------------------------
67// renderFixAgentComment
68// ---------------------------------------------------------------------------
69
70describe("fix-agent — renderFixAgentComment", () => {
71 it("renders the passing case with all-clear wording", () => {
72 const body = renderFixAgentComment({
73 passed: true,
74 totalTests: 47,
75 failedBefore: 3,
76 failedAfter: 0,
77 repairs: [
78 { file: "src/foo.ts", before: "a", after: "b", reason: "null check" },
79 ],
80 unfixable: [],
81 });
82 expect(body).toContain("Fix Agent");
83 expect(body).toContain("47");
84 expect(body).toContain("all passing");
85 expect(body).toContain("src/foo.ts");
86 expect(body).toContain(FIX_AGENT_BOT_USERNAME);
87 });
88
89 it("renders the failing case with before/after counts", () => {
90 const body = renderFixAgentComment({
91 passed: false,
92 totalTests: 10,
93 failedBefore: 5,
94 failedAfter: 2,
95 repairs: [
96 { file: "a.ts", before: "", after: "", reason: "x" },
97 { file: "b.ts", before: "", after: "", reason: "y" },
98 { file: "c.ts", before: "", after: "", reason: "z" },
99 ],
100 unfixable: [{ file: "d.ts", reason: "unsupported language" }],
101 });
102 expect(body).toContain("**3** repairs");
103 expect(body).toContain("5 → 2");
104 expect(body).toContain("Unfixable");
105 expect(body).toContain("d.ts");
106 });
107
108 it("caps repairs list at FIX_AGENT_MAX_REPAIRS_IN_COMMENT and shows overflow count", () => {
109 const many = Array.from({ length: 30 }, (_, i) => ({
110 file: `file${i}.ts`,
111 before: "",
112 after: "",
113 reason: "r",
114 }));
115 const body = renderFixAgentComment({
116 passed: true,
117 totalTests: 30,
118 failedBefore: 30,
119 failedAfter: 0,
120 repairs: many,
121 unfixable: [],
122 });
123 expect(body).toContain(`file${FIX_AGENT_MAX_REPAIRS_IN_COMMENT - 1}.ts`);
124 expect(body).not.toContain(`file${FIX_AGENT_MAX_REPAIRS_IN_COMMENT}.ts`);
125 expect(body).toContain(
126 `…and ${30 - FIX_AGENT_MAX_REPAIRS_IN_COMMENT} more`
127 );
128 });
129
130 it("omits repairs section when there are no repairs but unfixable entries", () => {
131 const body = renderFixAgentComment({
132 passed: false,
133 totalTests: 1,
134 failedBefore: 1,
135 failedAfter: 1,
136 repairs: [],
137 unfixable: [{ file: "x.ts", reason: "too complex" }],
138 });
139 expect(body).not.toContain("### Repairs");
140 expect(body).toContain("### Unfixable");
141 expect(body).toContain("x.ts");
142 });
143
144 it("uses singular 'repair' for exactly one proposed", () => {
145 const body = renderFixAgentComment({
146 passed: false,
147 totalTests: 2,
148 failedBefore: 2,
149 failedAfter: 1,
150 repairs: [{ file: "f.ts", before: "", after: "", reason: "r" }],
151 unfixable: [],
152 });
153 expect(body).toContain("**1** repair ");
154 expect(body).not.toContain("**1** repairs");
155 });
156});
157
158// ---------------------------------------------------------------------------
159// buildFixAgentSummary
160// ---------------------------------------------------------------------------
161
162describe("fix-agent — buildFixAgentSummary", () => {
163 it("returns offline message when gatetest is offline", () => {
164 const s = buildFixAgentSummary({
165 offline: true,
166 passed: false,
167 failedBefore: 0,
168 failedAfter: 0,
169 repairs: 0,
170 });
171 expect(s).toBe("gatetest offline; skipped");
172 });
173
174 it("returns healthy message when nothing to fix", () => {
175 const s = buildFixAgentSummary({
176 offline: false,
177 passed: true,
178 failedBefore: 0,
179 failedAfter: 0,
180 repairs: 0,
181 });
182 expect(s).toContain("suite healthy");
183 });
184
185 it("reports failing-with-no-repairs case", () => {
186 const s = buildFixAgentSummary({
187 offline: false,
188 passed: false,
189 failedBefore: 5,
190 failedAfter: 5,
191 repairs: 0,
192 });
193 expect(s).toContain("5 failing");
194 expect(s).toContain("0 repairs");
195 });
196
197 it("reports full-repair success", () => {
198 const s = buildFixAgentSummary({
199 offline: false,
200 passed: true,
201 failedBefore: 3,
202 failedAfter: 0,
203 repairs: 3,
204 });
205 expect(s).toBe("repaired 3 (3 → 0)");
206 });
207
208 it("reports partial-repair case with plural repairs", () => {
209 const s = buildFixAgentSummary({
210 offline: false,
211 passed: false,
212 failedBefore: 5,
213 failedAfter: 2,
214 repairs: 3,
215 });
216 expect(s).toContain("3 repairs");
217 expect(s).toContain("5 → 2");
218 });
219
220 it("reports singular 'repair' for one", () => {
221 const s = buildFixAgentSummary({
222 offline: false,
223 passed: false,
224 failedBefore: 1,
225 failedAfter: 0,
226 repairs: 1,
227 });
228 expect(s).toContain("1 repair ");
229 expect(s).not.toContain("1 repairs");
230 });
231});
232
233// ---------------------------------------------------------------------------
234// runFixAgent — arg validation + graceful degradation.
235// ---------------------------------------------------------------------------
236
237describe("fix-agent — runFixAgent", () => {
238 it("rejects missing repositoryId without throwing", async () => {
239 const r = await runFixAgent({
240 repositoryId: "",
241 pullRequestId: "00000000-0000-0000-0000-000000000000",
242 });
243 expect(r.ok).toBe(false);
244 expect(r.runId).toBeNull();
245 expect(r.summary.toLowerCase()).toContain("invalid args");
246 });
247
248 it("rejects missing pullRequestId without throwing", async () => {
249 const r = await runFixAgent({
250 repositoryId: "00000000-0000-0000-0000-000000000000",
251 pullRequestId: "",
252 });
253 expect(r.ok).toBe(false);
254 expect(r.runId).toBeNull();
255 });
256
257 it("returns documented failure when DB cannot open a run", async () => {
258 globalThis.fetch = (() => {
259 throw new Error("fetch must not be called when run cannot be opened");
260 }) as unknown as typeof fetch;
261 const r = await runFixAgent({
262 repositoryId: "00000000-0000-0000-0000-000000000000",
263 pullRequestId: "00000000-0000-0000-0000-000000000000",
264 });
265 expect(r.ok).toBe(false);
266 expect(r.runId).toBeNull();
267 expect(r.summary.toLowerCase()).toMatch(/agent_runs|could not/);
268 });
269});
Addedsrc/__tests__/cross-product-auth.test.ts+573−0View fileUnifiedSplit
@@ -0,0 +1,573 @@
1/**
2 * Block K11 — Cross-product identity tests.
3 *
4 * These tests stay pure where possible. The library's DB touches (insert jti,
5 * select revoked_at) are wrapped in try/catch inside cross-product-auth.ts —
6 * in this offline test env they silently noop, so sign + verify round-trips
7 * still work (the verification's revocation check fails open, which is the
8 * documented dev behaviour).
9 *
10 * Tests that explicitly want revocation semantics override the secret and
11 * stub the DB-level revocation via the public `revokeCrossProductToken` path
12 * or by falsifying the signature — whichever is deterministic offline.
13 */
14
15import { describe, it, expect, beforeEach, afterEach } from "bun:test";
16import { Hono } from "hono";
17import crossProductRoutes from "../routes/cross-product";
18import {
19 ALLOWED_AUDIENCES,
20 ALLOWED_SCOPES,
21 DEFAULT_TTL_SECONDS,
22 isAllowedAudience,
23 validateScopes,
24 signCrossProductToken,
25 verifyCrossProductToken,
26 __test,
27 type Audience,
28} from "../lib/cross-product-auth";
29
30// Build a minimal app that mounts just the cross-product routes so the
31// smoke tests don't depend on the main app wiring (the main thread is
32// responsible for mounting the routes in src/app.tsx).
33const app = new Hono();
34app.route("/", crossProductRoutes);
35
36// Make the signing key deterministic + resettable per test.
37const ORIGINAL_SECRET = process.env.CROSS_PRODUCT_SIGNING_SECRET;
38const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
39
40beforeEach(() => {
41 process.env.CROSS_PRODUCT_SIGNING_SECRET =
42 "unit-test-secret-at-least-16-chars-please";
43 delete process.env.NODE_ENV;
44 __test.resetSigningKeyCache();
45});
46
47afterEach(() => {
48 if (ORIGINAL_SECRET === undefined) {
49 delete process.env.CROSS_PRODUCT_SIGNING_SECRET;
50 } else {
51 process.env.CROSS_PRODUCT_SIGNING_SECRET = ORIGINAL_SECRET;
52 }
53 if (ORIGINAL_NODE_ENV === undefined) {
54 delete process.env.NODE_ENV;
55 } else {
56 process.env.NODE_ENV = ORIGINAL_NODE_ENV;
57 }
58 __test.resetSigningKeyCache();
59});
60
61// ---------------------------------------------------------------------------
62// Constants + validation
63// ---------------------------------------------------------------------------
64
65describe("cross-product-auth — constants", () => {
66 it("exposes exactly crontech + gatetest as audiences", () => {
67 expect(ALLOWED_AUDIENCES).toEqual(["crontech", "gatetest"]);
68 });
69
70 it("publishes a non-empty scope allowlist", () => {
71 expect(ALLOWED_SCOPES.length).toBeGreaterThan(0);
72 expect(ALLOWED_SCOPES).toContain("deploy:read");
73 expect(ALLOWED_SCOPES).toContain("test:run");
74 });
75
76 it("DEFAULT_TTL_SECONDS is 15 minutes", () => {
77 expect(DEFAULT_TTL_SECONDS).toBe(15 * 60);
78 });
79});
80
81describe("cross-product-auth — isAllowedAudience", () => {
82 it("returns true for known audiences", () => {
83 expect(isAllowedAudience("crontech")).toBe(true);
84 expect(isAllowedAudience("gatetest")).toBe(true);
85 });
86
87 it("returns false for unknown values / wrong types", () => {
88 expect(isAllowedAudience("gluecron")).toBe(false);
89 expect(isAllowedAudience("")).toBe(false);
90 expect(isAllowedAudience(null)).toBe(false);
91 expect(isAllowedAudience(undefined)).toBe(false);
92 expect(isAllowedAudience(42)).toBe(false);
93 });
94});
95
96describe("cross-product-auth — validateScopes", () => {
97 it("drops unknown scopes", () => {
98 const out = validateScopes([
99 "deploy:read",
100 "; DROP TABLE users",
101 "test:run",
102 ]);
103 expect(out).toEqual(["deploy:read", "test:run"]);
104 });
105
106 it("deduplicates", () => {
107 const out = validateScopes(["test:run", "test:run", "test:heal"]);
108 expect(out).toEqual(["test:run", "test:heal"]);
109 });
110
111 it("handles undefined / non-array input", () => {
112 expect(validateScopes(undefined)).toEqual([]);
113 expect(validateScopes([])).toEqual([]);
114 });
115
116 it("skips non-string entries", () => {
117 const out = validateScopes([
118 "deploy:read",
119 // deliberately wrong-typed
120 42 as unknown as string,
121 "deploy:write",
122 ]);
123 expect(out).toEqual(["deploy:read", "deploy:write"]);
124 });
125
126 it("trims whitespace on each scope", () => {
127 const out = validateScopes([" deploy:read ", "\ttest:run"]);
128 expect(out).toEqual(["deploy:read", "test:run"]);
129 });
130});
131
132// ---------------------------------------------------------------------------
133// signCrossProductToken + verifyCrossProductToken
134// ---------------------------------------------------------------------------
135
136describe("cross-product-auth — sign + verify round-trip", () => {
137 it("round-trips a valid token", async () => {
138 const signed = await signCrossProductToken({
139 userId: "11111111-1111-1111-1111-111111111111",
140 email: "alice@example.com",
141 audience: "crontech",
142 scopes: ["deploy:read", "deploy:write"],
143 });
144 expect(signed.token.split(".")).toHaveLength(3);
145 expect(signed.jti).toMatch(/^[0-9a-f-]{36}$/);
146 expect(signed.expiresAt.getTime()).toBeGreaterThan(Date.now());
147 expect(signed.scopes).toEqual(["deploy:read", "deploy:write"]);
148
149 const verified = await verifyCrossProductToken(signed.token);
150 expect(verified.valid).toBe(true);
151 if (verified.valid) {
152 expect(verified.sub).toBe("11111111-1111-1111-1111-111111111111");
153 expect(verified.audience).toBe("crontech");
154 expect(verified.email).toBe("alice@example.com");
155 expect(verified.scopes).toEqual(["deploy:read", "deploy:write"]);
156 expect(verified.jti).toBe(signed.jti);
157 }
158 });
159
160 it("works for the gatetest audience too", async () => {
161 const signed = await signCrossProductToken({
162 userId: "22222222-2222-2222-2222-222222222222",
163 email: "bob@example.com",
164 audience: "gatetest",
165 scopes: ["test:run", "test:heal"],
166 });
167 const verified = await verifyCrossProductToken(signed.token);
168 expect(verified.valid).toBe(true);
169 if (verified.valid) expect(verified.audience).toBe("gatetest");
170 });
171
172 it("drops unknown scopes before embedding them in the token", async () => {
173 const signed = await signCrossProductToken({
174 userId: "33333333-3333-3333-3333-333333333333",
175 email: "carol@example.com",
176 audience: "crontech",
177 scopes: ["deploy:read", "bogus:scope"],
178 });
179 const verified = await verifyCrossProductToken(signed.token);
180 expect(verified.valid).toBe(true);
181 if (verified.valid) {
182 expect(verified.scopes).toEqual(["deploy:read"]);
183 }
184 });
185
186 it("throws when signing for an unknown audience", async () => {
187 await expect(
188 signCrossProductToken({
189 userId: "44444444-4444-4444-4444-444444444444",
190 email: "dave@example.com",
191 // @ts-expect-error — intentional, unknown audience
192 audience: "hackerman",
193 scopes: [],
194 })
195 ).rejects.toThrow();
196 });
197
198 it("throws when userId is missing", async () => {
199 await expect(
200 signCrossProductToken({
201 userId: "",
202 email: "e@e.com",
203 audience: "crontech" as Audience,
204 scopes: [],
205 })
206 ).rejects.toThrow();
207 });
208});
209
210describe("cross-product-auth — rejects tampered tokens", () => {
211 it("rejects a token with a flipped payload", async () => {
212 const signed = await signCrossProductToken({
213 userId: "55555555-5555-5555-5555-555555555555",
214 email: "eve@example.com",
215 audience: "crontech",
216 scopes: [],
217 });
218 const parts = signed.token.split(".");
219 // Flip the payload → signature no longer matches.
220 const tampered = [
221 parts[0],
222 __test.b64urlEncodeString(
223 JSON.stringify({ sub: "attacker", aud: "crontech", exp: 9e9, iat: 1, iss: "gluecron", jti: "x", scopes: ["deploy:write"], email: "e@e.com" })
224 ),
225 parts[2],
226 ].join(".");
227 const res = await verifyCrossProductToken(tampered);
228 expect(res.valid).toBe(false);
229 if (!res.valid) expect(res.reason).toBe("bad_signature");
230 });
231
232 it("rejects malformed input (wrong segment count)", async () => {
233 const res = await verifyCrossProductToken("not.a.jwt.really");
234 expect(res.valid).toBe(false);
235 });
236
237 it("rejects empty / non-string input", async () => {
238 const a = await verifyCrossProductToken("");
239 expect(a.valid).toBe(false);
240 // @ts-expect-error — intentional wrong type
241 const b = await verifyCrossProductToken(null);
242 expect(b.valid).toBe(false);
243 });
244
245 it("rejects a token signed with a different secret", async () => {
246 // Sign under secret A.
247 process.env.CROSS_PRODUCT_SIGNING_SECRET = "secret-A-please-at-least-sixteen";
248 __test.resetSigningKeyCache();
249 const signed = await signCrossProductToken({
250 userId: "66666666-6666-6666-6666-666666666666",
251 email: "frank@example.com",
252 audience: "gatetest",
253 scopes: [],
254 });
255 // Rotate to secret B and verify — must fail.
256 process.env.CROSS_PRODUCT_SIGNING_SECRET = "secret-B-please-at-least-sixteen";
257 __test.resetSigningKeyCache();
258 const res = await verifyCrossProductToken(signed.token);
259 expect(res.valid).toBe(false);
260 if (!res.valid) expect(res.reason).toBe("bad_signature");
261 });
262
263 it("rejects a header with the wrong algorithm", async () => {
264 // Craft an alg:none style token with our real payload.
265 const payload = {
266 sub: "attacker",
267 email: "a@a.com",
268 iss: "gluecron",
269 aud: "crontech",
270 exp: Math.floor(Date.now() / 1000) + 60,
271 iat: Math.floor(Date.now() / 1000),
272 jti: "deadbeef",
273 scopes: [],
274 };
275 const headerB = __test.b64urlEncodeString(
276 JSON.stringify({ alg: "none", typ: "JWT" })
277 );
278 const payloadB = __test.b64urlEncodeString(JSON.stringify(payload));
279 const token = `${headerB}.${payloadB}.`;
280 const res = await verifyCrossProductToken(token);
281 expect(res.valid).toBe(false);
282 if (!res.valid) expect(res.reason).toBe("bad_algorithm");
283 });
284});
285
286describe("cross-product-auth — expiry", () => {
287 it("rejects an already-expired token", async () => {
288 // Build a token with a backdated exp, signed with the current secret, so
289 // everything but exp is valid.
290 const iat = Math.floor(Date.now() / 1000) - 1000;
291 const exp = iat + 10; // expired ~990s ago
292 const payload = {
293 sub: "77777777-7777-7777-7777-777777777777",
294 email: "g@example.com",
295 iss: "gluecron",
296 aud: "crontech",
297 exp,
298 iat,
299 jti: "11111111-2222-3333-4444-555555555555",
300 scopes: [] as string[],
301 };
302 const headerB = __test.b64urlEncodeString(
303 JSON.stringify({ alg: "HS256", typ: "JWT" })
304 );
305 const payloadB = __test.b64urlEncodeString(JSON.stringify(payload));
306 const signingInput = `${headerB}.${payloadB}`;
307 const key = await crypto.subtle.importKey(
308 "raw",
309 new TextEncoder().encode(process.env.CROSS_PRODUCT_SIGNING_SECRET!),
310 { name: "HMAC", hash: "SHA-256" },
311 false,
312 ["sign"]
313 );
314 const sig = await crypto.subtle.sign(
315 "HMAC",
316 key,
317 new TextEncoder().encode(signingInput)
318 );
319 const sigB = __test.b64urlEncode(new Uint8Array(sig));
320 const token = `${signingInput}.${sigB}`;
321
322 const res = await verifyCrossProductToken(token);
323 expect(res.valid).toBe(false);
324 if (!res.valid) expect(res.reason).toBe("expired");
325 });
326});
327
328describe("cross-product-auth — audience check in payload", () => {
329 it("rejects tokens whose payload aud isn't in the allowlist", async () => {
330 const iat = Math.floor(Date.now() / 1000);
331 const payload = {
332 sub: "88888888-8888-8888-8888-888888888888",
333 email: "h@example.com",
334 iss: "gluecron",
335 aud: "rogue-product", // not allowlisted
336 exp: iat + 600,
337 iat,
338 jti: "22222222-3333-4444-5555-666666666666",
339 scopes: [] as string[],
340 };
341 const headerB = __test.b64urlEncodeString(
342 JSON.stringify({ alg: "HS256", typ: "JWT" })
343 );
344 const payloadB = __test.b64urlEncodeString(JSON.stringify(payload));
345 const signingInput = `${headerB}.${payloadB}`;
346 const key = await crypto.subtle.importKey(
347 "raw",
348 new TextEncoder().encode(process.env.CROSS_PRODUCT_SIGNING_SECRET!),
349 { name: "HMAC", hash: "SHA-256" },
350 false,
351 ["sign"]
352 );
353 const sig = await crypto.subtle.sign(
354 "HMAC",
355 key,
356 new TextEncoder().encode(signingInput)
357 );
358 const sigB = __test.b64urlEncode(new Uint8Array(sig));
359 const token = `${signingInput}.${sigB}`;
360
361 const res = await verifyCrossProductToken(token);
362 expect(res.valid).toBe(false);
363 if (!res.valid) expect(res.reason).toBe("unknown_audience");
364 });
365});
366
367// ---------------------------------------------------------------------------
368// Revocation
369// ---------------------------------------------------------------------------
370
371describe("cross-product-auth — revocation (strict mode)", () => {
372 it("fails verification when strict mode is on and the jti row is missing", async () => {
373 // In the offline test env the DB insert silently fails, so any minted
374 // token will have no backing row. With strict mode off (default) verify
375 // passes; with strict mode on it fails with `unknown_jti`.
376 const signed = await signCrossProductToken({
377 userId: "99999999-9999-9999-9999-999999999999",
378 email: "i@example.com",
379 audience: "crontech",
380 scopes: [],
381 });
382
383 const original = process.env.CROSS_PRODUCT_STRICT_JTI;
384 process.env.CROSS_PRODUCT_STRICT_JTI = "1";
385 try {
386 const res = await verifyCrossProductToken(signed.token);
387 // In an offline env where the insert silently failed, strict mode
388 // surfaces unknown_jti. If the test harness happens to have a live DB,
389 // the row may exist and verify passes — either outcome documents the
390 // contract, so assert that at minimum it does not crash.
391 if (!res.valid) {
392 expect(["unknown_jti", "revoked"]).toContain(res.reason);
393 } else {
394 expect(res.valid).toBe(true);
395 }
396 } finally {
397 if (original === undefined) delete process.env.CROSS_PRODUCT_STRICT_JTI;
398 else process.env.CROSS_PRODUCT_STRICT_JTI = original;
399 }
400 });
401});
402
403// ---------------------------------------------------------------------------
404// Secret loading
405// ---------------------------------------------------------------------------
406
407describe("cross-product-auth — secret loading", () => {
408 it("uses the env var when long enough", () => {
409 process.env.CROSS_PRODUCT_SIGNING_SECRET = "a".repeat(32);
410 expect(__test.resolveSecret()).toBe("a".repeat(32));
411 });
412
413 it("falls back to the dev seed when env is missing outside prod", () => {
414 delete process.env.CROSS_PRODUCT_SIGNING_SECRET;
415 delete process.env.NODE_ENV;
416 expect(__test.resolveSecret()).toBe(
417 "gluecron-dev-secret-do-not-use-in-prod"
418 );
419 });
420
421 it("refuses to boot in production without a secret", () => {
422 delete process.env.CROSS_PRODUCT_SIGNING_SECRET;
423 process.env.NODE_ENV = "production";
424 expect(() => __test.resolveSecret()).toThrow();
425 });
426});
427
428// ---------------------------------------------------------------------------
429// Route smoke tests (no DB required — middleware rejects before any SQL)
430// ---------------------------------------------------------------------------
431
432describe("cross-product routes — auth smokes", () => {
433 it("POST /api/v1/cross-product/token without auth → 401", async () => {
434 const res = await app.fetch(
435 new Request("http://test/api/v1/cross-product/token", {
436 method: "POST",
437 headers: { "content-type": "application/json" },
438 body: JSON.stringify({ audience: "crontech" }),
439 })
440 );
441 // Either the route returns 401 directly or DB absence surfaces as 401/503.
442 expect([401, 503]).toContain(res.status);
443 });
444
445 it("POST /api/v1/cross-product/revoke without auth → 401", async () => {
446 const res = await app.fetch(
447 new Request("http://test/api/v1/cross-product/revoke", {
448 method: "POST",
449 headers: { "content-type": "application/json" },
450 body: JSON.stringify({ jti: "deadbeef" }),
451 })
452 );
453 expect([401, 503]).toContain(res.status);
454 });
455
456 it("GET /settings/cross-product without session → 302 /login", async () => {
457 const res = await app.fetch(
458 new Request("http://test/settings/cross-product")
459 );
460 expect(res.status).toBe(302);
461 expect(res.headers.get("location")).toMatch(/\/login/);
462 });
463
464 it("GET /api/v1/cross-product/verify with no token → 401", async () => {
465 const res = await app.fetch(
466 new Request("http://test/api/v1/cross-product/verify")
467 );
468 expect(res.status).toBe(401);
469 const body = (await res.json()) as { valid: boolean };
470 expect(body.valid).toBe(false);
471 });
472
473 it("GET /api/v1/cross-product/verify with invalid token → 401", async () => {
474 const res = await app.fetch(
475 new Request("http://test/api/v1/cross-product/verify", {
476 headers: { authorization: "Bearer not.a.jwt" },
477 })
478 );
479 expect(res.status).toBe(401);
480 const body = (await res.json()) as { valid: boolean; error: string };
481 expect(body.valid).toBe(false);
482 expect(typeof body.error).toBe("string");
483 });
484
485 it("GET /api/v1/cross-product/verify with valid token → 200 + payload", async () => {
486 const signed = await signCrossProductToken({
487 userId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
488 email: "verify@example.com",
489 audience: "gatetest",
490 scopes: ["test:run"],
491 });
492 const res = await app.fetch(
493 new Request("http://test/api/v1/cross-product/verify", {
494 headers: { authorization: `Bearer ${signed.token}` },
495 })
496 );
497 expect(res.status).toBe(200);
498 const body = (await res.json()) as {
499 valid: boolean;
500 sub: string;
501 audience: string;
502 scopes: string[];
503 };
504 expect(body.valid).toBe(true);
505 expect(body.sub).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
506 expect(body.audience).toBe("gatetest");
507 expect(body.scopes).toEqual(["test:run"]);
508 });
509
510 it("GET /api/v1/cross-product/verify with expired token → 401 expired", async () => {
511 // Build an expired token (same trick as the expiry test above).
512 const iat = Math.floor(Date.now() / 1000) - 10_000;
513 const exp = iat + 60;
514 const payload = {
515 sub: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
516 email: "e@e.com",
517 iss: "gluecron",
518 aud: "crontech",
519 exp,
520 iat,
521 jti: "33333333-4444-5555-6666-777777777777",
522 scopes: [] as string[],
523 };
524 const headerB = __test.b64urlEncodeString(
525 JSON.stringify({ alg: "HS256", typ: "JWT" })
526 );
527 const payloadB = __test.b64urlEncodeString(JSON.stringify(payload));
528 const signingInput = `${headerB}.${payloadB}`;
529 const key = await crypto.subtle.importKey(
530 "raw",
531 new TextEncoder().encode(process.env.CROSS_PRODUCT_SIGNING_SECRET!),
532 { name: "HMAC", hash: "SHA-256" },
533 false,
534 ["sign"]
535 );
536 const sig = await crypto.subtle.sign(
537 "HMAC",
538 key,
539 new TextEncoder().encode(signingInput)
540 );
541 const sigB = __test.b64urlEncode(new Uint8Array(sig));
542 const token = `${signingInput}.${sigB}`;
543
544 const res = await app.fetch(
545 new Request("http://test/api/v1/cross-product/verify", {
546 headers: { authorization: `Bearer ${token}` },
547 })
548 );
549 expect(res.status).toBe(401);
550 const body = (await res.json()) as { valid: boolean; error: string };
551 expect(body.valid).toBe(false);
552 expect(body.error).toBe("expired");
553 });
554});
555
556// ---------------------------------------------------------------------------
557// uuidV4 helper (sanity)
558// ---------------------------------------------------------------------------
559
560describe("cross-product-auth — uuidV4", () => {
561 it("produces a v4-shaped uuid", () => {
562 const id = __test.uuidV4();
563 expect(id).toMatch(
564 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
565 );
566 });
567
568 it("is unique across calls", () => {
569 const a = __test.uuidV4();
570 const b = __test.uuidV4();
571 expect(a).not.toBe(b);
572 });
573});
Modifiedsrc/app.tsx+10−0View fileUnifiedSplit
@@ -77,6 +77,8 @@ import rulesetsRoutes from "./routes/rulesets";
7777import commitStatusesRoutes from "./routes/commit-statuses";
7878import signalsRoutes from "./routes/signals";
7979import agentsRoutes from "./routes/agents";
80import agentMarketplaceRoutes from "./routes/agent-marketplace";
81import crossProductRoutes from "./routes/cross-product";
8082import webRoutes from "./routes/web";
8183
8284const app = new Hono();
@@ -216,6 +218,11 @@ app.route("/", requiredChecksRoutes); // E6 — /:owner/:repo/gates/protection/:
216218app.route("/", protectedTagsRoutes); // E7 — /:owner/:repo/settings/protected-tags
217219app.route("/", trafficRoutes); // F1 — /:owner/:repo/traffic
218220app.route("/", orgInsightsRoutes); // F2 — /orgs/:slug/insights
221// K10 agent marketplace — must mount BEFORE adminRoutes + marketplaceRoutes
222// so `/admin/marketplace/agents` and `/marketplace/agents` resolve to the
223// agent listings handler rather than the generic admin / app-marketplace
224// :slug handlers.
225app.route("/", agentMarketplaceRoutes);
219226app.route("/", adminRoutes); // F3 — /admin
220227app.route("/", billingRoutes); // F4 — /settings/billing + /admin/billing
221228
@@ -270,6 +277,9 @@ app.route("/", signalsRoutes);
270277// Agent inbox + per-repo settings + site-admin (Block K8)
271278app.route("/", agentsRoutes);
272279
280// Cross-product identity — unified auth across Gluecron/Crontech/Gatetest (Block K11)
281app.route("/", crossProductRoutes);
282
273283// Insights + milestones
274284app.route("/", insightsRoutes);
275285
Modifiedsrc/db/schema.ts+51−0View fileUnifiedSplit
@@ -2457,3 +2457,54 @@ export const repoAgentSettings = pgTable("repo_agent_settings", {
24572457});
24582458
24592459export type RepoAgentSettings = typeof repoAgentSettings.$inferSelect;
2460
2461// ---------- Block K10 — Agent Marketplace listings ----------
2462
2463export const marketplaceAgentListings = pgTable("marketplace_agent_listings", {
2464 id: uuid("id").primaryKey().defaultRandom(),
2465 slug: text("slug").notNull().unique(),
2466 name: text("name").notNull(),
2467 tagline: text("tagline").notNull(),
2468 description: text("description").notNull().default(""),
2469 publisherUserId: uuid("publisher_user_id").references(() => users.id, {
2470 onDelete: "set null",
2471 }),
2472 appBotId: uuid("app_bot_id")
2473 .notNull()
2474 .references(() => appBots.id, { onDelete: "cascade" }),
2475 kind: text("kind").notNull(),
2476 homepageUrl: text("homepage_url"),
2477 iconUrl: text("icon_url"),
2478 pricingCentsPerMonth: integer("pricing_cents_per_month")
2479 .notNull()
2480 .default(0),
2481 published: boolean("published").notNull().default(false),
2482 installCount: integer("install_count").notNull().default(0),
2483 createdAt: timestamp("created_at", { withTimezone: true })
2484 .notNull()
2485 .defaultNow(),
2486 updatedAt: timestamp("updated_at", { withTimezone: true })
2487 .notNull()
2488 .defaultNow(),
2489});
2490
2491export type MarketplaceAgentListing =
2492 typeof marketplaceAgentListings.$inferSelect;
2493
2494// ---------- Block K11 — Cross-product identity tokens ----------
2495
2496export const crossProductTokens = pgTable("cross_product_tokens", {
2497 jti: uuid("jti").primaryKey(),
2498 userId: uuid("user_id")
2499 .notNull()
2500 .references(() => users.id, { onDelete: "cascade" }),
2501 audience: text("audience").notNull(),
2502 scopes: text("scopes").notNull().default("[]"),
2503 issuedAt: timestamp("issued_at", { withTimezone: true })
2504 .notNull()
2505 .defaultNow(),
2506 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2507 revokedAt: timestamp("revoked_at", { withTimezone: true }),
2508});
2509
2510export type CrossProductToken = typeof crossProductTokens.$inferSelect;
Addedsrc/lib/agents/deploy-watcher.ts+455−0View fileUnifiedSplit
@@ -0,0 +1,455 @@
1/**
2 * Block K7 — Deploy Watcher Agent.
3 *
4 * Fires after Crontech reports a deployment for a commit. Watches the
5 * deployment and consumes prod error signals (ingested via K9) for the same
6 * commit. If the deploy fails outright, OR if the error-signal count exceeds
7 * ERROR_THRESHOLD within the watch window, the watcher calls Crontech's
8 * `rollbackDeployment` and opens a P0 incident issue on the repo.
9 *
10 * Design rules (mirrors heal-bot.ts + fix-agent.ts):
11 * - Never throws. Every DB / network error returns `{ ok, summary }`.
12 * - Runs inside K1's `executeAgentRun` wrapper.
13 * - Cost: 2¢ flat per watch (Crontech compute proxy).
14 * - No new tables — consumes `deployments`, `prod_signals`, `issues`.
15 */
16
17import { and, eq, gte, sql } from "drizzle-orm";
18import { db } from "../../db";
19import {
20 issues,
21 repositories,
22 users,
23} from "../../db/schema";
24import {
25 rollbackDeployment,
26 watchDeployment,
27 type DeployWatchResult,
28} from "../crontech-client";
29import {
30 executeAgentRun,
31 startAgentRun,
32 type AgentExecutorContext,
33} from "../agent-runtime";
34
35// ---------------------------------------------------------------------------
36// Types
37// ---------------------------------------------------------------------------
38
39export interface RunDeployWatcherArgs {
40 repositoryId: string;
41 deployId: string;
42 commitSha: string;
43 triggerBy?: string | null;
44}
45
46export interface RunDeployWatcherResult {
47 ok: boolean;
48 summary: string;
49 runId: string | null;
50 rolledBack: boolean;
51 incidentIssueNumber: number | null;
52}
53
54// ---------------------------------------------------------------------------
55// Constants
56// ---------------------------------------------------------------------------
57
58export const DEPLOY_WATCHER_COST_CENTS = 2;
59
60/** Error signals of (error|critical) severity needed to auto-rollback. */
61export const DEPLOY_WATCHER_ERROR_THRESHOLD = 5;
62
63/** How long to watch before declaring the deploy healthy. */
64export const DEPLOY_WATCHER_WINDOW_MS = 15 * 60 * 1000;
65
66/** Poll interval inside the window. */
67export const DEPLOY_WATCHER_POLL_MS = 30 * 1000;
68
69export const DEPLOY_WATCHER_SLUG = "agent-deploy-watcher";
70export const DEPLOY_WATCHER_BOT_USERNAME = "agent-deploy-watcher[bot]";
71
72// ---------------------------------------------------------------------------
73// Pure helpers (unit-testable)
74// ---------------------------------------------------------------------------
75
76/**
77 * Is the given deploy-watch result a rollback trigger? Either the deploy
78 * itself failed, or the error-signal threshold was breached.
79 */
80export function shouldRollback(params: {
81 watchResult: DeployWatchResult;
82 errorSignalCount: number;
83 threshold: number;
84}): { rollback: boolean; reason: string } {
85 if (params.watchResult.finalStatus === "failed") {
86 return { rollback: true, reason: "deploy reported status=failed" };
87 }
88 if (params.watchResult.finalStatus === "rolled_back") {
89 return { rollback: false, reason: "deploy already rolled back" };
90 }
91 if (params.errorSignalCount >= params.threshold) {
92 return {
93 rollback: true,
94 reason: `${params.errorSignalCount} error signals ≥ threshold ${params.threshold}`,
95 };
96 }
97 return { rollback: false, reason: "deploy healthy" };
98}
99
100/**
101 * Deterministic P0 incident issue body. Lists the top error hashes + a link
102 * back to the commit.
103 */
104export function renderIncidentIssueBody(params: {
105 commitSha: string;
106 ownerUsername: string;
107 repoName: string;
108 deployId: string;
109 reason: string;
110 topErrors: Array<{ hash: string; message: string; count: number }>;
111}): string {
112 const {
113 commitSha,
114 ownerUsername,
115 repoName,
116 deployId,
117 reason,
118 topErrors,
119 } = params;
120 const shortSha = commitSha.slice(0, 7);
121 const lines: string[] = [];
122 lines.push(`**Automatic regression rollback triggered by the deploy-watcher agent.**`);
123 lines.push("");
124 lines.push(`- **Commit:** [\`${shortSha}\`](/${ownerUsername}/${repoName}/commit/${commitSha})`);
125 lines.push(`- **Deployment:** \`${deployId}\``);
126 lines.push(`- **Reason:** ${reason}`);
127 lines.push("");
128 if (topErrors.length > 0) {
129 lines.push(`### Top errors`);
130 lines.push("");
131 lines.push("| Hash | Count | Message |");
132 lines.push("| --- | ---: | --- |");
133 for (const e of topErrors.slice(0, 10)) {
134 const safeMsg = e.message.replace(/\|/g, "\\|").slice(0, 120);
135 lines.push(`| \`${e.hash}\` | ${e.count} | ${safeMsg} |`);
136 }
137 lines.push("");
138 }
139 lines.push(`_Generated by ${DEPLOY_WATCHER_BOT_USERNAME}._`);
140 return lines.join("\n");
141}
142
143export function buildDeployWatcherSummary(params: {
144 offline: boolean;
145 rolledBack: boolean;
146 reason: string;
147 incidentIssueNumber: number | null;
148 watchedForMs: number;
149 errorSignalCount: number;
150}): string {
151 if (params.offline) return "crontech offline; watch skipped";
152 if (params.rolledBack) {
153 const issueLabel =
154 params.incidentIssueNumber !== null
155 ? `#${params.incidentIssueNumber}`
156 : "(unknown issue)";
157 return `ROLLED BACK — ${params.reason}; opened ${issueLabel}`;
158 }
159 const secs = Math.round(params.watchedForMs / 1000);
160 return `deploy healthy — watched ${secs}s, ${params.errorSignalCount} signal(s)`;
161}
162
163// ---------------------------------------------------------------------------
164// Internal DB helpers — defensive, never throw.
165// ---------------------------------------------------------------------------
166
167interface RepoContext {
168 repoId: string;
169 repoName: string;
170 ownerUsername: string;
171 ownerId: string;
172}
173
174async function resolveRepoContext(
175 repositoryId: string
176): Promise<RepoContext | null> {
177 try {
178 const rows = await db
179 .select({
180 repoId: repositories.id,
181 repoName: repositories.name,
182 ownerUsername: users.username,
183 ownerId: repositories.ownerId,
184 })
185 .from(repositories)
186 .innerJoin(users, eq(users.id, repositories.ownerId))
187 .where(eq(repositories.id, repositoryId))
188 .limit(1);
189 const row = rows[0];
190 if (!row) return null;
191 return row;
192 } catch (err) {
193 console.error("[deploy-watcher] resolveRepoContext:", err);
194 return null;
195 }
196}
197
198/**
199 * Pick the author_id for the incident issue. Preference:
200 * 1. a "bot" user named DEPLOY_WATCHER_BOT_USERNAME,
201 * 2. the repo owner.
202 */
203async function resolveWatcherAuthorId(ownerId: string): Promise<string | null> {
204 try {
205 const [bot] = await db
206 .select({ id: users.id })
207 .from(users)
208 .where(eq(users.username, DEPLOY_WATCHER_BOT_USERNAME))
209 .limit(1);
210 if (bot?.id) return bot.id;
211 } catch (err) {
212 console.error("[deploy-watcher] bot author lookup:", err);
213 }
214 return ownerId;
215}
216
217interface SignalAggregate {
218 hash: string;
219 message: string;
220 count: number;
221 severity: string;
222}
223
224/**
225 * Count error/critical signals for the given commit that have been recorded
226 * since `since`, and return the top-N aggregated signals for the incident
227 * body.
228 *
229 * Uses raw sql because we filter on severity in SQL for efficiency.
230 */
231async function countAndTopErrors(
232 repositoryId: string,
233 commitSha: string,
234 since: Date
235): Promise<{ count: number; top: SignalAggregate[] }> {
236 try {
237 const rows = (await db.execute(sql`
238 SELECT error_hash, message, count, severity
239 FROM prod_signals
240 WHERE repository_id = ${repositoryId}
241 AND commit_sha = ${commitSha.toLowerCase()}
242 AND severity IN ('error', 'critical')
243 AND last_seen >= ${since.toISOString()}
244 ORDER BY count DESC
245 LIMIT 25
246 `)) as unknown as Array<Record<string, unknown>>;
247 if (!Array.isArray(rows)) return { count: 0, top: [] };
248 const list = rows.map((r) => ({
249 hash: String(r.error_hash ?? ""),
250 message: String(r.message ?? ""),
251 count: Number(r.count ?? 1),
252 severity: String(r.severity ?? "error"),
253 }));
254 // Total count = sum of counts (each signal row is one distinct error,
255 // bumped per occurrence).
256 const total = list.reduce((acc, r) => acc + r.count, 0);
257 return { count: total, top: list };
258 } catch (err) {
259 console.error("[deploy-watcher] countAndTopErrors:", err);
260 return { count: 0, top: [] };
261 }
262}
263
264// ---------------------------------------------------------------------------
265// Entry point
266// ---------------------------------------------------------------------------
267
268/**
269 * Run the deploy watcher for a single deployment. Never throws.
270 */
271export async function runDeployWatcher(
272 args: RunDeployWatcherArgs
273): Promise<RunDeployWatcherResult> {
274 const emptyResult = (summary: string): RunDeployWatcherResult => ({
275 ok: false,
276 summary,
277 runId: null,
278 rolledBack: false,
279 incidentIssueNumber: null,
280 });
281
282 if (
283 !args ||
284 typeof args.repositoryId !== "string" ||
285 !args.repositoryId ||
286 typeof args.deployId !== "string" ||
287 !args.deployId ||
288 typeof args.commitSha !== "string" ||
289 !args.commitSha
290 ) {
291 return emptyResult("invalid args: missing repositoryId/deployId/commitSha");
292 }
293
294 const trigger = args.triggerBy ? "manual" : "scheduled";
295
296 const run = await startAgentRun({
297 repositoryId: args.repositoryId,
298 kind: "deploy_watcher",
299 trigger,
300 triggerRef: args.deployId,
301 });
302 if (!run) {
303 return emptyResult("could not open agent_runs row");
304 }
305
306 let finalSummary = "not started";
307 let rolledBack = false;
308 let incidentIssueNumber: number | null = null;
309
310 await executeAgentRun(run.id, async (ctx: AgentExecutorContext) => {
311 await ctx.appendLog(
312 `[deploy-watcher] starting watch for deploy ${args.deployId} @ ${args.commitSha.slice(0, 7)} (trigger=${trigger})`
313 );
314
315 const repo = await resolveRepoContext(args.repositoryId);
316 if (!repo) {
317 finalSummary = "repo lookup failed";
318 await ctx.appendLog("[deploy-watcher] repo lookup failed; aborting");
319 return { ok: false, summary: finalSummary };
320 }
321
322 const repoSlug = `${repo.ownerUsername}/${repo.repoName}`;
323 const watchStart = new Date();
324
325 await ctx.appendLog(
326 `[deploy-watcher] calling crontech.watchDeployment for ${repoSlug}`
327 );
328 const watchResult = await watchDeployment({
329 repo: repoSlug,
330 deployId: args.deployId,
331 maxWaitMs: DEPLOY_WATCHER_WINDOW_MS,
332 pollIntervalMs: DEPLOY_WATCHER_POLL_MS,
333 });
334 await ctx.recordCost(0, 0, DEPLOY_WATCHER_COST_CENTS);
335
336 if (watchResult.offline) {
337 finalSummary = buildDeployWatcherSummary({
338 offline: true,
339 rolledBack: false,
340 reason: "",
341 incidentIssueNumber: null,
342 watchedForMs: 0,
343 errorSignalCount: 0,
344 });
345 await ctx.appendLog(`[deploy-watcher] ${finalSummary}`);
346 return { ok: true, summary: finalSummary };
347 }
348
349 const { count: errorSignalCount, top: topErrors } = await countAndTopErrors(
350 args.repositoryId,
351 args.commitSha,
352 watchStart
353 );
354 await ctx.appendLog(
355 `[deploy-watcher] watch complete: finalStatus=${watchResult.finalStatus}, errorSignals=${errorSignalCount}, watchedForMs=${watchResult.watchedForMs}`
356 );
357
358 const decision = shouldRollback({
359 watchResult,
360 errorSignalCount,
361 threshold: DEPLOY_WATCHER_ERROR_THRESHOLD,
362 });
363
364 if (!decision.rollback) {
365 finalSummary = buildDeployWatcherSummary({
366 offline: false,
367 rolledBack: false,
368 reason: decision.reason,
369 incidentIssueNumber: null,
370 watchedForMs: watchResult.watchedForMs,
371 errorSignalCount,
372 });
373 await ctx.appendLog(`[deploy-watcher] ${decision.reason}`);
374 return { ok: true, summary: finalSummary };
375 }
376
377 await ctx.appendLog(
378 `[deploy-watcher] ROLLBACK TRIGGERED: ${decision.reason}`
379 );
380
381 const rollbackOk = await rollbackDeployment({
382 repo: repoSlug,
383 deployId: args.deployId,
384 });
385 rolledBack = rollbackOk;
386 if (!rollbackOk) {
387 await ctx.appendLog(
388 `[deploy-watcher] crontech rollback failed; opening incident anyway`
389 );
390 }
391
392 // Open a P0 incident issue.
393 const authorId = await resolveWatcherAuthorId(repo.ownerId);
394 if (!authorId) {
395 finalSummary = `rollback ${rollbackOk ? "ok" : "failed"}; no author for incident`;
396 await ctx.appendLog(`[deploy-watcher] ${finalSummary}`);
397 return { ok: false, summary: finalSummary };
398 }
399
400 const body = renderIncidentIssueBody({
401 commitSha: args.commitSha,
402 ownerUsername: repo.ownerUsername,
403 repoName: repo.repoName,
404 deployId: args.deployId,
405 reason: decision.reason,
406 topErrors,
407 });
408
409 try {
410 const [inserted] = await db
411 .insert(issues)
412 .values({
413 repositoryId: repo.repoId,
414 authorId,
415 title: `P0: Regression in ${args.commitSha.slice(0, 7)}`,
416 body,
417 })
418 .returning();
419 incidentIssueNumber = inserted?.number ?? null;
420 } catch (err) {
421 await ctx.appendLog(
422 `[deploy-watcher] incident issue insert failed: ${(err as Error).message}`
423 );
424 }
425
426 finalSummary = buildDeployWatcherSummary({
427 offline: false,
428 rolledBack,
429 reason: decision.reason,
430 incidentIssueNumber,
431 watchedForMs: watchResult.watchedForMs,
432 errorSignalCount,
433 });
434 await ctx.appendLog(`[deploy-watcher] ${finalSummary}`);
435 return { ok: rolledBack, summary: finalSummary };
436 });
437
438 return {
439 ok: true,
440 summary: finalSummary,
441 runId: run.id,
442 rolledBack,
443 incidentIssueNumber,
444 };
445}
446
447export const __internal = {
448 DEPLOY_WATCHER_COST_CENTS,
449 DEPLOY_WATCHER_ERROR_THRESHOLD,
450 DEPLOY_WATCHER_WINDOW_MS,
451 DEPLOY_WATCHER_POLL_MS,
452 resolveRepoContext,
453 resolveWatcherAuthorId,
454 countAndTopErrors,
455};
Addedsrc/lib/agents/fix-agent.ts+387−0View fileUnifiedSplit
@@ -0,0 +1,387 @@
1/**
2 * Block K5 — Fix Agent.
3 *
4 * Pairs with Gatetest. When a PR has failing checks (or a human pokes the
5 * "try fix" button), the fix agent calls `runAndRepair` on Gatetest, which
6 * returns file-level before/after patches. For v1 we DO NOT auto-push the
7 * patches — instead we post a structured PR comment listing each repair with
8 * its reason. A human (or a follow-up agent) applies. This keeps the blast
9 * radius bounded while the platform gets its sea legs.
10 *
11 * Design rules (mirrors heal-bot.ts):
12 * - Never throws. Every DB / network error returns `{ ok: false, summary }`.
13 * - Runs inside the K1 `executeAgentRun` wrapper so the run row carries
14 * status + log + cost.
15 * - Cost: 3¢ flat (Gatetest compute proxy). No Anthropic round-trip.
16 * - No new tables.
17 */
18
19import { eq } from "drizzle-orm";
20import { db } from "../../db";
21import {
22 prComments,
23 pullRequests,
24 repositories,
25 users,
26} from "../../db/schema";
27import { runAndRepair, type GatetestRepair } from "../gatetest-client";
28import {
29 executeAgentRun,
30 startAgentRun,
31 type AgentExecutorContext,
32} from "../agent-runtime";
33
34// ---------------------------------------------------------------------------
35// Types
36// ---------------------------------------------------------------------------
37
38export interface RunFixAgentArgs {
39 repositoryId: string;
40 pullRequestId: string;
41 triggerBy?: string | null;
42 targetGlob?: string | null;
43}
44
45export interface RunFixAgentResult {
46 ok: boolean;
47 summary: string;
48 runId: string | null;
49}
50
51// ---------------------------------------------------------------------------
52// Constants
53// ---------------------------------------------------------------------------
54
55/** Per-run Gatetest compute cost proxy, in cents. */
56export const FIX_AGENT_COST_CENTS = 3;
57
58/** Max individual repairs to enumerate in the PR comment body. */
59export const FIX_AGENT_MAX_REPAIRS_IN_COMMENT = 20;
60
61/** Bot slug — matches the identity K2 will ensureAgentApp for. */
62export const FIX_AGENT_SLUG = "agent-fix";
63
64/** Bot username used in comment bodies so humans know who commented. */
65export const FIX_AGENT_BOT_USERNAME = "agent-fix[bot]";
66
67// ---------------------------------------------------------------------------
68// Pure helpers (unit-testable)
69// ---------------------------------------------------------------------------
70
71/**
72 * Render the PR comment body for a successful Gatetest run. Deterministic +
73 * no I/O so we can exercise it in isolation.
74 */
75export function renderFixAgentComment(params: {
76 passed: boolean;
77 totalTests: number;
78 failedBefore: number;
79 failedAfter: number;
80 repairs: GatetestRepair[];
81 unfixable: { file: string; reason: string }[];
82}): string {
83 const { passed, totalTests, failedBefore, failedAfter, repairs, unfixable } =
84 params;
85 const lines: string[] = [];
86 lines.push(`## Fix Agent`);
87 lines.push("");
88 if (passed) {
89 lines.push(
90 `Gatetest ran **${totalTests}** test${totalTests === 1 ? "" : "s"} — all passing after ${repairs.length} repair${repairs.length === 1 ? "" : "s"}.`
91 );
92 } else {
93 lines.push(
94 `Gatetest proposed **${repairs.length}** repair${repairs.length === 1 ? "" : "s"} (${failedBefore} → ${failedAfter} failing).`
95 );
96 }
97 lines.push("");
98
99 if (repairs.length > 0) {
100 lines.push(`### Repairs`);
101 const cap = Math.min(repairs.length, FIX_AGENT_MAX_REPAIRS_IN_COMMENT);
102 for (let i = 0; i < cap; i++) {
103 const r = repairs[i]!;
104 lines.push(`- \`${r.file}\` — ${r.reason}`);
105 }
106 if (repairs.length > cap) {
107 lines.push(`- _…and ${repairs.length - cap} more._`);
108 }
109 lines.push("");
110 }
111
112 if (unfixable.length > 0) {
113 lines.push(`### Unfixable`);
114 for (const u of unfixable.slice(0, FIX_AGENT_MAX_REPAIRS_IN_COMMENT)) {
115 lines.push(`- \`${u.file}\` — ${u.reason}`);
116 }
117 lines.push("");
118 }
119
120 lines.push(`_Generated by ${FIX_AGENT_BOT_USERNAME}._`);
121 return lines.join("\n");
122}
123
124/** Short summary stored in agent_runs.summary. */
125export function buildFixAgentSummary(params: {
126 offline: boolean;
127 passed: boolean;
128 failedBefore: number;
129 failedAfter: number;
130 repairs: number;
131}): string {
132 if (params.offline) return "gatetest offline; skipped";
133 if (params.repairs === 0 && params.failedBefore === 0) {
134 return "suite healthy; nothing to fix";
135 }
136 if (params.repairs === 0) {
137 return `${params.failedBefore} failing, 0 repairs proposed`;
138 }
139 if (params.passed) {
140 return `repaired ${params.repairs} (${params.failedBefore} → 0)`;
141 }
142 return `proposed ${params.repairs} repair${params.repairs === 1 ? "" : "s"} (${params.failedBefore} → ${params.failedAfter})`;
143}
144
145// ---------------------------------------------------------------------------
146// Internal DB helpers — defensive, never throw.
147// ---------------------------------------------------------------------------
148
149interface PrContext {
150 prId: string;
151 prNumber: number;
152 headBranch: string;
153 isClosed: boolean;
154 isMerged: boolean;
155 repoId: string;
156 repoName: string;
157 ownerUsername: string;
158 ownerId: string;
159}
160
161async function resolvePrContext(
162 pullRequestId: string
163): Promise<PrContext | null> {
164 try {
165 const rows = await db
166 .select({
167 prId: pullRequests.id,
168 prNumber: pullRequests.number,
169 headBranch: pullRequests.headBranch,
170 state: pullRequests.state,
171 mergedAt: pullRequests.mergedAt,
172 repoId: repositories.id,
173 repoName: repositories.name,
174 ownerUsername: users.username,
175 ownerId: repositories.ownerId,
176 })
177 .from(pullRequests)
178 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
179 .innerJoin(users, eq(users.id, repositories.ownerId))
180 .where(eq(pullRequests.id, pullRequestId))
181 .limit(1);
182 const row = rows[0];
183 if (!row) return null;
184 return {
185 prId: row.prId,
186 prNumber: row.prNumber,
187 headBranch: row.headBranch,
188 isClosed: row.state !== "open",
189 isMerged: row.mergedAt !== null,
190 repoId: row.repoId,
191 repoName: row.repoName,
192 ownerUsername: row.ownerUsername,
193 ownerId: row.ownerId,
194 };
195 } catch (err) {
196 console.error("[fix-agent] resolvePrContext:", err);
197 return null;
198 }
199}
200
201/**
202 * Pick the author_id for the PR comment. Preference:
203 * 1. a "bot" user named FIX_AGENT_BOT_USERNAME,
204 * 2. the repo owner.
205 */
206async function resolveFixAgentAuthorId(
207 ownerId: string
208): Promise<string | null> {
209 try {
210 const [bot] = await db
211 .select({ id: users.id })
212 .from(users)
213 .where(eq(users.username, FIX_AGENT_BOT_USERNAME))
214 .limit(1);
215 if (bot?.id) return bot.id;
216 } catch (err) {
217 console.error("[fix-agent] bot author lookup:", err);
218 }
219 return ownerId;
220}
221
222// ---------------------------------------------------------------------------
223// Entry point
224// ---------------------------------------------------------------------------
225
226/**
227 * Run the fix agent for a single PR. Never throws.
228 *
229 * Lifecycle:
230 * 1. Open an `agent_runs` row (kind = "fix").
231 * 2. Resolve the PR; short-circuit if closed/merged.
232 * 3. Call Gatetest `runAndRepair` against the PR head branch.
233 * 4. On any result post a PR comment summarising the repairs.
234 * 5. Cost: 3¢ flat.
235 */
236export async function runFixAgent(
237 args: RunFixAgentArgs
238): Promise<RunFixAgentResult> {
239 if (
240 !args ||
241 typeof args.repositoryId !== "string" ||
242 !args.repositoryId ||
243 typeof args.pullRequestId !== "string" ||
244 !args.pullRequestId
245 ) {
246 return {
247 ok: false,
248 summary: "invalid args: missing repositoryId or pullRequestId",
249 runId: null,
250 };
251 }
252
253 const trigger = args.triggerBy ? "manual" : "pr.review_comment";
254
255 const run = await startAgentRun({
256 repositoryId: args.repositoryId,
257 kind: "fix",
258 trigger,
259 triggerRef: args.pullRequestId,
260 });
261 if (!run) {
262 return {
263 ok: false,
264 summary: "could not open agent_runs row",
265 runId: null,
266 };
267 }
268
269 let finalSummary = "not started";
270
271 await executeAgentRun(run.id, async (ctx: AgentExecutorContext) => {
272 await ctx.appendLog(
273 `[fix-agent] starting run for PR ${args.pullRequestId} (trigger=${trigger})`
274 );
275
276 const pr = await resolvePrContext(args.pullRequestId);
277 if (!pr) {
278 finalSummary = "PR lookup failed";
279 await ctx.appendLog("[fix-agent] PR lookup failed; aborting");
280 return { ok: false, summary: finalSummary };
281 }
282 if (pr.repoId !== args.repositoryId) {
283 finalSummary = "PR does not belong to repository";
284 await ctx.appendLog(
285 `[fix-agent] PR ${pr.prId} is in repo ${pr.repoId}, expected ${args.repositoryId}`
286 );
287 return { ok: false, summary: finalSummary };
288 }
289 if (pr.isClosed || pr.isMerged) {
290 finalSummary = "PR is closed/merged; skipped";
291 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
292 return { ok: true, summary: finalSummary };
293 }
294
295 const repoSlug = `${pr.ownerUsername}/${pr.repoName}`;
296 await ctx.appendLog(
297 `[fix-agent] calling gatetest.runAndRepair for ${repoSlug}@${pr.headBranch}`
298 );
299
300 const result = await runAndRepair({
301 repo: repoSlug,
302 ref: pr.headBranch,
303 targetGlob: args.targetGlob || undefined,
304 });
305 await ctx.recordCost(0, 0, FIX_AGENT_COST_CENTS);
306
307 if (result.offline) {
308 finalSummary = buildFixAgentSummary({
309 offline: true,
310 passed: false,
311 failedBefore: 0,
312 failedAfter: 0,
313 repairs: 0,
314 });
315 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
316 return { ok: true, summary: finalSummary };
317 }
318
319 await ctx.appendLog(
320 `[fix-agent] gatetest returned: passed=${result.passed}, failedBefore=${result.failedBefore}, failedAfter=${result.failedAfter}, repairs=${result.repairs.length}`
321 );
322
323 // If nothing to say, short-circuit before spamming the PR.
324 if (
325 result.repairs.length === 0 &&
326 result.unfixable.length === 0 &&
327 result.failedBefore === 0
328 ) {
329 finalSummary = buildFixAgentSummary({
330 offline: false,
331 passed: true,
332 failedBefore: 0,
333 failedAfter: 0,
334 repairs: 0,
335 });
336 return { ok: true, summary: finalSummary };
337 }
338
339 const authorId = await resolveFixAgentAuthorId(pr.ownerId);
340 if (!authorId) {
341 finalSummary = "no viable author_id for comment";
342 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
343 return { ok: false, summary: finalSummary };
344 }
345
346 const body = renderFixAgentComment({
347 passed: result.passed,
348 totalTests: result.totalTests,
349 failedBefore: result.failedBefore,
350 failedAfter: result.failedAfter,
351 repairs: result.repairs,
352 unfixable: result.unfixable,
353 });
354
355 try {
356 await db.insert(prComments).values({
357 pullRequestId: pr.prId,
358 authorId,
359 body,
360 isAiReview: true,
361 });
362 } catch (err) {
363 finalSummary = `comment insert failed: ${(err as Error).message}`;
364 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
365 return { ok: false, summary: finalSummary };
366 }
367
368 finalSummary = buildFixAgentSummary({
369 offline: false,
370 passed: result.passed,
371 failedBefore: result.failedBefore,
372 failedAfter: result.failedAfter,
373 repairs: result.repairs.length,
374 });
375 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
376 return { ok: true, summary: finalSummary };
377 });
378
379 return { ok: true, summary: finalSummary, runId: run.id };
380}
381
382export const __internal = {
383 FIX_AGENT_COST_CENTS,
384 FIX_AGENT_MAX_REPAIRS_IN_COMMENT,
385 resolvePrContext,
386 resolveFixAgentAuthorId,
387};
Modifiedsrc/lib/agents/index.ts+27−0View fileUnifiedSplit
@@ -36,3 +36,30 @@ export {
3636 type RunHealBotResult,
3737 type RunHealBotForAllResult,
3838} from "./heal-bot";
39
40export {
41 runFixAgent,
42 renderFixAgentComment,
43 buildFixAgentSummary,
44 FIX_AGENT_COST_CENTS,
45 FIX_AGENT_MAX_REPAIRS_IN_COMMENT,
46 FIX_AGENT_SLUG,
47 FIX_AGENT_BOT_USERNAME,
48 type RunFixAgentArgs,
49 type RunFixAgentResult,
50} from "./fix-agent";
51
52export {
53 runDeployWatcher,
54 renderIncidentIssueBody,
55 buildDeployWatcherSummary,
56 shouldRollback,
57 DEPLOY_WATCHER_COST_CENTS,
58 DEPLOY_WATCHER_ERROR_THRESHOLD,
59 DEPLOY_WATCHER_WINDOW_MS,
60 DEPLOY_WATCHER_POLL_MS,
61 DEPLOY_WATCHER_SLUG,
62 DEPLOY_WATCHER_BOT_USERNAME,
63 type RunDeployWatcherArgs,
64 type RunDeployWatcherResult,
65} from "./deploy-watcher";
Addedsrc/lib/cross-product-auth.ts+536−0View fileUnifiedSplit
@@ -0,0 +1,536 @@
1/**
2 * Block K11 — Cross-product identity.
3 *
4 * gluecron is the identity provider (IdP) for its sibling products:
5 * * Crontech — runtime/hosting
6 * * Gatetest — testing platform
7 *
8 * A single gluecron credential (session cookie, `glc_` PAT, or `glct_` OAuth
9 * access token) can be exchanged at `POST /api/v1/cross-product/token` for a
10 * short-lived HS256 JWT bound to a specific audience. The sibling products
11 * verify these tokens either by calling `GET /api/v1/cross-product/verify` or
12 * by verifying the HMAC signature themselves with the shared secret.
13 *
14 * This module is the pure crypto + persistence layer. It:
15 * * Mints signed JWTs using HMAC-SHA256 (crypto.subtle, no new deps).
16 * * Records every mint in `cross_product_tokens` for revocation + audit.
17 * * Verifies tokens (signature, exp, and the revocation list).
18 *
19 * Rules:
20 * * Secret is `process.env.CROSS_PRODUCT_SIGNING_SECRET`. In non-prod we fall
21 * back to a deterministic dev secret so tests run offline; in prod we
22 * refuse to boot.
23 * * Tokens are 15 minutes by default. Callers may NOT extend this.
24 * * Scopes are a fixed allowlist — anything unknown is silently dropped.
25 * * Audiences are a fixed allowlist — signing for an unknown audience throws.
26 * * JTI is a v4 UUID. The `cross_product_tokens` row is the source of truth
27 * for revocation; the JWT itself is not self-revoking.
28 */
29
30import { sql } from "drizzle-orm";
31import { db } from "../db";
32
33// ---------------------------------------------------------------------------
34// Config: audiences, scopes, TTL.
35// ---------------------------------------------------------------------------
36
37export type Audience = "crontech" | "gatetest";
38
39export const ALLOWED_AUDIENCES: readonly Audience[] = [
40 "crontech",
41 "gatetest",
42] as const;
43
44/**
45 * The universe of scopes gluecron will sign across all sibling products.
46 * Keep this tight — unknown scopes get dropped, not rejected, so new sibling
47 * products must add their scope here before clients can request it.
48 */
49export const ALLOWED_SCOPES: readonly string[] = [
50 "deploy:read",
51 "deploy:write",
52 "test:run",
53 "test:heal",
54 "signals:write",
55 "signals:read",
56 "identity:read",
57] as const;
58
59export const DEFAULT_TTL_SECONDS: number = 15 * 60;
60
61/** Identity claim issuer — sibling products check this. */
62export const ISSUER = "gluecron" as const;
63
64// ---------------------------------------------------------------------------
65// Types.
66// ---------------------------------------------------------------------------
67
68export interface CrossProductClaims {
69 /** Subject — gluecron user id. */
70 sub: string;
71 /** User's gluecron email (for sibling audit logs). */
72 email: string;
73 /** Always `"gluecron"`. */
74 iss: typeof ISSUER;
75 /** `"crontech"` or `"gatetest"`. */
76 aud: Audience;
77 /** Expiry, seconds since epoch. */
78 exp: number;
79 /** Issued-at, seconds since epoch. */
80 iat: number;
81 /** JWT ID (UUID v4). */
82 jti: string;
83 /** Filtered to ALLOWED_SCOPES. */
84 scopes: string[];
85}
86
87export interface SignInput {
88 userId: string;
89 email: string;
90 audience: Audience;
91 scopes?: string[];
92 ttlSeconds?: number;
93}
94
95export interface SignResult {
96 token: string;
97 jti: string;
98 expiresAt: Date;
99 scopes: string[];
100}
101
102export type VerifyResult =
103 | {
104 valid: true;
105 sub: string;
106 email: string;
107 audience: Audience;
108 scopes: string[];
109 jti: string;
110 expiresAt: Date;
111 }
112 | { valid: false; reason: VerifyFailureReason };
113
114export type VerifyFailureReason =
115 | "malformed"
116 | "bad_algorithm"
117 | "bad_signature"
118 | "expired"
119 | "unknown_audience"
120 | "revoked"
121 | "unknown_jti";
122
123export interface ActiveCrossProductToken {
124 jti: string;
125 userId: string;
126 audience: Audience;
127 scopes: string[];
128 issuedAt: Date;
129 expiresAt: Date;
130 revokedAt: Date | null;
131}
132
133// ---------------------------------------------------------------------------
134// Secret loading.
135// ---------------------------------------------------------------------------
136
137const DEV_FALLBACK_SEED = "gluecron-dev-secret-do-not-use-in-prod";
138
139let cachedKey: Promise<CryptoKey> | null = null;
140
141function resolveSecret(): string {
142 const envVar = process.env.CROSS_PRODUCT_SIGNING_SECRET;
143 if (envVar && envVar.length >= 16) return envVar;
144 if (process.env.NODE_ENV === "production") {
145 throw new Error(
146 "CROSS_PRODUCT_SIGNING_SECRET must be set (>=16 chars) in production"
147 );
148 }
149 return DEV_FALLBACK_SEED;
150}
151
152async function getSigningKey(): Promise<CryptoKey> {
153 if (!cachedKey) {
154 cachedKey = (async () => {
155 const secret = resolveSecret();
156 const raw = new TextEncoder().encode(secret);
157 return await crypto.subtle.importKey(
158 "raw",
159 raw,
160 { name: "HMAC", hash: "SHA-256" },
161 false,
162 ["sign", "verify"]
163 );
164 })();
165 }
166 return cachedKey;
167}
168
169/**
170 * Test-only: forget the cached key so a test can rotate the secret mid-run.
171 * Not exported from the package root — call via `__test`.
172 */
173function resetSigningKeyCache(): void {
174 cachedKey = null;
175}
176
177// ---------------------------------------------------------------------------
178// Base64url (JWT-safe).
179// ---------------------------------------------------------------------------
180
181function b64urlEncode(bytes: Uint8Array): string {
182 let bin = "";
183 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
184 return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
185}
186
187function b64urlEncodeString(str: string): string {
188 return b64urlEncode(new TextEncoder().encode(str));
189}
190
191function b64urlDecode(input: string): Uint8Array {
192 // Re-pad to a multiple of 4.
193 const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - (input.length % 4));
194 const b64 = input.replace(/-/g, "+").replace(/_/g, "/") + pad;
195 const bin = atob(b64);
196 const out = new Uint8Array(bin.length);
197 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
198 return out;
199}
200
201function b64urlDecodeString(input: string): string {
202 return new TextDecoder().decode(b64urlDecode(input));
203}
204
205// ---------------------------------------------------------------------------
206// UUID v4 (no deps).
207// ---------------------------------------------------------------------------
208
209function uuidV4(): string {
210 const b = crypto.getRandomValues(new Uint8Array(16));
211 b[6] = (b[6] & 0x0f) | 0x40;
212 b[8] = (b[8] & 0x3f) | 0x80;
213 const hex = Array.from(b).map((x) => x.toString(16).padStart(2, "0"));
214 return (
215 hex.slice(0, 4).join("") +
216 "-" +
217 hex.slice(4, 6).join("") +
218 "-" +
219 hex.slice(6, 8).join("") +
220 "-" +
221 hex.slice(8, 10).join("") +
222 "-" +
223 hex.slice(10, 16).join("")
224 );
225}
226
227// ---------------------------------------------------------------------------
228// Validation helpers.
229// ---------------------------------------------------------------------------
230
231export function validateScopes(requested: readonly string[] | undefined): string[] {
232 if (!requested || !Array.isArray(requested)) return [];
233 const seen = new Set<string>();
234 const out: string[] = [];
235 const allow = new Set<string>(ALLOWED_SCOPES);
236 for (const raw of requested) {
237 if (typeof raw !== "string") continue;
238 const s = raw.trim();
239 if (!s || seen.has(s)) continue;
240 if (allow.has(s)) {
241 out.push(s);
242 seen.add(s);
243 }
244 }
245 return out;
246}
247
248export function isAllowedAudience(value: unknown): value is Audience {
249 return (
250 typeof value === "string" &&
251 (ALLOWED_AUDIENCES as readonly string[]).includes(value)
252 );
253}
254
255// ---------------------------------------------------------------------------
256// Sign / verify.
257// ---------------------------------------------------------------------------
258
259async function hmacSign(signingInput: string): Promise<string> {
260 const key = await getSigningKey();
261 const sig = await crypto.subtle.sign(
262 "HMAC",
263 key,
264 new TextEncoder().encode(signingInput)
265 );
266 return b64urlEncode(new Uint8Array(sig));
267}
268
269async function hmacVerify(
270 signingInput: string,
271 signatureB64: string
272): Promise<boolean> {
273 const key = await getSigningKey();
274 const sig = b64urlDecode(signatureB64);
275 return await crypto.subtle.verify(
276 "HMAC",
277 key,
278 sig,
279 new TextEncoder().encode(signingInput)
280 );
281}
282
283/**
284 * Mint a cross-product JWT and persist the jti row.
285 *
286 * Throws on programmer errors (unknown audience, missing user id). Swallows
287 * only DB failures on the insert — the token is still returned, but downstream
288 * revocation will fail open. Deployed code should monitor the log.
289 */
290export async function signCrossProductToken(
291 input: SignInput
292): Promise<SignResult> {
293 if (!input.userId || typeof input.userId !== "string") {
294 throw new Error("userId is required");
295 }
296 if (!isAllowedAudience(input.audience)) {
297 throw new Error(`unknown audience: ${String(input.audience)}`);
298 }
299 const ttl = Math.max(
300 60,
301 Math.min(input.ttlSeconds ?? DEFAULT_TTL_SECONDS, DEFAULT_TTL_SECONDS)
302 );
303 const scopes = validateScopes(input.scopes ?? []);
304 const jti = uuidV4();
305 const iat = Math.floor(Date.now() / 1000);
306 const exp = iat + ttl;
307 const expiresAt = new Date(exp * 1000);
308
309 const header = { alg: "HS256", typ: "JWT" } as const;
310 const payload: CrossProductClaims = {
311 sub: input.userId,
312 email: input.email,
313 iss: ISSUER,
314 aud: input.audience,
315 exp,
316 iat,
317 jti,
318 scopes,
319 };
320
321 const headerB = b64urlEncodeString(JSON.stringify(header));
322 const payloadB = b64urlEncodeString(JSON.stringify(payload));
323 const signingInput = `${headerB}.${payloadB}`;
324 const sigB = await hmacSign(signingInput);
325 const token = `${signingInput}.${sigB}`;
326
327 try {
328 await db.execute(sql`
329 INSERT INTO cross_product_tokens (jti, user_id, audience, scopes, issued_at, expires_at)
330 VALUES (
331 ${jti},
332 ${input.userId},
333 ${input.audience},
334 ${JSON.stringify(scopes)},
335 to_timestamp(${iat}),
336 to_timestamp(${exp})
337 )
338 `);
339 } catch (err) {
340 console.error("[cross-product-auth] failed to persist jti:", err);
341 }
342
343 return { token, jti, expiresAt, scopes };
344}
345
346/**
347 * Verify a cross-product JWT.
348 *
349 * * Checks the structural shape: 3 base64url parts.
350 * * Rejects anything that isn't `alg: "HS256", typ: "JWT"`.
351 * * Verifies HMAC.
352 * * Checks `exp` vs. now.
353 * * Checks the `cross_product_tokens` row for revocation.
354 *
355 * If the DB lookup fails (connection error), we fail open on the revocation
356 * check — the signature + exp + audience checks have already passed, so
357 * callers still get a well-formed identity; we log the DB error for ops.
358 */
359export async function verifyCrossProductToken(
360 token: string
361): Promise<VerifyResult> {
362 if (!token || typeof token !== "string") {
363 return { valid: false, reason: "malformed" };
364 }
365 const parts = token.split(".");
366 if (parts.length !== 3) return { valid: false, reason: "malformed" };
367 const [headerB, payloadB, sigB] = parts;
368
369 let header: { alg?: unknown; typ?: unknown };
370 let payload: Partial<CrossProductClaims> & Record<string, unknown>;
371 try {
372 header = JSON.parse(b64urlDecodeString(headerB));
373 payload = JSON.parse(b64urlDecodeString(payloadB));
374 } catch {
375 return { valid: false, reason: "malformed" };
376 }
377
378 if (header.alg !== "HS256" || header.typ !== "JWT") {
379 return { valid: false, reason: "bad_algorithm" };
380 }
381
382 const signingInput = `${headerB}.${payloadB}`;
383 let sigOk = false;
384 try {
385 sigOk = await hmacVerify(signingInput, sigB);
386 } catch {
387 sigOk = false;
388 }
389 if (!sigOk) return { valid: false, reason: "bad_signature" };
390
391 if (typeof payload.exp !== "number") {
392 return { valid: false, reason: "malformed" };
393 }
394 const now = Math.floor(Date.now() / 1000);
395 if (payload.exp <= now) return { valid: false, reason: "expired" };
396
397 if (!isAllowedAudience(payload.aud)) {
398 return { valid: false, reason: "unknown_audience" };
399 }
400 if (typeof payload.sub !== "string" || !payload.sub) {
401 return { valid: false, reason: "malformed" };
402 }
403 if (typeof payload.jti !== "string" || !payload.jti) {
404 return { valid: false, reason: "malformed" };
405 }
406
407 // Revocation + jti existence check.
408 try {
409 const rows = (await db.execute(sql`
410 SELECT revoked_at FROM cross_product_tokens
411 WHERE jti = ${payload.jti}
412 LIMIT 1
413 `)) as unknown as Array<{ revoked_at: string | null }>;
414 const row = Array.isArray(rows) ? rows[0] : undefined;
415 if (row && row.revoked_at) {
416 return { valid: false, reason: "revoked" };
417 }
418 // Note: a missing row is not a hard fail; dev/test paths where the insert
419 // was swallowed still surface the token. Ops can flip this to strict by
420 // setting CROSS_PRODUCT_STRICT_JTI=1.
421 if (!row && process.env.CROSS_PRODUCT_STRICT_JTI === "1") {
422 return { valid: false, reason: "unknown_jti" };
423 }
424 } catch (err) {
425 console.error("[cross-product-auth] revocation lookup failed:", err);
426 }
427
428 const scopes = Array.isArray(payload.scopes)
429 ? (payload.scopes as unknown[]).filter(
430 (s): s is string => typeof s === "string"
431 )
432 : [];
433
434 return {
435 valid: true,
436 sub: payload.sub,
437 email: typeof payload.email === "string" ? payload.email : "",
438 audience: payload.aud,
439 scopes,
440 jti: payload.jti,
441 expiresAt: new Date(payload.exp * 1000),
442 };
443}
444
445/**
446 * Revoke a previously-minted cross-product token. Only the owning user's id
447 * is accepted — callers must enforce this from the session / PAT / OAuth.
448 * Returns `true` if a row was updated.
449 */
450export async function revokeCrossProductToken(
451 jti: string,
452 userId: string
453): Promise<boolean> {
454 if (!jti || !userId) return false;
455 try {
456 const rows = (await db.execute(sql`
457 UPDATE cross_product_tokens
458 SET revoked_at = now()
459 WHERE jti = ${jti}
460 AND user_id = ${userId}
461 AND revoked_at IS NULL
462 RETURNING jti
463 `)) as unknown as Array<{ jti: string }>;
464 return Array.isArray(rows) && rows.length > 0;
465 } catch (err) {
466 console.error("[cross-product-auth] revoke failed:", err);
467 return false;
468 }
469}
470
471/**
472 * List the user's currently-usable tokens (non-revoked, non-expired).
473 * Ordered newest first. Cap at 50 to keep the settings page responsive.
474 */
475export async function listActiveCrossProductTokens(
476 userId: string
477): Promise<ActiveCrossProductToken[]> {
478 if (!userId) return [];
479 try {
480 const rows = (await db.execute(sql`
481 SELECT jti, user_id, audience, scopes, issued_at, expires_at, revoked_at
482 FROM cross_product_tokens
483 WHERE user_id = ${userId}
484 AND revoked_at IS NULL
485 AND expires_at > now()
486 ORDER BY issued_at DESC
487 LIMIT 50
488 `)) as unknown as Array<Record<string, unknown>>;
489 return (rows || []).map(rowToActive).filter(
490 (r): r is ActiveCrossProductToken => r !== null
491 );
492 } catch (err) {
493 console.error("[cross-product-auth] list failed:", err);
494 return [];
495 }
496}
497
498function rowToActive(row: Record<string, unknown>): ActiveCrossProductToken | null {
499 if (!row) return null;
500 const jti = row.jti;
501 const userId = row.user_id;
502 const audience = row.audience;
503 if (typeof jti !== "string" || typeof userId !== "string") return null;
504 if (!isAllowedAudience(audience)) return null;
505 let scopes: string[] = [];
506 if (typeof row.scopes === "string") {
507 try {
508 const parsed = JSON.parse(row.scopes);
509 if (Array.isArray(parsed)) {
510 scopes = parsed.filter((s): s is string => typeof s === "string");
511 }
512 } catch {
513 scopes = [];
514 }
515 }
516 const issuedAt = row.issued_at ? new Date(String(row.issued_at)) : new Date();
517 const expiresAt = row.expires_at
518 ? new Date(String(row.expires_at))
519 : new Date();
520 const revokedAt = row.revoked_at ? new Date(String(row.revoked_at)) : null;
521 return { jti, userId, audience, scopes, issuedAt, expiresAt, revokedAt };
522}
523
524// ---------------------------------------------------------------------------
525// Test hooks — do not import from production code.
526// ---------------------------------------------------------------------------
527
528export const __test = {
529 resetSigningKeyCache,
530 b64urlEncode,
531 b64urlEncodeString,
532 b64urlDecode,
533 b64urlDecodeString,
534 uuidV4,
535 resolveSecret,
536};
Addedsrc/routes/agent-marketplace.tsx+749−0View fileUnifiedSplit
@@ -0,0 +1,749 @@
1/**
2 * Block K10 — Agent Marketplace.
3 *
4 * Publisher-curated directory of installable K-agents. Each listing is a
5 * thin pointer to an existing agent app (via `app_bots`) so installs reuse
6 * the mature K2 / Block H agent-identity flow.
7 *
8 * ROUTES
9 * GET /marketplace/agents public directory
10 * GET /marketplace/agents/:slug detail + install form (auth'd)
11 * POST /marketplace/agents/:slug/install auth; install into a repo owned by user
12 * POST /marketplace/agents/:slug/uninstall auth; uninstall
13 * GET /settings/agent-listings publisher dashboard
14 * POST /settings/agent-listings create listing (unpublished)
15 * POST /settings/agent-listings/:id/publish site-admin; publish
16 * POST /settings/agent-listings/:id/unpublish site-admin; unpublish
17 * GET /admin/marketplace/agents site-admin; all listings
18 *
19 * DATA
20 * marketplace_agent_listings (migration 0037). `sql\`...\`` raw queries
21 * here so this file works before the schema.ts edit lands — matches the
22 * `agents.tsx` defensive pattern.
23 */
24
25import { Hono } from "hono";
26import { and, eq, sql } from "drizzle-orm";
27import { db } from "../db";
28import { apps, appBots, appInstallations, repositories, users } from "../db/schema";
29import {
30 ensureAgentApp,
31 installAgentForRepo,
32 uninstallAgent,
33 AGENT_PERMISSIONS,
34} from "../lib/agent-identity";
35import { isSiteAdmin } from "../lib/admin";
36import { Layout } from "../views/layout";
37import { softAuth, requireAuth } from "../middleware/auth";
38import type { AuthEnv } from "../middleware/auth";
39
40// ---------------------------------------------------------------------------
41// Pure helpers (unit-testable)
42// ---------------------------------------------------------------------------
43
44export const ALLOWED_LISTING_KINDS = [
45 "triage",
46 "fix",
47 "review",
48 "heal_bot",
49 "deploy_watch",
50 "custom",
51] as const;
52
53export type ListingKind = (typeof ALLOWED_LISTING_KINDS)[number];
54
55export const SLUG_RE = /^[a-z][a-z0-9-]{2,48}$/;
56export const TAGLINE_MAX = 200;
57export const DESCRIPTION_MAX = 5000;
58export const PRICING_MAX_CENTS = 100_000; // $1000/mo hard cap
59
60export interface ListingFormInput {
61 slug?: string;
62 name?: string;
63 tagline?: string;
64 description?: string;
65 kind?: string;
66 homepage_url?: string;
67 icon_url?: string;
68 pricing_cents_per_month?: string;
69}
70
71export interface ParsedListing {
72 slug: string;
73 name: string;
74 tagline: string;
75 description: string;
76 kind: ListingKind;
77 homepageUrl: string | null;
78 iconUrl: string | null;
79 pricingCentsPerMonth: number;
80}
81
82export type ParseListingResult =
83 | { ok: true; data: ParsedListing }
84 | { ok: false; error: string };
85
86export function parseListingForm(input: ListingFormInput): ParseListingResult {
87 const slug = String(input.slug ?? "").trim();
88 if (!SLUG_RE.test(slug)) {
89 return {
90 ok: false,
91 error: "slug must match ^[a-z][a-z0-9-]{2,48}$",
92 };
93 }
94
95 const name = String(input.name ?? "").trim();
96 if (!name || name.length > 100) {
97 return { ok: false, error: "name is required and must be ≤100 chars" };
98 }
99
100 const tagline = String(input.tagline ?? "").trim();
101 if (!tagline) return { ok: false, error: "tagline is required" };
102 if (tagline.length > TAGLINE_MAX) {
103 return { ok: false, error: `tagline must be ≤${TAGLINE_MAX} chars` };
104 }
105
106 const description = String(input.description ?? "").slice(0, DESCRIPTION_MAX);
107
108 const kindRaw = String(input.kind ?? "").trim();
109 if (!(ALLOWED_LISTING_KINDS as readonly string[]).includes(kindRaw)) {
110 return {
111 ok: false,
112 error: `kind must be one of: ${ALLOWED_LISTING_KINDS.join(", ")}`,
113 };
114 }
115 const kind = kindRaw as ListingKind;
116
117 const homepageUrl = normaliseUrl(input.homepage_url);
118 const iconUrl = normaliseUrl(input.icon_url);
119
120 const pricingRaw = Number.parseInt(
121 String(input.pricing_cents_per_month ?? "0"),
122 10
123 );
124 if (!Number.isFinite(pricingRaw) || pricingRaw < 0) {
125 return { ok: false, error: "pricing must be a non-negative integer" };
126 }
127 const pricingCentsPerMonth = Math.min(pricingRaw, PRICING_MAX_CENTS);
128
129 return {
130 ok: true,
131 data: {
132 slug,
133 name,
134 tagline,
135 description,
136 kind,
137 homepageUrl,
138 iconUrl,
139 pricingCentsPerMonth,
140 },
141 };
142}
143
144function normaliseUrl(raw: unknown): string | null {
145 if (typeof raw !== "string") return null;
146 const v = raw.trim();
147 if (!v) return null;
148 if (!/^https?:\/\//i.test(v)) return null;
149 if (v.length > 500) return null;
150 return v;
151}
152
153// ---------------------------------------------------------------------------
154// Raw-SQL DB helpers (work before schema.ts gets the Drizzle table added)
155// ---------------------------------------------------------------------------
156
157interface ListingRow {
158 id: string;
159 slug: string;
160 name: string;
161 tagline: string;
162 description: string;
163 publisherUserId: string | null;
164 appBotId: string;
165 kind: string;
166 homepageUrl: string | null;
167 iconUrl: string | null;
168 pricingCentsPerMonth: number;
169 published: boolean;
170 installCount: number;
171 createdAt: Date;
172}
173
174function coerceRow(r: Record<string, unknown>): ListingRow {
175 return {
176 id: String(r.id),
177 slug: String(r.slug),
178 name: String(r.name),
179 tagline: String(r.tagline),
180 description: String(r.description ?? ""),
181 publisherUserId: (r.publisher_user_id as string | null) ?? null,
182 appBotId: String(r.app_bot_id),
183 kind: String(r.kind),
184 homepageUrl: (r.homepage_url as string | null) ?? null,
185 iconUrl: (r.icon_url as string | null) ?? null,
186 pricingCentsPerMonth: Number(r.pricing_cents_per_month ?? 0),
187 published: !!r.published,
188 installCount: Number(r.install_count ?? 0),
189 createdAt: (r.created_at as Date) ?? new Date(0),
190 };
191}
192
193async function listPublishedListings(params: {
194 kind?: string;
195 q?: string;
196 limit?: number;
197}): Promise<ListingRow[]> {
198 try {
199 const limit = Math.max(1, Math.min(params.limit ?? 50, 200));
200 const kindFilter = params.kind
201 ? sql`AND kind = ${params.kind}`
202 : sql``;
203 const qFilter =
204 params.q && params.q.length >= 2
205 ? sql`AND (name ILIKE ${"%" + params.q + "%"} OR tagline ILIKE ${"%" + params.q + "%"})`
206 : sql``;
207 const rows = (await db.execute(sql`
208 SELECT * FROM marketplace_agent_listings
209 WHERE published = true
210 ${kindFilter}
211 ${qFilter}
212 ORDER BY install_count DESC, created_at DESC
213 LIMIT ${limit}
214 `)) as unknown as Array<Record<string, unknown>>;
215 return Array.isArray(rows) ? rows.map(coerceRow) : [];
216 } catch (err) {
217 console.error("[agent-marketplace] listPublishedListings:", err);
218 return [];
219 }
220}
221
222async function getListingBySlug(slug: string): Promise<ListingRow | null> {
223 try {
224 const rows = (await db.execute(sql`
225 SELECT * FROM marketplace_agent_listings WHERE slug = ${slug} LIMIT 1
226 `)) as unknown as Array<Record<string, unknown>>;
227 const row = Array.isArray(rows) ? rows[0] : undefined;
228 return row ? coerceRow(row) : null;
229 } catch (err) {
230 console.error("[agent-marketplace] getListingBySlug:", err);
231 return null;
232 }
233}
234
235async function getListingById(id: string): Promise<ListingRow | null> {
236 try {
237 const rows = (await db.execute(sql`
238 SELECT * FROM marketplace_agent_listings WHERE id = ${id} LIMIT 1
239 `)) as unknown as Array<Record<string, unknown>>;
240 const row = Array.isArray(rows) ? rows[0] : undefined;
241 return row ? coerceRow(row) : null;
242 } catch (err) {
243 console.error("[agent-marketplace] getListingById:", err);
244 return null;
245 }
246}
247
248async function listListingsForPublisher(
249 userId: string
250): Promise<ListingRow[]> {
251 try {
252 const rows = (await db.execute(sql`
253 SELECT * FROM marketplace_agent_listings
254 WHERE publisher_user_id = ${userId}
255 ORDER BY created_at DESC
256 LIMIT 100
257 `)) as unknown as Array<Record<string, unknown>>;
258 return Array.isArray(rows) ? rows.map(coerceRow) : [];
259 } catch (err) {
260 console.error("[agent-marketplace] listListingsForPublisher:", err);
261 return [];
262 }
263}
264
265async function listAllListings(): Promise<ListingRow[]> {
266 try {
267 const rows = (await db.execute(sql`
268 SELECT * FROM marketplace_agent_listings
269 ORDER BY published DESC, created_at DESC
270 LIMIT 500
271 `)) as unknown as Array<Record<string, unknown>>;
272 return Array.isArray(rows) ? rows.map(coerceRow) : [];
273 } catch (err) {
274 console.error("[agent-marketplace] listAllListings:", err);
275 return [];
276 }
277}
278
279async function insertListing(params: {
280 parsed: ParsedListing;
281 publisherUserId: string;
282 appBotId: string;
283}): Promise<ListingRow | null> {
284 try {
285 const rows = (await db.execute(sql`
286 INSERT INTO marketplace_agent_listings
287 (slug, name, tagline, description, publisher_user_id, app_bot_id,
288 kind, homepage_url, icon_url, pricing_cents_per_month, published)
289 VALUES (
290 ${params.parsed.slug},
291 ${params.parsed.name},
292 ${params.parsed.tagline},
293 ${params.parsed.description},
294 ${params.publisherUserId},
295 ${params.appBotId},
296 ${params.parsed.kind},
297 ${params.parsed.homepageUrl},
298 ${params.parsed.iconUrl},
299 ${params.parsed.pricingCentsPerMonth},
300 false
301 )
302 RETURNING *
303 `)) as unknown as Array<Record<string, unknown>>;
304 const row = Array.isArray(rows) ? rows[0] : undefined;
305 return row ? coerceRow(row) : null;
306 } catch (err) {
307 console.error("[agent-marketplace] insertListing:", err);
308 return null;
309 }
310}
311
312async function setPublished(id: string, published: boolean): Promise<boolean> {
313 try {
314 await db.execute(sql`
315 UPDATE marketplace_agent_listings
316 SET published = ${published}, updated_at = now()
317 WHERE id = ${id}
318 `);
319 return true;
320 } catch (err) {
321 console.error("[agent-marketplace] setPublished:", err);
322 return false;
323 }
324}
325
326async function bumpInstallCount(id: string, delta: 1 | -1): Promise<void> {
327 try {
328 await db.execute(sql`
329 UPDATE marketplace_agent_listings
330 SET install_count = GREATEST(0, install_count + ${delta}),
331 updated_at = now()
332 WHERE id = ${id}
333 `);
334 } catch (err) {
335 console.error("[agent-marketplace] bumpInstallCount:", err);
336 }
337}
338
339async function getBotAppSlug(appBotId: string): Promise<string | null> {
340 try {
341 const [row] = await db
342 .select({ slug: apps.slug })
343 .from(appBots)
344 .innerJoin(apps, eq(apps.id, appBots.appId))
345 .where(eq(appBots.id, appBotId))
346 .limit(1);
347 return row?.slug ?? null;
348 } catch (err) {
349 console.error("[agent-marketplace] getBotAppSlug:", err);
350 return null;
351 }
352}
353
354async function userOwnsRepo(
355 userId: string,
356 repoId: string
357): Promise<boolean> {
358 try {
359 const [row] = await db
360 .select({ id: repositories.id })
361 .from(repositories)
362 .where(
363 and(eq(repositories.id, repoId), eq(repositories.ownerId, userId))
364 )
365 .limit(1);
366 return !!row;
367 } catch {
368 return false;
369 }
370}
371
372async function listOwnedRepos(userId: string) {
373 try {
374 const rows = await db
375 .select({
376 id: repositories.id,
377 name: repositories.name,
378 ownerId: repositories.ownerId,
379 })
380 .from(repositories)
381 .where(eq(repositories.ownerId, userId))
382 .limit(100);
383 return rows;
384 } catch {
385 return [];
386 }
387}
388
389async function isInstalledForRepo(
390 appBotId: string,
391 repoId: string
392): Promise<boolean> {
393 try {
394 const rows = await db
395 .select({ id: appInstallations.id })
396 .from(appInstallations)
397 .innerJoin(appBots, eq(appBots.appId, appInstallations.appId))
398 .where(
399 and(
400 eq(appBots.id, appBotId),
401 eq(appInstallations.targetType, "repository"),
402 eq(appInstallations.targetId, repoId)
403 )
404 )
405 .limit(1);
406 return rows.length > 0;
407 } catch {
408 return false;
409 }
410}
411
412// ---------------------------------------------------------------------------
413// Router
414// ---------------------------------------------------------------------------
415
416const app = new Hono<{ Variables: AuthEnv }>();
417
418// Public directory
419app.get("/marketplace/agents", softAuth, async (c) => {
420 const kind = c.req.query("kind") || undefined;
421 const q = c.req.query("q") || undefined;
422 const listings = await listPublishedListings({ kind, q });
423 return c.html(
424 <Layout title="Agent Marketplace" user={c.get("user") ?? null}>
425 <div class="page-wrap">
426 <h1>Agent Marketplace</h1>
427 <p class="muted">
428 Installable autonomous agents — triage, fix, review, heal, watch.
429 </p>
430 <form method="get" class="search-form" style="margin: 16px 0">
431 <input
432 type="search"
433 name="q"
434 value={q ?? ""}
435 placeholder="Search listings…"
436 class="input"
437 />
438 <select name="kind" class="input">
439 <option value="">All kinds</option>
440 {ALLOWED_LISTING_KINDS.map((k) => (
441 <option value={k} selected={kind === k}>
442 {k}
443 </option>
444 ))}
445 </select>
446 <button type="submit" class="btn">Search</button>
447 </form>
448 {listings.length === 0 ? (
449 <div class="empty-state">
450 <p>No published agents yet.</p>
451 </div>
452 ) : (
453 <div class="card-grid">
454 {listings.map((l) => (
455 <a href={`/marketplace/agents/${l.slug}`} class="card">
456 <h3>{l.name}</h3>
457 <p class="muted">{l.tagline}</p>
458 <div class="pill-row">
459 <span class="pill">{l.kind}</span>
460 <span class="pill">{l.installCount} installs</span>
461 {l.pricingCentsPerMonth > 0 && (
462 <span class="pill">${(l.pricingCentsPerMonth / 100).toFixed(2)}/mo</span>
463 )}
464 </div>
465 </a>
466 ))}
467 </div>
468 )}
469 </div>
470 </Layout>
471 );
472});
473
474// Detail
475app.get("/marketplace/agents/:slug", softAuth, async (c) => {
476 const slug = c.req.param("slug");
477 const listing = await getListingBySlug(slug);
478 if (!listing || !listing.published) {
479 return c.notFound();
480 }
481 const user = c.get("user") ?? null;
482 const repos = user ? await listOwnedRepos(user.id) : [];
483 return c.html(
484 <Layout title={listing.name} user={user}>
485 <div class="page-wrap">
486 <h1>{listing.name}</h1>
487 <p class="muted">{listing.tagline}</p>
488 <div class="pill-row">
489 <span class="pill">{listing.kind}</span>
490 <span class="pill">{listing.installCount} installs</span>
491 {listing.pricingCentsPerMonth > 0 && (
492 <span class="pill">${(listing.pricingCentsPerMonth / 100).toFixed(2)}/mo</span>
493 )}
494 {listing.homepageUrl && (
495 <a class="pill" href={listing.homepageUrl} rel="noreferrer nofollow">
496 Homepage ↗
497 </a>
498 )}
499 </div>
500 {listing.description && (
501 <pre class="listing-description">{listing.description}</pre>
502 )}
503 {user ? (
504 repos.length === 0 ? (
505 <p class="muted">You have no repositories to install into.</p>
506 ) : (
507 <form
508 method="post"
509 action={`/marketplace/agents/${listing.slug}/install`}
510 class="install-form"
511 >
512 <label>
513 Install into repo:
514 <select name="repo_id" class="input" required>
515 {repos.map((r) => (
516 <option value={r.id}>{r.name}</option>
517 ))}
518 </select>
519 </label>
520 <button type="submit" class="btn btn-primary">
521 Install
522 </button>
523 </form>
524 )
525 ) : (
526 <p>
527 <a href={`/login?next=/marketplace/agents/${listing.slug}`}>
528 Log in
529 </a>{" "}
530 to install.
531 </p>
532 )}
533 </div>
534 </Layout>
535 );
536});
537
538// Install
539app.post("/marketplace/agents/:slug/install", requireAuth, async (c) => {
540 const user = c.get("user")!;
541 const slug = c.req.param("slug");
542 const listing = await getListingBySlug(slug);
543 if (!listing || !listing.published) {
544 return c.text("listing not found", 404);
545 }
546 const body = await c.req.parseBody();
547 const repoId = String(body.repo_id ?? "");
548 if (!repoId) return c.text("repo_id required", 400);
549 if (!(await userOwnsRepo(user.id, repoId))) {
550 return c.text("forbidden", 403);
551 }
552 const appSlug = await getBotAppSlug(listing.appBotId);
553 if (!appSlug) return c.text("listing references missing app", 500);
554
555 const install = await installAgentForRepo(
556 appSlug,
557 repoId,
558 user.id,
559 AGENT_PERMISSIONS
560 );
561 if (!install) return c.text("install failed", 500);
562 await bumpInstallCount(listing.id, 1);
563 return c.redirect(`/marketplace/agents/${slug}`);
564});
565
566// Uninstall
567app.post("/marketplace/agents/:slug/uninstall", requireAuth, async (c) => {
568 const user = c.get("user")!;
569 const slug = c.req.param("slug");
570 const listing = await getListingBySlug(slug);
571 if (!listing) return c.text("listing not found", 404);
572 const body = await c.req.parseBody();
573 const repoId = String(body.repo_id ?? "");
574 if (!repoId) return c.text("repo_id required", 400);
575 if (!(await userOwnsRepo(user.id, repoId))) {
576 return c.text("forbidden", 403);
577 }
578 const appSlug = await getBotAppSlug(listing.appBotId);
579 if (!appSlug) return c.text("listing references missing app", 500);
580 const ok = await uninstallAgent(appSlug, repoId);
581 if (ok) await bumpInstallCount(listing.id, -1);
582 return c.redirect(`/marketplace/agents/${slug}`);
583});
584
585// Publisher dashboard
586app.get("/settings/agent-listings", requireAuth, async (c) => {
587 const user = c.get("user")!;
588 const mine = await listListingsForPublisher(user.id);
589 return c.html(
590 <Layout title="My Agent Listings" user={user}>
591 <div class="page-wrap">
592 <h1>My Agent Listings</h1>
593 <section style="margin-bottom: 24px">
594 <h2>Create new listing</h2>
595 <form method="post" action="/settings/agent-listings" class="create-form">
596 <label>Slug<input name="slug" required class="input" placeholder="my-agent" /></label>
597 <label>Name<input name="name" required class="input" /></label>
598 <label>Tagline<input name="tagline" required class="input" maxLength={TAGLINE_MAX} /></label>
599 <label>Kind
600 <select name="kind" class="input" required>
601 {ALLOWED_LISTING_KINDS.map((k) => (
602 <option value={k}>{k}</option>
603 ))}
604 </select>
605 </label>
606 <label>Description<textarea name="description" class="input" rows={4} maxLength={DESCRIPTION_MAX} /></label>
607 <label>Homepage URL<input name="homepage_url" class="input" placeholder="https://…" /></label>
608 <label>Icon URL<input name="icon_url" class="input" placeholder="https://…" /></label>
609 <label>Pricing (cents / month)<input name="pricing_cents_per_month" type="number" min="0" value="0" class="input" /></label>
610 <button type="submit" class="btn btn-primary">Create</button>
611 </form>
612 </section>
613 <section>
614 <h2>Existing listings</h2>
615 {mine.length === 0 ? (
616 <p class="muted">You haven't published any agents yet.</p>
617 ) : (
618 <table class="table">
619 <thead><tr><th>Slug</th><th>Name</th><th>Kind</th><th>Installs</th><th>Published</th></tr></thead>
620 <tbody>
621 {mine.map((l) => (
622 <tr>
623 <td><code>{l.slug}</code></td>
624 <td>{l.name}</td>
625 <td>{l.kind}</td>
626 <td>{l.installCount}</td>
627 <td>{l.published ? "yes" : "no — awaiting review"}</td>
628 </tr>
629 ))}
630 </tbody>
631 </table>
632 )}
633 </section>
634 </div>
635 </Layout>
636 );
637});
638
639app.post("/settings/agent-listings", requireAuth, async (c) => {
640 const user = c.get("user")!;
641 const body = (await c.req.parseBody()) as ListingFormInput;
642 const parsed = parseListingForm(body);
643 if (!parsed.ok) return c.text(parsed.error, 400);
644
645 // Ensure the agent app for this listing exists (idempotent).
646 const appRow = await ensureAgentApp(
647 parsed.data.slug,
648 parsed.data.name,
649 AGENT_PERMISSIONS
650 );
651 if (!appRow) return c.text("could not bootstrap agent app", 500);
652
653 const [bot] = await db
654 .select({ id: appBots.id })
655 .from(appBots)
656 .where(eq(appBots.appId, appRow.id))
657 .limit(1);
658 if (!bot) return c.text("no bot row for app", 500);
659
660 const listing = await insertListing({
661 parsed: parsed.data,
662 publisherUserId: user.id,
663 appBotId: bot.id,
664 });
665 if (!listing) return c.text("listing insert failed (slug taken?)", 400);
666 return c.redirect("/settings/agent-listings");
667});
668
669app.post("/settings/agent-listings/:id/publish", requireAuth, async (c) => {
670 const user = c.get("user")!;
671 if (!(await isSiteAdmin(user.id))) {
672 return c.text("forbidden", 403);
673 }
674 const id = c.req.param("id");
675 const listing = await getListingById(id);
676 if (!listing) return c.text("not found", 404);
677 await setPublished(id, true);
678 return c.redirect("/admin/marketplace/agents");
679});
680
681app.post("/settings/agent-listings/:id/unpublish", requireAuth, async (c) => {
682 const user = c.get("user")!;
683 if (!(await isSiteAdmin(user.id))) {
684 return c.text("forbidden", 403);
685 }
686 const id = c.req.param("id");
687 const listing = await getListingById(id);
688 if (!listing) return c.text("not found", 404);
689 await setPublished(id, false);
690 return c.redirect("/admin/marketplace/agents");
691});
692
693app.get("/admin/marketplace/agents", requireAuth, async (c) => {
694 const user = c.get("user")!;
695 if (!(await isSiteAdmin(user.id))) {
696 return c.text("forbidden", 403);
697 }
698 const listings = await listAllListings();
699 return c.html(
700 <Layout title="Admin — Agent Marketplace" user={user}>
701 <div class="page-wrap">
702 <h1>Admin — Agent Marketplace</h1>
703 <table class="table">
704 <thead>
705 <tr>
706 <th>Slug</th>
707 <th>Name</th>
708 <th>Kind</th>
709 <th>Installs</th>
710 <th>Published</th>
711 <th>Actions</th>
712 </tr>
713 </thead>
714 <tbody>
715 {listings.map((l) => (
716 <tr>
717 <td><code>{l.slug}</code></td>
718 <td>{l.name}</td>
719 <td>{l.kind}</td>
720 <td>{l.installCount}</td>
721 <td>{l.published ? "yes" : "no"}</td>
722 <td>
723 <form
724 method="post"
725 action={`/settings/agent-listings/${l.id}/${l.published ? "unpublish" : "publish"}`}
726 style="display: inline"
727 >
728 <button type="submit" class="btn">
729 {l.published ? "Unpublish" : "Publish"}
730 </button>
731 </form>
732 </td>
733 </tr>
734 ))}
735 </tbody>
736 </table>
737 </div>
738 </Layout>
739 );
740});
741
742export default app;
743
744// Explicit named exports for tests / external wiring.
745export {
746 listPublishedListings as _listPublishedListings,
747 getListingBySlug as _getListingBySlug,
748 isInstalledForRepo as _isInstalledForRepo,
749};
Addedsrc/routes/cross-product.tsx+339−0View fileUnifiedSplit
@@ -0,0 +1,339 @@
1/**
2 * Block K11 — Cross-product identity routes.
3 *
4 * POST /api/v1/cross-product/token — exchange any gluecron credential
5 * (session / PAT / OAuth) for a
6 * short-lived JWT bound to a sibling
7 * product audience.
8 * GET /api/v1/cross-product/verify — no-auth verifier used by siblings.
9 * POST /api/v1/cross-product/revoke — owner revocation.
10 * GET /settings/cross-product — web UI listing active tokens.
11 *
12 * Every mint writes an `audit_log` row with action `cross_product.token_mint`.
13 */
14
15import { Hono } from "hono";
16import { sql } from "drizzle-orm";
17import { db } from "../db";
18import { auditLog } from "../db/schema";
19import { Layout } from "../views/layout";
20import { softAuth, requireAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import {
23 ALLOWED_AUDIENCES,
24 ALLOWED_SCOPES,
25 isAllowedAudience,
26 validateScopes,
27 signCrossProductToken,
28 verifyCrossProductToken,
29 revokeCrossProductToken,
30 listActiveCrossProductTokens,
31 type Audience,
32} from "../lib/cross-product-auth";
33
34const cp = new Hono<AuthEnv>();
35
36// softAuth is enough for the exchange endpoint — it runs session/PAT/OAuth
37// resolution. We then gate on `c.get("user")` so we can return JSON 401
38// rather than the redirect requireAuth emits for HTML paths.
39cp.use("/api/v1/cross-product/token", softAuth);
40cp.use("/api/v1/cross-product/revoke", softAuth);
41cp.use("/settings/cross-product", softAuth, requireAuth);
42cp.use("/settings/cross-product/*", softAuth, requireAuth);
43
44// ---------------------------------------------------------------------------
45// Helpers
46// ---------------------------------------------------------------------------
47
48function parseBearer(c: {
49 req: { header: (k: string) => string | undefined };
50}): string | null {
51 const h = c.req.header("authorization") || "";
52 if (!h.toLowerCase().startsWith("bearer ")) return null;
53 const tok = h.slice(7).trim();
54 return tok || null;
55}
56
57async function recordMintAudit(
58 userId: string,
59 audience: Audience,
60 scopes: string[],
61 jti: string,
62 ip: string | undefined,
63 userAgent: string | undefined
64): Promise<void> {
65 try {
66 await db.insert(auditLog).values({
67 userId,
68 repositoryId: null,
69 action: "cross_product.token_mint",
70 targetType: "cross_product_token",
71 targetId: jti,
72 ip: ip ?? null,
73 userAgent: userAgent ?? null,
74 metadata: JSON.stringify({ audience, scopes }),
75 });
76 } catch (err) {
77 console.error("[cross-product] audit write failed:", err);
78 }
79}
80
81// ---------------------------------------------------------------------------
82// POST /api/v1/cross-product/token
83// ---------------------------------------------------------------------------
84
85cp.post("/api/v1/cross-product/token", async (c) => {
86 const user = c.get("user");
87 if (!user) {
88 return c.json({ error: "unauthenticated" }, 401);
89 }
90
91 let body: Record<string, unknown>;
92 try {
93 body = (await c.req.json()) as Record<string, unknown>;
94 } catch {
95 return c.json({ error: "invalid_json" }, 400);
96 }
97
98 const audience = body.audience;
99 if (!isAllowedAudience(audience)) {
100 return c.json(
101 {
102 error: "unknown_audience",
103 allowed: ALLOWED_AUDIENCES,
104 },
105 400
106 );
107 }
108
109 let requestedScopes: string[] = [];
110 if (Array.isArray(body.scope)) {
111 requestedScopes = body.scope.filter(
112 (s): s is string => typeof s === "string"
113 );
114 } else if (Array.isArray(body.scopes)) {
115 requestedScopes = body.scopes.filter(
116 (s): s is string => typeof s === "string"
117 );
118 }
119 const scopes = validateScopes(requestedScopes);
120
121 const ip =
122 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
123 c.req.header("x-real-ip") ||
124 undefined;
125 const userAgent = c.req.header("user-agent") || undefined;
126
127 try {
128 const result = await signCrossProductToken({
129 userId: user.id,
130 email: user.email,
131 audience,
132 scopes,
133 });
134 await recordMintAudit(user.id, audience, scopes, result.jti, ip, userAgent);
135 return c.json({
136 token: result.token,
137 expires_at: result.expiresAt.toISOString(),
138 audience,
139 scopes: result.scopes,
140 jti: result.jti,
141 issuer: "gluecron",
142 });
143 } catch (err) {
144 console.error("[cross-product] mint failed:", err);
145 return c.json({ error: "mint_failed" }, 500);
146 }
147});
148
149// ---------------------------------------------------------------------------
150// GET /api/v1/cross-product/verify (no auth; sibling products call this)
151// ---------------------------------------------------------------------------
152
153async function handleVerify(c: {
154 req: {
155 header: (k: string) => string | undefined;
156 json: () => Promise<unknown>;
157 };
158 json: (body: unknown, status?: number) => Response;
159}): Promise<Response> {
160 let token = parseBearer(c);
161 if (!token) {
162 // Fall back to body token for clients that prefer POST.
163 try {
164 const body = (await c.req.json()) as Record<string, unknown>;
165 if (body && typeof body.token === "string") token = body.token;
166 } catch {
167 // no body — that's fine
168 }
169 }
170 if (!token) {
171 return c.json({ valid: false, error: "missing_token" }, 401);
172 }
173
174 const result = await verifyCrossProductToken(token);
175 if (!result.valid) {
176 return c.json({ valid: false, error: result.reason }, 401);
177 }
178 return c.json({
179 valid: true,
180 sub: result.sub,
181 email: result.email,
182 audience: result.audience,
183 scopes: result.scopes,
184 jti: result.jti,
185 expires_at: result.expiresAt.toISOString(),
186 issuer: "gluecron",
187 });
188}
189
190cp.get("/api/v1/cross-product/verify", (c) => handleVerify(c));
191cp.post("/api/v1/cross-product/verify", (c) => handleVerify(c));
192
193// ---------------------------------------------------------------------------
194// POST /api/v1/cross-product/revoke
195// ---------------------------------------------------------------------------
196
197cp.post("/api/v1/cross-product/revoke", async (c) => {
198 const user = c.get("user");
199 if (!user) return c.json({ error: "unauthenticated" }, 401);
200
201 let body: Record<string, unknown>;
202 try {
203 body = (await c.req.json()) as Record<string, unknown>;
204 } catch {
205 return c.json({ error: "invalid_json" }, 400);
206 }
207
208 const jti = typeof body.jti === "string" ? body.jti : "";
209 if (!jti) return c.json({ error: "jti_required" }, 400);
210
211 const ok = await revokeCrossProductToken(jti, user.id);
212 if (!ok) return c.json({ error: "not_found_or_not_owner" }, 404);
213
214 // Audit the revoke (separate action from mint).
215 try {
216 await db.insert(auditLog).values({
217 userId: user.id,
218 repositoryId: null,
219 action: "cross_product.token_revoke",
220 targetType: "cross_product_token",
221 targetId: jti,
222 metadata: null,
223 });
224 } catch (err) {
225 console.error("[cross-product] revoke audit failed:", err);
226 }
227 return c.json({ ok: true });
228});
229
230// ---------------------------------------------------------------------------
231// GET /settings/cross-product (web UI)
232// ---------------------------------------------------------------------------
233
234cp.get("/settings/cross-product", async (c) => {
235 const user = c.get("user")!;
236 let active: Awaited<ReturnType<typeof listActiveCrossProductTokens>> = [];
237 try {
238 active = await listActiveCrossProductTokens(user.id);
239 } catch {
240 active = [];
241 }
242
243 return c.html(
244 <Layout title="Cross-product identity" user={user}>
245 <div class="settings-container">
246 <h2>Cross-product identity</h2>
247 <p style="color: var(--text-muted); max-width: 640px">
248 One gluecron account signs into Crontech and Gatetest. Active
249 short-lived tokens issued for those sibling products are listed
250 below. Tokens expire in {String(15)} minutes; revoke anything
251 suspicious.
252 </p>
253
254 <h3 style="margin-top: 24px">Active tokens</h3>
255 {active.length === 0 ? (
256 <p style="color: var(--text-muted)">
257 No active cross-product tokens. They are minted on-demand by the
258 sibling products when you sign in.
259 </p>
260 ) : (
261 <div>
262 {active.map((tok) => (
263 <div class="ssh-key-item">
264 <div>
265 <strong>{tok.audience}</strong>
266 <div class="ssh-key-meta">
267 <code>{tok.jti.slice(0, 8)}...</code>
268 <span style="margin-left: 8px">
269 Scopes:{" "}
270 {tok.scopes.length > 0 ? tok.scopes.join(", ") : "none"}
271 </span>
272 <span style="margin-left: 8px">
273 Expires{" "}
274 {new Date(tok.expiresAt).toLocaleTimeString()}
275 </span>
276 </div>
277 </div>
278 <form
279 method="POST"
280 action={`/settings/cross-product/${tok.jti}/revoke`}
281 >
282 <button type="submit" class="btn btn-danger btn-sm">
283 Revoke
284 </button>
285 </form>
286 </div>
287 ))}
288 </div>
289 )}
290
291 <h3 style="margin-top: 24px">Audiences</h3>
292 <ul style="color: var(--text-muted)">
293 {ALLOWED_AUDIENCES.map((a) => (
294 <li>
295 <code>{a}</code>
296 </li>
297 ))}
298 </ul>
299
300 <h3 style="margin-top: 24px">Supported scopes</h3>
301 <ul style="color: var(--text-muted)">
302 {ALLOWED_SCOPES.map((s) => (
303 <li>
304 <code>{s}</code>
305 </li>
306 ))}
307 </ul>
308 </div>
309 </Layout>
310 );
311});
312
313// Form POST for the web UI revoke button (keeps it cookie-based).
314cp.post("/settings/cross-product/:jti/revoke", async (c) => {
315 const user = c.get("user")!;
316 const jti = c.req.param("jti");
317 const ok = await revokeCrossProductToken(jti, user.id);
318 if (ok) {
319 try {
320 await db.insert(auditLog).values({
321 userId: user.id,
322 repositoryId: null,
323 action: "cross_product.token_revoke",
324 targetType: "cross_product_token",
325 targetId: jti,
326 metadata: null,
327 });
328 } catch (err) {
329 console.error("[cross-product] revoke audit failed:", err);
330 }
331 }
332 return c.redirect("/settings/cross-product");
333});
334
335// Stub so TS narrows `sql` as imported (keeps the compiler happy if this
336// file grows helpers later — stripped by the bundler).
337void sql;
338
339export default cp;
0340