Skip to content
231 linesCodeBlameRaw
1/**
2 * The Marketplace's install requests (docs.g1t.sh/guides/marketplace/): a
3 * member asks the workspace's owners to add an extension or an
4 * integration, and an owner adds it or turns it down. Kept in
5 * `install_requests`, one open request per person and listing.
6 *
7 * Only what can be added today can be asked for: a published extension,
8 * or a connector the catalog marks available for a workspace
9 * (@g1t/contracts/connectors). Agents aren't asked for here: they start
10 * from templates in Agents mode, and owners make them. Who is told, and
11 * how, is index.ts's; this file is the rules and the table, tested on
12 * SQLite.
13 */
14import type { FailureCode, InstallRequest, InstallRequestStatus, ListingKind, Result } from "@g1t/contracts";
15import { CONNECTORS } from "@g1t/contracts/connectors";
16import { ANSWERED_REQUESTS_DAYS, MAX_OPEN_REQUESTS, extensionById, listingRef, parseListing } from "@g1t/contracts/marketplace";
17import { parsePluginRef } from "@g1t/contracts/claude-plugin";
18
19// Plain values rather than @g1t/contracts' helpers, so this file runs under Node's tests.
20const ok = <T>(value: T): Result<T> => ({ ok: true, value });
21const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } });
22
23/** Something the workspace can add, as a request names it. */
24export type Listing = { ref: string; kind: ListingKind; id: string; name: string };
25
26/** The listing `value` names, when it is one that can be added today; null otherwise. */
27export function findListing(value: unknown): Listing | null {
28 const parsed = parseListing(value);
29 if (!parsed) return null;
30 if (parsed.kind === "plugin") {
31 const ref = parsePluginRef(parsed.id);
32 return ref ? { ref: listingRef("plugin", parsed.id), kind: "plugin", id: parsed.id, name: ref.name } : null;
33 }
34 if (parsed.kind === "skill") {
35 const [plugin, skill] = parsed.id.split("/");
36 const ref = parsePluginRef(plugin);
37 return ref && skill ? { ref: listingRef("skill", parsed.id), kind: "skill", id: parsed.id, name: `${ref.name}:${skill}` } : null;
38 }
39 if (parsed.kind === "connector") {
40 const connector = CONNECTORS.find((c) => c.id === parsed.id && c.mcp);
41 return connector ? { ref: listingRef("connector", connector.id), kind: "connector", id: connector.id, name: connector.name } : null;
42 }
43 if (parsed.kind === "extension") {
44 const extension = extensionById(parsed.id);
45 return extension?.status === "available" ? { ref: listingRef("extension", extension.id), kind: "extension", id: extension.id, name: extension.name } : null;
46 }
47 const connector = CONNECTORS.find((c) => c.id === parsed.id);
48 if (!connector || connector.status !== "available" || !connector.scopes.includes("workspace")) return null;
49 return { ref: listingRef("integration", connector.id), kind: "integration", id: connector.id, name: connector.name };
50}
51
52type Row = {
53 id: string;
54 listing: string;
55 name: string;
56 note: string | null;
57 requested_by: string;
58 requested_by_id: string;
59 requested_at: string;
60 status: string;
61 resolved_by: string | null;
62 resolved_at: string | null;
63};
64
65const COLUMNS = "id, listing, name, note, requested_by, requested_by_id, requested_at, status, resolved_by, resolved_at";
66
67/** A row as a request; null for one whose listing is no longer a kind the Marketplace has. */
68export function toRequest(row: Row): InstallRequest | null {
69 const parsed = parseListing(row.listing);
70 if (!parsed) return null;
71 return {
72 id: row.id,
73 listing: row.listing,
74 kind: parsed.kind,
75 name: row.name,
76 note: row.note,
77 requested_by: row.requested_by,
78 requested_at: row.requested_at,
79 status: (["open", "done", "declined"].includes(row.status) ? row.status : "open") as InstallRequestStatus,
80 resolved_by: row.resolved_by,
81 resolved_at: row.resolved_at,
82 };
83}
84
85/** Who is asking or answering. */
86export type Person = { id: string; username: string };
87
88/**
89 * Requests as `viewer` sees them: every one in the workspace for an owner,
90 * their own for anyone else. Open ones first, then the latest; answered
91 * ones for `ANSWERED_REQUESTS_DAYS`. At most 200.
92 */
93export async function listRequests(db: D1Database, workspaceId: string, viewer: Person, owner: boolean, now = new Date()): Promise<InstallRequest[]> {
94 const since = new Date(now.getTime() - ANSWERED_REQUESTS_DAYS * 86_400_000).toISOString();
95 const mine = owner ? "" : " AND requested_by_id = ?3";
96 const statement = db.prepare(
97 `SELECT ${COLUMNS} FROM install_requests
98 WHERE workspace_id = ?1 AND (status = 'open' OR resolved_at >= ?2)${mine}
99 ORDER BY status = 'open' DESC, requested_at DESC LIMIT 200`,
100 );
101 const bound = owner ? statement.bind(workspaceId, since) : statement.bind(workspaceId, since, viewer.id);
102 const { results } = await bound.all<Row>();
103 return results.map(toRequest).filter((request) => request !== null);
104}
105
106/**
107 * Opens a request for `listing`, as `id`. A conflict when the person has
108 * one open for it already; a limit when they have `MAX_OPEN_REQUESTS` open.
109 */
110export async function openRequest(
111 db: D1Database,
112 workspaceId: string,
113 id: string,
114 listing: Listing,
115 by: Person,
116 note: string | null,
117 now = new Date(),
118): Promise<Result<InstallRequest>> {
119 const open = await db
120 .prepare("SELECT COUNT(*) AS n FROM install_requests WHERE workspace_id = ? AND requested_by_id = ? AND status = 'open'")
121 .bind(workspaceId, by.id)
122 .first<{ n: number }>();
123 if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS) {
124 return fail("limit", `You have ${MAX_OPEN_REQUESTS} requests waiting on an owner. Ask again once some are answered.`);
125 }
126 const row: Row = {
127 id,
128 listing: listing.ref,
129 name: listing.name,
130 note,
131 requested_by: by.username,
132 requested_by_id: by.id,
133 requested_at: now.toISOString(),
134 status: "open",
135 resolved_by: null,
136 resolved_at: null,
137 };
138 // The unique index on open requests turns a second press into nothing.
139 const inserted = await db
140 .prepare(
141 `INSERT INTO install_requests (id, workspace_id, listing, name, note, requested_by, requested_by_id, requested_at, status)
142 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open') ON CONFLICT DO NOTHING RETURNING id`,
143 )
144 .bind(row.id, workspaceId, row.listing, row.name, row.note, row.requested_by, row.requested_by_id, row.requested_at)
145 .first<{ id: string }>();
146 if (!inserted) return fail("conflict", `You've already asked for ${listing.name}. The workspace's owners have your request.`);
147 return ok(toRequest(row)!);
148}
149
150/** A request answered, with who asked, for telling them. */
151export type Answered = { request: InstallRequest; requested_by_id: string };
152
153/** An owner adds (`done`) or turns down (`declined`) an open request. */
154export async function resolveRequest(
155 db: D1Database,
156 workspaceId: string,
157 id: string,
158 status: Exclude<InstallRequestStatus, "open">,
159 by: Person,
160 now = new Date(),
161): Promise<Result<Answered>> {
162 if (status !== "done" && status !== "declined") return fail("invalid", "Answer a request with done or declined.");
163 const row = await db
164 .prepare(
165 `UPDATE install_requests SET status = ?, resolved_by = ?, resolved_at = ?
166 WHERE id = ? AND workspace_id = ? AND status = 'open' RETURNING ${COLUMNS}`,
167 )
168 .bind(status, by.username, now.toISOString(), id, workspaceId)
169 .first<Row>();
170 const request = row ? toRequest(row) : null;
171 if (row && request) return ok({ request, requested_by_id: row.requested_by_id });
172 const exists = await db.prepare("SELECT status FROM install_requests WHERE id = ? AND workspace_id = ?").bind(id, workspaceId).first<{ status: string }>();
173 return exists ? fail("conflict", "That request was already answered.") : fail("not_found", "There is no such request.");
174}
175
176/**
177 * Every open request for `listing`, marked added: an owner added it, so
178 * whoever asked for it has what they asked for.
179 */
180export async function resolveListing(db: D1Database, workspaceId: string, listing: string, by: Person, now = new Date()): Promise<Answered[]> {
181 const { results } = await db
182 .prepare(
183 `UPDATE install_requests SET status = 'done', resolved_by = ?, resolved_at = ?
184 WHERE workspace_id = ? AND listing = ? AND status = 'open' RETURNING ${COLUMNS}`,
185 )
186 .bind(by.username, now.toISOString(), workspaceId, listing)
187 .all<Row>();
188 return results.flatMap((row) => {
189 const request = toRequest(row);
190 return request ? [{ request, requested_by_id: row.requested_by_id }] : [];
191 });
192}
193
194/** Where a request is looked at in the workspace `slug`. */
195export function requestsPath(slug: string): string {
196 return `/${slug}/-/customize/requests`;
197}
198
199/** Where the thing a request asked for is, once added. */
200export function listingPath(slug: string, listing: string): string {
201 const parsed = parseListing(listing);
202 if (parsed?.kind === "plugin") return `/${slug}/-/customize/plugins/${encodeURIComponent(parsed.id)}`;
203 if (parsed?.kind === "extension") return `/${slug}/-/customize/plugins/${encodeURIComponent(`${parsed.id}@g1t`)}`;
204 if (parsed?.kind === "skill") return `/${slug}/-/customize/skills`;
205 return `/${slug}/-/customize/connectors`;
206}
207
208/** What the person who asked is told when their request is answered. */
209export function answerLine(request: InstallRequest, by: string): { title: string; body: string } {
210 if (request.status === "declined") {
211 return { title: `${by} turned down your request for ${request.name}`, body: "Ask them in chat if you want to know why." };
212 }
213 return {
214 title: `${request.name} was added to the workspace`,
215 body: request.kind === "extension" || request.kind === "plugin" ? `${by} installed it.` : request.kind === "skill" ? `${by} added it.` : `${by} connected it.`,
216 };
217}
218
219/**
220 * After something was added from Customize: every open request for its
221 * listing is marked done. Telling the people who asked is index.ts's;
222 * here the rows change, so a listing added twice answers nothing new.
223 */
224export async function answerListing(ctx: { db: D1Database; workspaceId: string; viewer: { id: string; username: string } }, listing: string): Promise<Answered[]> {
225 try {
226 return await resolveListing(ctx.db, ctx.workspaceId, listing, { id: ctx.viewer.id, username: ctx.viewer.username });
227 } catch (error) {
228 console.error("agents: requests for a listing were not answered", listing, String(error));
229 return [];
230 }
231}