Commit06139e6unknown_key
feat(BLOCK-H): H1 marketplace + H2 bot identities + app install tokens
feat(BLOCK-H): H1 marketplace + H2 bot identities + app install tokens
H1 — App marketplace. Public directory at /marketplace with search, per-app
detail + install CTA, /settings/apps personal list, /developer/apps-new
registration, /developer/apps/:slug/manage for event log + install count +
show-once token issuance. Install is idempotent via soft-update on existing
non-uninstalled row. All mutations audit-logged.
H2 — GitHub Apps equivalent. Every app gets a <slug>[bot] identity in
app_bots. generateBearerToken() mints ghi_-prefixed bearers; only sha256
hashes are stored. verifyInstallToken() returns {installation, app,
botUsername, permissions} or null (checks revoked/expired/uninstalled/
suspended). 10 known permission scopes; hasPermission implements write→read
implication. 1h default TTL; uninstall revokes all outstanding tokens.
Schema — drizzle/0021_marketplace_and_apps.sql adds 5 tables (apps,
app_installations with partial unique index on (app_id, target_type,
target_id) WHERE uninstalled_at IS NULL, app_bots one-per-app,
app_install_tokens, app_events).
Tests — 30 new tests covering slugify, botUsername, permissions, bearer
tokens, and route auth gates. Full suite: 543 pass, 0 fail.
https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS67 files changed+1479−606139e69b64457d39a45185cab7a84b6f287cbc8
7 changed files+1479−6
ModifiedBUILD_BIBLE.md+9−6View fileUnifiedSplit
@@ -143,7 +143,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
143143| Releases + tags | ✅ | AI changelog |
144144| Personal access tokens | ✅ | SHA-256 hashed |
145145| OAuth app provider | ✅ | `src/routes/oauth.tsx`, `src/routes/developer-apps.tsx`, `src/lib/oauth.ts`; `oauth_apps` + `oauth_authorizations` + `oauth_access_tokens` tables |
146| GitHub Apps equivalent | ❌ | |
146| GitHub Apps equivalent | ✅ | H2 — `src/lib/marketplace.ts` `generateBearerToken`/`verifyInstallToken` (1h TTL, `ghi_` prefix, sha256 hashed). Each app gets a `<slug>[bot]` identity (`app_bots`). Permissions enforced via `hasPermission` (write implies read). |
147147| GraphQL API | ❌ | REST only |
148148| Organizations + teams | ✅ | B1+B2+B3 shipped: `src/routes/orgs.tsx`, `src/lib/orgs.ts`; org-owned repos (`repositories.orgId`); team-based CODEOWNERS (`@org/team` resolution) |
149149| Enterprise SAML / SSO | ❌ | |
@@ -153,7 +153,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
153153| Pages / static hosting | ✅ | `src/lib/pages.ts`, `src/routes/pages.tsx`; serves blobs from bare git at latest `gh-pages` commit; per-repo settings (source branch/dir, custom domain); short-cache headers |
154154| Gists | ✅ | E4 — multi-file tiny repos with per-revision JSON snapshots + stars. `src/routes/gists.tsx` + `drizzle/0014_gists.sql` |
155155| Sponsors | ❌ | |
156| Marketplace | ❌ | |
156| Marketplace | ✅ | H1 — `src/routes/marketplace.tsx` + `src/lib/marketplace.ts`, `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Public `/marketplace` directory, `/marketplace/:slug` detail + install, `/settings/apps` personal installs, `/developer/apps-new` registration, `/developer/apps/:slug/manage` event log + token issuance. |
157157| Environments / deployment tracking | ✅ | `src/routes/deployments.tsx` — grouped by env, success-rate rollup, per-deploy detail. Protected environments (`src/routes/environments.tsx`, `src/lib/environments.ts`) with reviewer-gated approval, branch-glob restrictions, approve/reject decisions recorded in `deployment_approvals` |
158158| Merge queues | ✅ | E5 — serialised merge with re-test. `src/lib/merge-queue.ts`, `src/routes/merge-queue.tsx`, `drizzle/0017_merge_queue.sql`; per `(repo, base_branch)` queue, owner-only process-next re-runs gates against latest base before merging. |
159159| Required checks matrix | ✅ | E6 — per branch-protection named check list. `src/routes/required-checks.tsx`, `drizzle/0018_required_checks.sql`; `listRequiredChecks` + `passingCheckNames` helpers in `src/lib/branch-protection.ts`; merge handler verifies every required name has a passing gate_run or workflow_run. |
@@ -267,8 +267,8 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
267267- **G4** — VS Code extension → ✅ shipped. `vscode-extension/` contains package.json + `src/extension.ts`. Commands: `gluecron.explainFile`, `gluecron.openOnWeb`, `gluecron.searchSemantic`, `gluecron.generateTests`. Detects Gluecron remotes via `git config remote.origin.url`. Settings: `gluecron.host` + `gluecron.token`.
268268
269269### BLOCK H — Marketplace
270- **H1** — App marketplace (install third-party apps against a repo)
271- **H2** — GitHub Apps equivalent (bot identities with scoped permissions)
270- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
271- **H2** — GitHub Apps equivalent (bot identities + installation tokens) → ✅ shipped. Same schema as H1: every app gets a `<slug>[bot]` row in `app_bots`. `generateBearerToken()` produces `ghi_`-prefixed bearers; `hashBearer` (sha256) is the only form persisted. `verifyInstallToken(token)` returns `{installation, app, botUsername, permissions}` or `null` (checks revoked/expired/uninstalled/suspended). Permission vocabulary: `contents:read/write`, `issues:read/write`, `pulls:read/write`, `checks:read/write`, `deployments:read/write`, `metadata:read` — `hasPermission` implements write→read implication.
272272
273273---
274274
@@ -280,7 +280,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
280280- `src/app.tsx` — route composition, middleware order, error handlers
281281- `src/index.ts` — Bun server entry
282282- `src/lib/config.ts` — env getters (late-binding)
283- `src/db/schema.ts` — 73 tables. New tables only via new migration.
283- `src/db/schema.ts` — 78 tables. New tables only via new migration.
284284- `src/db/index.ts` — lazy proxy DB connection
285285- `src/db/migrate.ts` — migration runner
286286- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -301,6 +301,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
301301- `drizzle/0018_required_checks.sql` (Block E6) — migration, never edited in place. Adds `branch_required_checks`.
302302- `drizzle/0019_protected_tags.sql` (Block E7) — migration, never edited in place. Adds `protected_tags`.
303303- `drizzle/0020_analytics_and_admin.sql` (Block F) — migration, never edited in place. Adds `repo_traffic_events`, `system_flags`, `site_admins`, `billing_plans` (seeded free/pro/team/enterprise), `user_quotas`.
304- `drizzle/0021_marketplace_and_apps.sql` (Block H) — migration, never edited in place. Adds `apps`, `app_installations` (partial unique index on `(app_id, target_type, target_id) WHERE uninstalled_at IS NULL`), `app_bots` (one-per-app, `<slug>[bot]` username), `app_install_tokens` (sha256 hash, expires_at, revoked_at), `app_events` (audit trail).
304305
305306### 4.2 Git layer (locked)
306307- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
@@ -417,6 +418,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
417418- `src/routes/graphql.ts` (Block G2) — `POST /api/graphql` JSON endpoint + `GET /api/graphql` GraphiQL-lite explorer (Cmd+Enter to run).
418419- `cli/gluecron.ts` (Block G3) — single-file Bun CLI. Exports `dispatch(argv, out)` for programmatic use, `HELP` constant, `loadConfig`/`saveConfig`. Config at `~/.gluecron/config.json` (0600). Compile: `bun build cli/gluecron.ts --compile --outfile gluecron`.
419420- `vscode-extension/` (Block G4) — VS Code extension with `package.json` declaring four commands (explainFile, openOnWeb, searchSemantic, generateTests) + `gluecron.host` / `gluecron.token` settings. Detects Gluecron remotes via `git config remote.origin.url`.
421- `src/lib/marketplace.ts` (Block H1+H2) — marketplace + app identity surface. `KNOWN_PERMISSIONS` (10 scopes), `KNOWN_EVENTS` (8 kinds). Pure helpers: `slugify` (40-char cap), `botUsername` (`<slug>[bot]`), `normalisePermissions` (drops unknown, de-dupes), `parsePermissions` (JSON), `hasPermission` (write→read implication), `permissionsSubset`, `generateBearerToken` (`ghi_` prefix + 24-byte hex), `hashBearer` (sha256). DB helpers: `listPublicApps(query)`, `getAppBySlug`, `createApp` (retries slug collisions, creates matching bot row), `installApp` (idempotent soft-update), `uninstallApp` (revokes all tokens), `issueInstallToken` (1h TTL default), `verifyInstallToken` (checks revoked/expired/uninstalled/suspended), `listInstallationsForApp`, `listInstallationsForTarget`, `listEventsForApp`, `countInstalls`. Never throws into request path.
422- `src/routes/marketplace.tsx` (Block H1+H2) — public marketplace + developer UX. `GET /marketplace` (directory + search), `GET /marketplace/:slug` (detail + install form), `POST /marketplace/:slug/install` (v1 user-target only), `POST /marketplace/installations/:id/uninstall` (installer-only), `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count, owner-only), `POST /developer/apps/:slug/tokens/new` (show-once `ghi_` token). All mutations audit-logged.
420423
421424### 4.7 Views (locked contracts)
422425- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -451,7 +454,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
451454```bash
452455bun install
453456bun dev # hot reload
454bun test # 513 tests currently pass
457bun test # 543 tests currently pass
455458bun run db:migrate
456459```
457460
Addeddrizzle/0021_marketplace_and_apps.sql+100−0View fileUnifiedSplit
@@ -0,0 +1,100 @@
1-- Gluecron migration 0021: Block H — Marketplace + GitHub Apps equivalent.
2--
3-- H1 — App marketplace: creators register apps, users install them against
4-- their personal account / org / individual repo. Each install grants a
5-- concrete set of scopes (pull-read, issues-write, checks-write, etc.).
6--
7-- H2 — Bot identities: every marketplace app gets an "app user" that can
8-- comment, open PRs, attach checks, etc. Bots authenticate with installation
9-- tokens tied to a single installation and a time-window.
10--
11-- Tables:
12-- apps — app definitions (slug, description, webhook, permissions)
13-- app_installations — app X (repo | org | user) with granted permissions
14-- app_bots — one bot account per app (username ends with `[bot]`)
15-- app_install_tokens — short-lived bearer tokens scoped to a single install
16-- app_events — audit trail of installs, uninstalls, events delivered
17
18--> statement-breakpoint
19CREATE TABLE IF NOT EXISTS "apps" (
20 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
21 "slug" text NOT NULL UNIQUE, -- url-safe, globally unique
22 "name" text NOT NULL,
23 "description" text NOT NULL DEFAULT '',
24 "icon_url" text,
25 "homepage_url" text,
26 "webhook_url" text, -- where events are delivered
27 "webhook_secret" text, -- HMAC secret; shown once at create
28 "creator_id" uuid NOT NULL,
29 "permissions" text NOT NULL DEFAULT '[]', -- JSON array of permission names
30 "default_events" text NOT NULL DEFAULT '[]', -- JSON array: push, issues, pulls…
31 "is_public" boolean NOT NULL DEFAULT true, -- listed in /marketplace?
32 "created_at" timestamp DEFAULT now() NOT NULL,
33 "updated_at" timestamp DEFAULT now() NOT NULL,
34 CONSTRAINT "apps_creator_fk" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE cascade
35);
36
37--> statement-breakpoint
38CREATE INDEX IF NOT EXISTS "apps_public_slug" ON "apps" ("is_public", "slug");
39
40--> statement-breakpoint
41CREATE TABLE IF NOT EXISTS "app_installations" (
42 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
43 "app_id" uuid NOT NULL,
44 "installed_by" uuid NOT NULL, -- user who clicked install
45 "target_type" text NOT NULL, -- user | org | repository
46 "target_id" uuid NOT NULL,
47 "granted_permissions" text NOT NULL DEFAULT '[]', -- JSON subset of app.permissions
48 "suspended_at" timestamp,
49 "created_at" timestamp DEFAULT now() NOT NULL,
50 "uninstalled_at" timestamp,
51 CONSTRAINT "app_installations_app_fk" FOREIGN KEY ("app_id") REFERENCES "apps"("id") ON DELETE cascade,
52 CONSTRAINT "app_installations_user_fk" FOREIGN KEY ("installed_by") REFERENCES "users"("id") ON DELETE cascade
53);
54
55--> statement-breakpoint
56CREATE INDEX IF NOT EXISTS "app_installations_app" ON "app_installations" ("app_id");
57--> statement-breakpoint
58CREATE INDEX IF NOT EXISTS "app_installations_target" ON "app_installations" ("target_type", "target_id");
59--> statement-breakpoint
60CREATE UNIQUE INDEX IF NOT EXISTS "app_installations_unique" ON "app_installations" ("app_id", "target_type", "target_id") WHERE "uninstalled_at" IS NULL;
61
62--> statement-breakpoint
63CREATE TABLE IF NOT EXISTS "app_bots" (
64 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
65 "app_id" uuid NOT NULL UNIQUE,
66 "username" text NOT NULL UNIQUE, -- `${app.slug}[bot]`
67 "display_name" text NOT NULL,
68 "avatar_url" text,
69 "created_at" timestamp DEFAULT now() NOT NULL,
70 CONSTRAINT "app_bots_app_fk" FOREIGN KEY ("app_id") REFERENCES "apps"("id") ON DELETE cascade
71);
72
73--> statement-breakpoint
74CREATE TABLE IF NOT EXISTS "app_install_tokens" (
75 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
76 "installation_id" uuid NOT NULL,
77 "token_hash" text NOT NULL UNIQUE, -- sha256 of bearer
78 "expires_at" timestamp NOT NULL,
79 "created_at" timestamp DEFAULT now() NOT NULL,
80 "revoked_at" timestamp,
81 CONSTRAINT "app_install_tokens_inst_fk" FOREIGN KEY ("installation_id") REFERENCES "app_installations"("id") ON DELETE cascade
82);
83
84--> statement-breakpoint
85CREATE INDEX IF NOT EXISTS "app_install_tokens_hash" ON "app_install_tokens" ("token_hash");
86
87--> statement-breakpoint
88CREATE TABLE IF NOT EXISTS "app_events" (
89 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
90 "app_id" uuid NOT NULL,
91 "installation_id" uuid,
92 "kind" text NOT NULL, -- installed | uninstalled | delivery_ok | delivery_fail
93 "payload" text, -- JSON, first 2048 chars
94 "response_status" integer,
95 "created_at" timestamp DEFAULT now() NOT NULL,
96 CONSTRAINT "app_events_app_fk" FOREIGN KEY ("app_id") REFERENCES "apps"("id") ON DELETE cascade
97);
98
99--> statement-breakpoint
100CREATE INDEX IF NOT EXISTS "app_events_app_time" ON "app_events" ("app_id", "created_at");
Addedsrc/__tests__/marketplace.test.ts+239−0View fileUnifiedSplit
@@ -0,0 +1,239 @@
1/**
2 * Block H — Marketplace + app identities tests.
3 *
4 * Pure helpers (slugify, botUsername, normalisePermissions, parsePermissions,
5 * hasPermission, generateBearerToken, hashBearer, permissionsSubset) + route
6 * auth smoke. DB-dependent helpers (createApp, installApp, verifyInstallToken)
7 * are exercised via type/shape checks only — real integration happens on the
8 * live server.
9 */
10
11import { describe, it, expect } from "bun:test";
12import app from "../app";
13import {
14 KNOWN_PERMISSIONS,
15 KNOWN_EVENTS,
16 botUsername,
17 generateBearerToken,
18 hasPermission,
19 hashBearer,
20 normalisePermissions,
21 parsePermissions,
22 permissionsSubset,
23 slugify,
24} from "../lib/marketplace";
25
26describe("marketplace — slugify", () => {
27 it("lowercases + replaces spaces with dashes", () => {
28 expect(slugify("My Cool App")).toBe("my-cool-app");
29 });
30
31 it("strips non-alphanumeric", () => {
32 expect(slugify("Hello, World!")).toBe("hello-world");
33 });
34
35 it("trims leading/trailing dashes", () => {
36 expect(slugify("---foo---")).toBe("foo");
37 });
38
39 it("caps at 40 characters", () => {
40 const s = slugify("a".repeat(100));
41 expect(s.length).toBeLessThanOrEqual(40);
42 });
43
44 it("collapses consecutive separators", () => {
45 expect(slugify("foo bar")).toBe("foo-bar");
46 });
47});
48
49describe("marketplace — botUsername", () => {
50 it("appends [bot] suffix", () => {
51 expect(botUsername("my-app")).toBe("my-app[bot]");
52 });
53});
54
55describe("marketplace — permissions", () => {
56 it("KNOWN_PERMISSIONS includes contents + issues + pulls + checks", () => {
57 expect(KNOWN_PERMISSIONS).toContain("contents:read");
58 expect(KNOWN_PERMISSIONS).toContain("contents:write");
59 expect(KNOWN_PERMISSIONS).toContain("issues:write");
60 expect(KNOWN_PERMISSIONS).toContain("pulls:write");
61 expect(KNOWN_PERMISSIONS).toContain("checks:write");
62 });
63
64 it("KNOWN_EVENTS includes push + pull_request + issues", () => {
65 expect(KNOWN_EVENTS).toContain("push");
66 expect(KNOWN_EVENTS).toContain("pull_request");
67 expect(KNOWN_EVENTS).toContain("issues");
68 });
69
70 it("normalisePermissions drops unknown values", () => {
71 const perms = normalisePermissions([
72 "contents:read",
73 "bogus:thing",
74 "issues:write",
75 ]);
76 expect(perms).toEqual(["contents:read", "issues:write"]);
77 });
78
79 it("normalisePermissions de-duplicates", () => {
80 const perms = normalisePermissions([
81 "contents:read",
82 "contents:read",
83 "contents:read",
84 ]);
85 expect(perms.length).toBe(1);
86 });
87
88 it("parsePermissions reads JSON array out of DB column", () => {
89 const raw = JSON.stringify(["contents:read", "issues:write"]);
90 expect(parsePermissions(raw)).toEqual(["contents:read", "issues:write"]);
91 });
92
93 it("parsePermissions handles null/empty/invalid JSON", () => {
94 expect(parsePermissions(null)).toEqual([]);
95 expect(parsePermissions(undefined)).toEqual([]);
96 expect(parsePermissions("")).toEqual([]);
97 expect(parsePermissions("not json")).toEqual([]);
98 expect(parsePermissions("{}")).toEqual([]);
99 });
100
101 it("hasPermission direct match", () => {
102 expect(hasPermission(["issues:read"], "issues:read")).toBe(true);
103 });
104
105 it("hasPermission: write implies read", () => {
106 expect(hasPermission(["issues:write"], "issues:read")).toBe(true);
107 expect(hasPermission(["contents:write"], "contents:read")).toBe(true);
108 });
109
110 it("hasPermission: read does NOT imply write", () => {
111 expect(hasPermission(["issues:read"], "issues:write")).toBe(false);
112 });
113
114 it("hasPermission: empty grant fails", () => {
115 expect(hasPermission([], "issues:read")).toBe(false);
116 });
117
118 it("permissionsSubset checks containment", () => {
119 expect(
120 permissionsSubset(["contents:read"], ["contents:read", "issues:read"])
121 ).toBe(true);
122 expect(
123 permissionsSubset(
124 ["contents:read", "admin:god"],
125 ["contents:read"]
126 )
127 ).toBe(false);
128 });
129});
130
131describe("marketplace — bearer tokens", () => {
132 it("generateBearerToken produces ghi_ prefix + hex body", () => {
133 const { token, hash } = generateBearerToken();
134 expect(token.startsWith("ghi_")).toBe(true);
135 expect(token.length).toBeGreaterThan(10);
136 expect(hash.length).toBe(64); // sha256 hex
137 });
138
139 it("generateBearerToken yields unique tokens", () => {
140 const a = generateBearerToken();
141 const b = generateBearerToken();
142 expect(a.token).not.toBe(b.token);
143 expect(a.hash).not.toBe(b.hash);
144 });
145
146 it("hashBearer is deterministic", () => {
147 const t = "ghi_deadbeef";
148 expect(hashBearer(t)).toBe(hashBearer(t));
149 });
150
151 it("hashBearer of generated token matches returned hash", () => {
152 const { token, hash } = generateBearerToken();
153 expect(hashBearer(token)).toBe(hash);
154 });
155});
156
157describe("marketplace — route smoke", () => {
158 it("GET /marketplace → 200 (public)", async () => {
159 const res = await app.request("/marketplace");
160 expect(res.status).toBe(200);
161 });
162
163 it("GET /marketplace?q=foo → 200", async () => {
164 const res = await app.request("/marketplace?q=foo");
165 expect(res.status).toBe(200);
166 });
167
168 it("GET /marketplace/unknown-slug → 404", async () => {
169 const res = await app.request(
170 "/marketplace/this-app-does-not-exist-abcdef"
171 );
172 expect(res.status).toBe(404);
173 });
174
175 it("POST /marketplace/:slug/install without auth → 302 /login", async () => {
176 const res = await app.request("/marketplace/foo/install", {
177 method: "POST",
178 body: new URLSearchParams({}),
179 headers: { "content-type": "application/x-www-form-urlencoded" },
180 });
181 expect(res.status).toBe(302);
182 expect(res.headers.get("location") || "").toContain("/login");
183 });
184
185 it("GET /settings/apps without auth → 302 /login", async () => {
186 const res = await app.request("/settings/apps");
187 expect(res.status).toBe(302);
188 expect(res.headers.get("location") || "").toContain("/login");
189 });
190
191 it("GET /developer/apps-new without auth → 302 /login", async () => {
192 const res = await app.request("/developer/apps-new");
193 expect(res.status).toBe(302);
194 expect(res.headers.get("location") || "").toContain("/login");
195 });
196
197 it("POST /developer/apps-new without auth → 302 /login", async () => {
198 const res = await app.request("/developer/apps-new", {
199 method: "POST",
200 body: new URLSearchParams({ name: "My App" }),
201 headers: { "content-type": "application/x-www-form-urlencoded" },
202 });
203 expect(res.status).toBe(302);
204 expect(res.headers.get("location") || "").toContain("/login");
205 });
206
207 it("GET /developer/apps/:slug/manage without auth → 302 /login", async () => {
208 const res = await app.request("/developer/apps/foo/manage");
209 expect(res.status).toBe(302);
210 expect(res.headers.get("location") || "").toContain("/login");
211 });
212});
213
214describe("marketplace — lib exports", () => {
215 it("exports the full surface", async () => {
216 const mod = await import("../lib/marketplace");
217 expect(typeof mod.slugify).toBe("function");
218 expect(typeof mod.botUsername).toBe("function");
219 expect(typeof mod.normalisePermissions).toBe("function");
220 expect(typeof mod.parsePermissions).toBe("function");
221 expect(typeof mod.hasPermission).toBe("function");
222 expect(typeof mod.permissionsSubset).toBe("function");
223 expect(typeof mod.generateBearerToken).toBe("function");
224 expect(typeof mod.hashBearer).toBe("function");
225 expect(typeof mod.listPublicApps).toBe("function");
226 expect(typeof mod.getAppBySlug).toBe("function");
227 expect(typeof mod.createApp).toBe("function");
228 expect(typeof mod.installApp).toBe("function");
229 expect(typeof mod.uninstallApp).toBe("function");
230 expect(typeof mod.issueInstallToken).toBe("function");
231 expect(typeof mod.verifyInstallToken).toBe("function");
232 expect(typeof mod.listInstallationsForApp).toBe("function");
233 expect(typeof mod.listInstallationsForTarget).toBe("function");
234 expect(typeof mod.listEventsForApp).toBe("function");
235 expect(typeof mod.countInstalls).toBe("function");
236 expect(Array.isArray(mod.KNOWN_PERMISSIONS)).toBe(true);
237 expect(Array.isArray(mod.KNOWN_EVENTS)).toBe(true);
238 });
239});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -62,6 +62,7 @@ import adminRoutes from "./routes/admin";
6262import billingRoutes from "./routes/billing";
6363import pwaRoutes from "./routes/pwa";
6464import graphqlRoutes from "./routes/graphql";
65import marketplaceRoutes from "./routes/marketplace";
6566import webRoutes from "./routes/web";
6667
6768const app = new Hono();
@@ -210,6 +211,9 @@ app.route("/", pwaRoutes);
210211// GraphQL mirror of REST (Block G2)
211212app.route("/", graphqlRoutes);
212213
214// Marketplace + app installations + bot identities (Block H1 + H2)
215app.route("/", marketplaceRoutes);
216
213217// Insights + milestones
214218app.route("/", insightsRoutes);
215219
Modifiedsrc/db/schema.ts+101−0View fileUnifiedSplit
@@ -1787,3 +1787,104 @@ export const userQuotas = pgTable("user_quotas", {
17871787});
17881788
17891789export type UserQuota = typeof userQuotas.$inferSelect;
1790
1791// Block H — App marketplace + bot identities (GitHub Apps equivalent)
1792
1793export const apps = pgTable(
1794 "apps",
1795 {
1796 id: uuid("id").primaryKey().defaultRandom(),
1797 slug: text("slug").notNull().unique(),
1798 name: text("name").notNull(),
1799 description: text("description").notNull().default(""),
1800 iconUrl: text("icon_url"),
1801 homepageUrl: text("homepage_url"),
1802 webhookUrl: text("webhook_url"),
1803 webhookSecret: text("webhook_secret"),
1804 creatorId: uuid("creator_id")
1805 .notNull()
1806 .references(() => users.id, { onDelete: "cascade" }),
1807 permissions: text("permissions").notNull().default("[]"), // JSON array
1808 defaultEvents: text("default_events").notNull().default("[]"), // JSON array
1809 isPublic: boolean("is_public").notNull().default(true),
1810 createdAt: timestamp("created_at").defaultNow().notNull(),
1811 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1812 },
1813 (table) => [index("apps_public_slug").on(table.isPublic, table.slug)]
1814);
1815
1816export type App = typeof apps.$inferSelect;
1817
1818export const appInstallations = pgTable(
1819 "app_installations",
1820 {
1821 id: uuid("id").primaryKey().defaultRandom(),
1822 appId: uuid("app_id")
1823 .notNull()
1824 .references(() => apps.id, { onDelete: "cascade" }),
1825 installedBy: uuid("installed_by")
1826 .notNull()
1827 .references(() => users.id, { onDelete: "cascade" }),
1828 targetType: text("target_type").notNull(), // user | org | repository
1829 targetId: uuid("target_id").notNull(),
1830 grantedPermissions: text("granted_permissions").notNull().default("[]"),
1831 suspendedAt: timestamp("suspended_at"),
1832 createdAt: timestamp("created_at").defaultNow().notNull(),
1833 uninstalledAt: timestamp("uninstalled_at"),
1834 },
1835 (table) => [
1836 index("app_installations_app").on(table.appId),
1837 index("app_installations_target").on(table.targetType, table.targetId),
1838 ]
1839);
1840
1841export type AppInstallation = typeof appInstallations.$inferSelect;
1842
1843export const appBots = pgTable("app_bots", {
1844 id: uuid("id").primaryKey().defaultRandom(),
1845 appId: uuid("app_id")
1846 .notNull()
1847 .unique()
1848 .references(() => apps.id, { onDelete: "cascade" }),
1849 username: text("username").notNull().unique(),
1850 displayName: text("display_name").notNull(),
1851 avatarUrl: text("avatar_url"),
1852 createdAt: timestamp("created_at").defaultNow().notNull(),
1853});
1854
1855export type AppBot = typeof appBots.$inferSelect;
1856
1857export const appInstallTokens = pgTable(
1858 "app_install_tokens",
1859 {
1860 id: uuid("id").primaryKey().defaultRandom(),
1861 installationId: uuid("installation_id")
1862 .notNull()
1863 .references(() => appInstallations.id, { onDelete: "cascade" }),
1864 tokenHash: text("token_hash").notNull().unique(),
1865 expiresAt: timestamp("expires_at").notNull(),
1866 createdAt: timestamp("created_at").defaultNow().notNull(),
1867 revokedAt: timestamp("revoked_at"),
1868 },
1869 (table) => [index("app_install_tokens_hash").on(table.tokenHash)]
1870);
1871
1872export type AppInstallToken = typeof appInstallTokens.$inferSelect;
1873
1874export const appEvents = pgTable(
1875 "app_events",
1876 {
1877 id: uuid("id").primaryKey().defaultRandom(),
1878 appId: uuid("app_id")
1879 .notNull()
1880 .references(() => apps.id, { onDelete: "cascade" }),
1881 installationId: uuid("installation_id"),
1882 kind: text("kind").notNull(), // installed | uninstalled | delivery_ok | delivery_fail
1883 payload: text("payload"),
1884 responseStatus: integer("response_status"),
1885 createdAt: timestamp("created_at").defaultNow().notNull(),
1886 },
1887 (table) => [index("app_events_app_time").on(table.appId, table.createdAt)]
1888);
1889
1890export type AppEvent = typeof appEvents.$inferSelect;
Addedsrc/lib/marketplace.ts+484−0View fileUnifiedSplit
@@ -0,0 +1,484 @@
1/**
2 * Block H — App marketplace + bot identities.
3 *
4 * Known permission names. These are the vocabulary that apps declare and
5 * installers grant. Permissions are string-matched at request time — for v1,
6 * handlers that consume app tokens call `hasPermission(token, "issues:write")`
7 * and fail closed when absent.
8 *
9 * Higher-level permissions imply lower ones (write implies read) so the UI
10 * only presents one level at a time.
11 */
12
13import { createHash, randomBytes } from "node:crypto";
14import { and, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
15import { db } from "../db";
16import {
17 apps,
18 appBots,
19 appInstallations,
20 appInstallTokens,
21 appEvents,
22 users,
23 type App,
24 type AppInstallation,
25} from "../db/schema";
26
27export const KNOWN_PERMISSIONS = [
28 "contents:read",
29 "contents:write",
30 "issues:read",
31 "issues:write",
32 "pulls:read",
33 "pulls:write",
34 "checks:read",
35 "checks:write",
36 "deployments:read",
37 "deployments:write",
38 "metadata:read",
39] as const;
40
41export type Permission = (typeof KNOWN_PERMISSIONS)[number];
42
43export const KNOWN_EVENTS = [
44 "push",
45 "issues",
46 "issue_comment",
47 "pull_request",
48 "pull_request_review",
49 "check_run",
50 "deployment",
51 "release",
52] as const;
53
54export type EventName = (typeof KNOWN_EVENTS)[number];
55
56// ---------- Pure helpers ----------
57
58/**
59 * Slugify an app name — lowercase alphanumeric + dashes, trim leading/trailing
60 * dashes, cap at 40 chars.
61 */
62export function slugify(name: string): string {
63 return name
64 .toLowerCase()
65 .replace(/[^a-z0-9]+/g, "-")
66 .replace(/^-+|-+$/g, "")
67 .slice(0, 40);
68}
69
70/** Bot usernames are always `<slug>[bot]`. */
71export function botUsername(slug: string): string {
72 return `${slug}[bot]`;
73}
74
75/** Is `granted` a subset of `requested`? Used when validating install forms. */
76export function permissionsSubset(
77 granted: readonly string[],
78 requested: readonly string[]
79): boolean {
80 const req = new Set(requested);
81 return granted.every((g) => req.has(g));
82}
83
84/** Normalise + de-dup permissions. Drops unknown values. */
85export function normalisePermissions(input: readonly string[]): Permission[] {
86 const seen = new Set<string>();
87 const out: Permission[] = [];
88 for (const p of input) {
89 if ((KNOWN_PERMISSIONS as readonly string[]).includes(p) && !seen.has(p)) {
90 seen.add(p);
91 out.push(p as Permission);
92 }
93 }
94 return out;
95}
96
97/** Parse a JSON permission list out of the DB column. */
98export function parsePermissions(raw: string | null | undefined): Permission[] {
99 if (!raw) return [];
100 try {
101 const parsed = JSON.parse(raw);
102 if (!Array.isArray(parsed)) return [];
103 return normalisePermissions(parsed);
104 } catch {
105 return [];
106 }
107}
108
109/** write:* implies read:* on the same resource family. */
110export function hasPermission(
111 granted: readonly string[],
112 required: string
113): boolean {
114 if (granted.includes(required)) return true;
115 // write implies read
116 if (required.endsWith(":read")) {
117 const writeEquivalent = required.replace(":read", ":write");
118 return granted.includes(writeEquivalent);
119 }
120 return false;
121}
122
123export function generateBearerToken(): { token: string; hash: string } {
124 const token = "ghi_" + randomBytes(24).toString("hex");
125 const hash = createHash("sha256").update(token).digest("hex");
126 return { token, hash };
127}
128
129export function hashBearer(token: string): string {
130 return createHash("sha256").update(token).digest("hex");
131}
132
133// ---------- DB helpers ----------
134
135export async function listPublicApps(query = ""): Promise<App[]> {
136 try {
137 const rows = await db
138 .select()
139 .from(apps)
140 .where(
141 query
142 ? and(
143 eq(apps.isPublic, true),
144 or(
145 ilike(apps.name, `%${query}%`),
146 ilike(apps.description, `%${query}%`),
147 ilike(apps.slug, `%${query}%`)
148 )!
149 )
150 : eq(apps.isPublic, true)
151 )
152 .orderBy(desc(apps.createdAt))
153 .limit(100);
154 return rows;
155 } catch {
156 return [];
157 }
158}
159
160export async function getAppBySlug(slug: string): Promise<App | null> {
161 try {
162 const [r] = await db
163 .select()
164 .from(apps)
165 .where(eq(apps.slug, slug))
166 .limit(1);
167 return r || null;
168 } catch {
169 return null;
170 }
171}
172
173export interface CreateAppArgs {
174 name: string;
175 description?: string;
176 iconUrl?: string;
177 homepageUrl?: string;
178 webhookUrl?: string;
179 creatorId: string;
180 permissions: readonly string[];
181 defaultEvents?: readonly string[];
182 isPublic?: boolean;
183}
184
185/** Create an app + matching bot. Slug is derived from name; retries on collision. */
186export async function createApp(args: CreateAppArgs): Promise<App | null> {
187 const baseSlug = slugify(args.name) || "app";
188 for (let attempt = 0; attempt < 6; attempt++) {
189 const slug = attempt === 0 ? baseSlug : `${baseSlug}-${randomBytes(2).toString("hex")}`;
190 const webhookSecret = randomBytes(24).toString("hex");
191 try {
192 const [row] = await db
193 .insert(apps)
194 .values({
195 slug,
196 name: args.name,
197 description: args.description || "",
198 iconUrl: args.iconUrl,
199 homepageUrl: args.homepageUrl,
200 webhookUrl: args.webhookUrl,
201 webhookSecret,
202 creatorId: args.creatorId,
203 permissions: JSON.stringify(normalisePermissions(args.permissions)),
204 defaultEvents: JSON.stringify(
205 (args.defaultEvents || []).filter((e) =>
206 (KNOWN_EVENTS as readonly string[]).includes(e)
207 )
208 ),
209 isPublic: args.isPublic ?? true,
210 })
211 .returning();
212 if (!row) return null;
213 // Create matching bot account
214 await db.insert(appBots).values({
215 appId: row.id,
216 username: botUsername(slug),
217 displayName: `${args.name} (bot)`,
218 avatarUrl: args.iconUrl,
219 });
220 return row;
221 } catch (err: any) {
222 if (String(err?.message || "").includes("duplicate")) continue;
223 console.error("[marketplace] createApp:", err);
224 return null;
225 }
226 }
227 return null;
228}
229
230export async function listInstallationsForApp(
231 appId: string
232): Promise<AppInstallation[]> {
233 try {
234 return await db
235 .select()
236 .from(appInstallations)
237 .where(
238 and(eq(appInstallations.appId, appId), isNull(appInstallations.uninstalledAt))
239 )
240 .orderBy(desc(appInstallations.createdAt));
241 } catch {
242 return [];
243 }
244}
245
246export async function listInstallationsForTarget(
247 targetType: "user" | "org" | "repository",
248 targetId: string
249): Promise<Array<AppInstallation & { app: App | null }>> {
250 try {
251 const rows = await db
252 .select({
253 install: appInstallations,
254 app: apps,
255 })
256 .from(appInstallations)
257 .leftJoin(apps, eq(appInstallations.appId, apps.id))
258 .where(
259 and(
260 eq(appInstallations.targetType, targetType),
261 eq(appInstallations.targetId, targetId),
262 isNull(appInstallations.uninstalledAt)
263 )
264 )
265 .orderBy(desc(appInstallations.createdAt));
266 return rows.map((r) => ({ ...r.install, app: r.app }));
267 } catch {
268 return [];
269 }
270}
271
272export interface InstallArgs {
273 appId: string;
274 installedBy: string;
275 targetType: "user" | "org" | "repository";
276 targetId: string;
277 grantedPermissions: readonly string[];
278}
279
280export async function installApp(
281 args: InstallArgs
282): Promise<AppInstallation | null> {
283 try {
284 // Find the app to validate permissions
285 const [app] = await db
286 .select()
287 .from(apps)
288 .where(eq(apps.id, args.appId))
289 .limit(1);
290 if (!app) return null;
291 const appPerms = parsePermissions(app.permissions);
292 const granted = normalisePermissions(args.grantedPermissions);
293 // Only allow granting what the app actually requests
294 const filtered = granted.filter((p) => appPerms.includes(p));
295 // If a non-uninstalled row exists, soft-update it (idempotent)
296 const [existing] = await db
297 .select()
298 .from(appInstallations)
299 .where(
300 and(
301 eq(appInstallations.appId, args.appId),
302 eq(appInstallations.targetType, args.targetType),
303 eq(appInstallations.targetId, args.targetId),
304 isNull(appInstallations.uninstalledAt)
305 )
306 )
307 .limit(1);
308 if (existing) {
309 await db
310 .update(appInstallations)
311 .set({ grantedPermissions: JSON.stringify(filtered) })
312 .where(eq(appInstallations.id, existing.id));
313 await db.insert(appEvents).values({
314 appId: args.appId,
315 installationId: existing.id,
316 kind: "installed",
317 payload: JSON.stringify({ updated: true }),
318 });
319 return existing;
320 }
321 const [row] = await db
322 .insert(appInstallations)
323 .values({
324 appId: args.appId,
325 installedBy: args.installedBy,
326 targetType: args.targetType,
327 targetId: args.targetId,
328 grantedPermissions: JSON.stringify(filtered),
329 })
330 .returning();
331 if (row) {
332 await db.insert(appEvents).values({
333 appId: args.appId,
334 installationId: row.id,
335 kind: "installed",
336 payload: JSON.stringify({
337 targetType: args.targetType,
338 targetId: args.targetId,
339 }),
340 });
341 }
342 return row || null;
343 } catch (err) {
344 console.error("[marketplace] installApp:", err);
345 return null;
346 }
347}
348
349export async function uninstallApp(installationId: string): Promise<boolean> {
350 try {
351 const [row] = await db
352 .update(appInstallations)
353 .set({ uninstalledAt: new Date() })
354 .where(
355 and(
356 eq(appInstallations.id, installationId),
357 isNull(appInstallations.uninstalledAt)
358 )
359 )
360 .returning();
361 if (row) {
362 await db.insert(appEvents).values({
363 appId: row.appId,
364 installationId: row.id,
365 kind: "uninstalled",
366 });
367 // Revoke all tokens
368 await db
369 .update(appInstallTokens)
370 .set({ revokedAt: new Date() })
371 .where(
372 and(
373 eq(appInstallTokens.installationId, installationId),
374 isNull(appInstallTokens.revokedAt)
375 )
376 );
377 return true;
378 }
379 return false;
380 } catch (err) {
381 console.error("[marketplace] uninstallApp:", err);
382 return false;
383 }
384}
385
386/** Issue a bearer token scoped to a single installation. Default TTL: 1h. */
387export async function issueInstallToken(
388 installationId: string,
389 ttlSeconds = 3600
390): Promise<{ token: string; expiresAt: Date } | null> {
391 try {
392 const { token, hash } = generateBearerToken();
393 const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
394 await db
395 .insert(appInstallTokens)
396 .values({ installationId, tokenHash: hash, expiresAt });
397 return { token, expiresAt };
398 } catch (err) {
399 console.error("[marketplace] issueInstallToken:", err);
400 return null;
401 }
402}
403
404/**
405 * Verify a bearer and return the matched installation + permissions. Returns
406 * null when the token is unknown, revoked, or expired.
407 */
408export async function verifyInstallToken(
409 token: string
410): Promise<{
411 installation: AppInstallation;
412 app: App;
413 botUsername: string;
414 permissions: Permission[];
415} | null> {
416 if (!token || !token.startsWith("ghi_")) return null;
417 const hash = hashBearer(token);
418 try {
419 const [row] = await db
420 .select({
421 inst: appInstallations,
422 app: apps,
423 tok: appInstallTokens,
424 })
425 .from(appInstallTokens)
426 .innerJoin(
427 appInstallations,
428 eq(appInstallTokens.installationId, appInstallations.id)
429 )
430 .innerJoin(apps, eq(appInstallations.appId, apps.id))
431 .where(eq(appInstallTokens.tokenHash, hash))
432 .limit(1);
433 if (!row) return null;
434 if (row.tok.revokedAt) return null;
435 if (row.tok.expiresAt < new Date()) return null;
436 if (row.inst.uninstalledAt) return null;
437 if (row.inst.suspendedAt) return null;
438 const perms = parsePermissions(row.inst.grantedPermissions);
439 const slug = row.app.slug;
440 return {
441 installation: row.inst,
442 app: row.app,
443 botUsername: botUsername(slug),
444 permissions: perms,
445 };
446 } catch {
447 return null;
448 }
449}
450
451/** Admin-ish listing of an app's recent event log. */
452export async function listEventsForApp(
453 appId: string,
454 limit = 50
455): Promise<Array<typeof appEvents.$inferSelect>> {
456 try {
457 return await db
458 .select()
459 .from(appEvents)
460 .where(eq(appEvents.appId, appId))
461 .orderBy(desc(appEvents.createdAt))
462 .limit(limit);
463 } catch {
464 return [];
465 }
466}
467
468/** Count live installs — for app detail page. */
469export async function countInstalls(appId: string): Promise<number> {
470 try {
471 const [r] = await db
472 .select({ n: sql<number>`count(*)::int` })
473 .from(appInstallations)
474 .where(
475 and(
476 eq(appInstallations.appId, appId),
477 isNull(appInstallations.uninstalledAt)
478 )
479 );
480 return Number(r?.n || 0);
481 } catch {
482 return 0;
483 }
484}
Addedsrc/routes/marketplace.tsx+542−0View fileUnifiedSplit
@@ -0,0 +1,542 @@
1/**
2 * Block H — Marketplace UI + developer-side app management.
3 *
4 * GET /marketplace — public app directory (search)
5 * GET /marketplace/:slug — app detail + install CTA
6 * POST /marketplace/:slug/install — install to user (v1 only)
7 * POST /marketplace/installations/:id/uninstall
8 * — revoke access
9 * GET /settings/apps — list installed apps
10 * GET /developer/apps-new — register a new app
11 * POST /developer/apps-new — create app + bot
12 * GET /developer/apps/:slug/manage — event log + install count
13 * POST /developer/apps/:slug/tokens/new — issue install token (for testing)
14 */
15
16import { Hono } from "hono";
17import { eq } from "drizzle-orm";
18import { db } from "../db";
19import { apps, appInstallations } from "../db/schema";
20import { Layout } from "../views/layout";
21import { softAuth, requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import {
24 KNOWN_PERMISSIONS,
25 KNOWN_EVENTS,
26 countInstalls,
27 createApp,
28 getAppBySlug,
29 installApp,
30 issueInstallToken,
31 listEventsForApp,
32 listInstallationsForApp,
33 listInstallationsForTarget,
34 listPublicApps,
35 normalisePermissions,
36 parsePermissions,
37 uninstallApp,
38} from "../lib/marketplace";
39import { audit } from "../lib/notify";
40
41const marketplace = new Hono<AuthEnv>();
42marketplace.use("*", softAuth);
43
44// ---------- Public directory ----------
45
46marketplace.get("/marketplace", async (c) => {
47 const user = c.get("user");
48 const q = c.req.query("q") || "";
49 const list = await listPublicApps(q);
50 return c.html(
51 <Layout title="Marketplace — Gluecron" user={user}>
52 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
53 <h2>Marketplace</h2>
54 {user && (
55 <a href="/developer/apps-new" class="btn btn-sm">
56 + Register app
57 </a>
58 )}
59 </div>
60 <form method="GET" action="/marketplace" style="margin-bottom:16px">
61 <input
62 type="text"
63 name="q"
64 value={q}
65 placeholder="Search apps"
66 style="width:320px"
67 />{" "}
68 <button type="submit" class="btn">
69 Search
70 </button>
71 </form>
72 <div
73 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px"
74 >
75 {list.length === 0 ? (
76 <div class="panel-empty">No apps found.</div>
77 ) : (
78 list.map((a) => (
79 <a
80 href={`/marketplace/${a.slug}`}
81 class="panel"
82 style="padding:16px;color:inherit;text-decoration:none"
83 >
84 <div style="font-size:15px;font-weight:700">{a.name}</div>
85 <div
86 style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.4"
87 >
88 {a.description.slice(0, 140) || "No description."}
89 </div>
90 <div
91 style="font-size:11px;color:var(--text-muted);margin-top:8px;text-transform:uppercase"
92 >
93 {parsePermissions(a.permissions).length} permissions
94 </div>
95 </a>
96 ))
97 )}
98 </div>
99 </Layout>
100 );
101});
102
103marketplace.get("/marketplace/:slug", async (c) => {
104 const user = c.get("user");
105 const slug = c.req.param("slug");
106 const app = await getAppBySlug(slug);
107 if (!app || !app.isPublic) return c.notFound();
108 const [installs, perms] = await Promise.all([
109 countInstalls(app.id),
110 Promise.resolve(parsePermissions(app.permissions)),
111 ]);
112 return c.html(
113 <Layout title={`${app.name} — Marketplace`} user={user}>
114 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
115 <h2>{app.name}</h2>
116 <a href="/marketplace" class="btn btn-sm">
117 Back
118 </a>
119 </div>
120 <div class="panel" style="padding:16px;margin-bottom:20px">
121 <p>{app.description || "No description."}</p>
122 {app.homepageUrl && (
123 <p style="margin-top:8px">
124 Homepage: <a href={app.homepageUrl}>{app.homepageUrl}</a>
125 </p>
126 )}
127 <div style="font-size:12px;color:var(--text-muted);margin-top:8px">
128 {installs} install{installs === 1 ? "" : "s"} · bot username{" "}
129 <code>{app.slug}[bot]</code>
130 </div>
131 </div>
132
133 <h3>Permissions</h3>
134 <div class="panel" style="margin-bottom:20px">
135 {perms.length === 0 ? (
136 <div class="panel-empty">No permissions requested.</div>
137 ) : (
138 perms.map((p) => (
139 <div class="panel-item">
140 <code>{p}</code>
141 </div>
142 ))
143 )}
144 </div>
145
146 {user ? (
147 <form method="POST" action={`/marketplace/${slug}/install`}>
148 <div class="form-group">
149 <p
150 style="font-size:13px;color:var(--text-muted);margin-bottom:8px"
151 >
152 Installing grants {perms.length} permission
153 {perms.length === 1 ? "" : "s"} to <strong>{app.name}</strong>{" "}
154 on your personal account.
155 </p>
156 </div>
157 {perms.map((p) => (
158 <label
159 style="display:inline-block;margin-right:10px;font-size:13px"
160 >
161 <input
162 type="checkbox"
163 name="permissions"
164 value={p}
165 checked
166 />{" "}
167 {p}
168 </label>
169 ))}
170 <div style="margin-top:12px">
171 <button type="submit" class="btn btn-primary">
172 Install
173 </button>
174 </div>
175 </form>
176 ) : (
177 <div class="panel" style="padding:16px;text-align:center">
178 <a href={`/login?next=/marketplace/${slug}`}>Sign in to install</a>
179 </div>
180 )}
181 </Layout>
182 );
183});
184
185marketplace.post("/marketplace/:slug/install", requireAuth, async (c) => {
186 const user = c.get("user")!;
187 const slug = c.req.param("slug");
188 const app = await getAppBySlug(slug);
189 if (!app) return c.notFound();
190 const body = await c.req.parseBody({ all: true });
191 const rawPerms = body.permissions;
192 const perms = Array.isArray(rawPerms)
193 ? rawPerms.map(String)
194 : rawPerms
195 ? [String(rawPerms)]
196 : [];
197 const inst = await installApp({
198 appId: app.id,
199 installedBy: user.id,
200 targetType: "user",
201 targetId: user.id,
202 grantedPermissions: perms,
203 });
204 if (inst) {
205 await audit({
206 userId: user.id,
207 action: "marketplace.install",
208 targetType: "app",
209 targetId: app.id,
210 metadata: { grantedPermissions: normalisePermissions(perms) },
211 });
212 }
213 return c.redirect("/settings/apps");
214});
215
216marketplace.post(
217 "/marketplace/installations/:id/uninstall",
218 requireAuth,
219 async (c) => {
220 const user = c.get("user")!;
221 const id = c.req.param("id");
222 // Only the installer can uninstall
223 const [inst] = await db
224 .select()
225 .from(appInstallations)
226 .where(eq(appInstallations.id, id))
227 .limit(1);
228 if (!inst || inst.installedBy !== user.id) {
229 return c.text("forbidden", 403);
230 }
231 const ok = await uninstallApp(id);
232 if (ok) {
233 await audit({
234 userId: user.id,
235 action: "marketplace.uninstall",
236 targetType: "app_installation",
237 targetId: id,
238 });
239 }
240 return c.redirect("/settings/apps");
241 }
242);
243
244// ---------- Personal installs ----------
245
246marketplace.get("/settings/apps", requireAuth, async (c) => {
247 const user = c.get("user")!;
248 const installs = await listInstallationsForTarget("user", user.id);
249 return c.html(
250 <Layout title="Installed apps — Gluecron" user={user}>
251 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
252 <h2>Installed apps</h2>
253 <a href="/marketplace" class="btn btn-sm">
254 Browse marketplace
255 </a>
256 </div>
257 <div class="panel">
258 {installs.length === 0 ? (
259 <div class="panel-empty">
260 No apps installed.{" "}
261 <a href="/marketplace">Browse the marketplace</a>.
262 </div>
263 ) : (
264 installs.map((i) => (
265 <div class="panel-item" style="justify-content:space-between">
266 <div style="flex:1;min-width:0">
267 <a
268 href={i.app ? `/marketplace/${i.app.slug}` : "#"}
269 style="font-weight:600"
270 >
271 {i.app?.name || "(unknown app)"}
272 </a>
273 <div
274 style="font-size:12px;color:var(--text-muted);margin-top:2px"
275 >
276 {parsePermissions(i.grantedPermissions).length} permissions ·
277 installed{" "}
278 {i.createdAt
279 ? new Date(i.createdAt).toLocaleDateString()
280 : ""}
281 </div>
282 </div>
283 <form
284 method="POST"
285 action={`/marketplace/installations/${i.id}/uninstall`}
286 onsubmit="return confirm('Uninstall this app?')"
287 >
288 <button type="submit" class="btn btn-sm btn-danger">
289 Uninstall
290 </button>
291 </form>
292 </div>
293 ))
294 )}
295 </div>
296 </Layout>
297 );
298});
299
300// ---------- Developer UX ----------
301
302marketplace.get("/developer/apps-new", requireAuth, async (c) => {
303 const user = c.get("user")!;
304 return c.html(
305 <Layout title="New app — Marketplace" user={user}>
306 <h2>Register a new app</h2>
307 <form method="POST" action="/developer/apps-new" class="panel" style="padding:16px">
308 <div class="form-group">
309 <label>Name</label>
310 <input type="text" name="name" required style="width:100%" />
311 </div>
312 <div class="form-group">
313 <label>Description</label>
314 <textarea name="description" rows="3" style="width:100%" />
315 </div>
316 <div class="form-group">
317 <label>Homepage URL</label>
318 <input type="url" name="homepageUrl" style="width:100%" />
319 </div>
320 <div class="form-group">
321 <label>Webhook URL (optional)</label>
322 <input type="url" name="webhookUrl" style="width:100%" />
323 </div>
324 <div class="form-group">
325 <label>Permissions</label>
326 <div
327 style="display:grid;grid-template-columns:repeat(2,1fr);gap:4px;font-size:13px"
328 >
329 {KNOWN_PERMISSIONS.map((p) => (
330 <label>
331 <input type="checkbox" name="permissions" value={p} /> {p}
332 </label>
333 ))}
334 </div>
335 </div>
336 <div class="form-group">
337 <label>Events</label>
338 <div
339 style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;font-size:13px"
340 >
341 {KNOWN_EVENTS.map((e) => (
342 <label>
343 <input type="checkbox" name="events" value={e} /> {e}
344 </label>
345 ))}
346 </div>
347 </div>
348 <div class="form-group">
349 <label>
350 <input type="checkbox" name="isPublic" value="1" checked /> List in
351 public marketplace
352 </label>
353 </div>
354 <button type="submit" class="btn btn-primary">
355 Create app
356 </button>
357 </form>
358 </Layout>
359 );
360});
361
362marketplace.post("/developer/apps-new", requireAuth, async (c) => {
363 const user = c.get("user")!;
364 const body = await c.req.parseBody({ all: true });
365 const name = String(body.name || "").trim();
366 if (!name) return c.redirect("/developer/apps-new");
367 const rawPerms = body.permissions;
368 const perms = Array.isArray(rawPerms)
369 ? rawPerms.map(String)
370 : rawPerms
371 ? [String(rawPerms)]
372 : [];
373 const rawEvents = body.events;
374 const events = Array.isArray(rawEvents)
375 ? rawEvents.map(String)
376 : rawEvents
377 ? [String(rawEvents)]
378 : [];
379 const app = await createApp({
380 name,
381 description: String(body.description || ""),
382 homepageUrl: String(body.homepageUrl || "") || undefined,
383 webhookUrl: String(body.webhookUrl || "") || undefined,
384 creatorId: user.id,
385 permissions: perms,
386 defaultEvents: events,
387 isPublic: !!body.isPublic,
388 });
389 if (!app) return c.text("failed to create", 500);
390 await audit({
391 userId: user.id,
392 action: "marketplace.app.create",
393 targetType: "app",
394 targetId: app.id,
395 });
396 return c.redirect(`/developer/apps/${app.slug}/manage`);
397});
398
399marketplace.get("/developer/apps/:slug/manage", requireAuth, async (c) => {
400 const user = c.get("user")!;
401 const slug = c.req.param("slug");
402 const app = await getAppBySlug(slug);
403 if (!app) return c.notFound();
404 if (app.creatorId !== user.id) return c.text("forbidden", 403);
405 const [installs, events] = await Promise.all([
406 listInstallationsForApp(app.id),
407 listEventsForApp(app.id, 20),
408 ]);
409 return c.html(
410 <Layout title={`Manage ${app.name}`} user={user}>
411 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
412 <h2>{app.name} · Developer</h2>
413 <a href={`/marketplace/${app.slug}`} class="btn btn-sm">
414 Public page
415 </a>
416 </div>
417 <div class="panel" style="padding:12px;margin-bottom:20px">
418 <div style="font-size:12px;color:var(--text-muted)">Bot identity</div>
419 <div style="font-family:var(--font-mono)">{app.slug}[bot]</div>
420 {app.webhookSecret && (
421 <div style="margin-top:8px;font-size:12px">
422 <span style="color:var(--text-muted)">Webhook secret:</span>{" "}
423 <code>{app.webhookSecret}</code>
424 </div>
425 )}
426 </div>
427
428 <h3>Installations ({installs.length})</h3>
429 <div class="panel" style="margin-bottom:20px">
430 {installs.length === 0 ? (
431 <div class="panel-empty">No installs yet.</div>
432 ) : (
433 installs.map((i) => (
434 <div class="panel-item" style="justify-content:space-between">
435 <div>
436 {i.targetType}: <code>{i.targetId}</code>
437 </div>
438 <div style="font-size:12px;color:var(--text-muted)">
439 {parsePermissions(i.grantedPermissions).length} perms ·{" "}
440 {i.createdAt
441 ? new Date(i.createdAt).toLocaleDateString()
442 : ""}
443 </div>
444 </div>
445 ))
446 )}
447 </div>
448
449 <h3>Recent events</h3>
450 <div class="panel" style="margin-bottom:20px">
451 {events.length === 0 ? (
452 <div class="panel-empty">No events yet.</div>
453 ) : (
454 events.map((e) => (
455 <div class="panel-item" style="justify-content:space-between">
456 <span>{e.kind}</span>
457 <span style="font-size:12px;color:var(--text-muted)">
458 {e.createdAt
459 ? new Date(e.createdAt).toLocaleString()
460 : ""}
461 </span>
462 </div>
463 ))
464 )}
465 </div>
466
467 <h3>Installation tokens</h3>
468 <form
469 method="POST"
470 action={`/developer/apps/${app.slug}/tokens/new`}
471 class="panel"
472 style="padding:16px"
473 >
474 <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">
475 Issue a bearer token for an existing installation. Use this to test
476 bot API calls. Tokens are shown once and expire after 1 hour.
477 </p>
478 <select name="installationId">
479 {installs.map((i) => (
480 <option value={i.id}>
481 {i.targetType}:{i.targetId.slice(0, 8)}
482 </option>
483 ))}
484 </select>{" "}
485 <button type="submit" class="btn btn-sm" disabled={installs.length === 0}>
486 Issue token
487 </button>
488 </form>
489 </Layout>
490 );
491});
492
493marketplace.post(
494 "/developer/apps/:slug/tokens/new",
495 requireAuth,
496 async (c) => {
497 const user = c.get("user")!;
498 const slug = c.req.param("slug");
499 const app = await getAppBySlug(slug);
500 if (!app) return c.notFound();
501 if (app.creatorId !== user.id) return c.text("forbidden", 403);
502 const body = await c.req.parseBody();
503 const installationId = String(body.installationId || "");
504 if (!installationId) return c.redirect(`/developer/apps/${slug}/manage`);
505 // Validate the installation belongs to this app
506 const [inst] = await db
507 .select()
508 .from(appInstallations)
509 .where(eq(appInstallations.id, installationId))
510 .limit(1);
511 if (!inst || inst.appId !== app.id) return c.text("forbidden", 403);
512 const t = await issueInstallToken(installationId);
513 if (!t) return c.text("failed", 500);
514 await audit({
515 userId: user.id,
516 action: "marketplace.token.issue",
517 targetType: "app_installation",
518 targetId: installationId,
519 });
520 return c.html(
521 <Layout title="Token issued" user={user}>
522 <h2>Token issued</h2>
523 <div class="panel" style="padding:16px">
524 <p>Copy this token now — it won't be shown again.</p>
525 <pre
526 style="font-family:var(--font-mono);background:var(--bg-secondary);padding:12px;border-radius:6px;word-break:break-all"
527 >
528 {t.token}
529 </pre>
530 <p style="font-size:12px;color:var(--text-muted)">
531 Expires at {t.expiresAt.toISOString()}
532 </p>
533 <a href={`/developer/apps/${slug}/manage`} class="btn" style="margin-top:12px">
534 Back
535 </a>
536 </div>
537 </Layout>
538 );
539 }
540);
541
542export default marketplace;
0543