Skip to content
324 linesCodeBlameRaw
1/**
2 * The Marketplace: what a workspace can add, the installs it has, and
3 * members' requests to add things. Anyone in a workspace browses it; only
4 * owners add things. A member asks instead, and the request reaches every
5 * owner as a notification.
6 *
7 * Every listing has a reference, `<kind>:<id>`:
8 *
9 * - `agent:<template>`: an agent hired from one of the catalog's roles
10 * (the agents service's templates); the agents service keeps the agent;
11 * - `integration:<connector>`: a connector from the catalog
12 * (./connectors.ts); the integrations service keeps the connection;
13 * - `extension:<id>`: an extension, described by its manifest
14 * (`ExtensionManifest`): who publishes it, where its source is, the
15 * scopes it asks for, the domains its data goes to, the page it shows
16 * inside g1t, and its pricing. An install (`ExtensionInstall`) pins a
17 * version and can be switched off at once.
18 *
19 * Extensions are shared the way workflow actions are: a public
20 * repository with a manifest, where tagging a release publishes a
21 * version, and an install keeps the version it was installed at until an
22 * owner takes an update. Nothing is paid yet, but listings carry a
23 * pricing field and installs a plan, so paid listings are billing work.
24 *
25 * The agents service keeps installs (`extension_installs`) and requests
26 * (`install_requests`). Wire shapes are snake_case. No imports, so services
27 * test it under Node.
28 */
29
30/** What a listing adds to a workspace. */
31export type ListingKind = "agent" | "integration" | "extension";
32
33export const LISTING_KINDS: readonly ListingKind[] = ["agent", "integration", "extension"];
34
35/** Whether a listing can be added today, or is planned. */
36export type ListingStatus = "available" | "soon";
37
38/**
39 * Where an extension's server code runs: on g1t (official, verified and
40 * internal publishers, and community code once it has an isolated
41 * sandbox), or on the publisher's own servers, reached over HTTPS.
42 */
43export type ExtensionRuntime = "hosted" | "connected";
44
45/** The workspace areas an extension can add to. */
46export type ExtensionAdds = {
47 /** Its pages, in its sidebar, in order. */
48 pages: string[];
49 /** Agent roles it brings, hired like any other. */
50 agent_roles: string[];
51 /** Tools agents can call. */
52 tools: string[];
53 /** Notification kinds it sends. */
54 notifications: string[];
55};
56
57/**
58 * An extension, as its manifest describes it. A published one comes from
59 * `.g1t/extension.json` in its source repository at a tag; first-party
60 * listings not yet published (`status: "soon"`) have no source yet.
61 */
62export type ExtensionManifest = {
63 /** Lowercase letters, digits and hyphens: `support`. */
64 id: string;
65 name: string;
66 /** One line, for cards. */
67 tagline: string;
68 /** A paragraph, for its page. */
69 description: string;
70 category: string;
71 publisher: { name: string; tier: ListingTier };
72 status: ListingStatus;
73 /** Its public repository on g1t and the tag a version was published from; null until it is published. */
74 source: { repo: string; tag: string } | null;
75 /** The version a tag published (`1.4.0`); null until it is published. */
76 version: string | null;
77 runtime: ExtensionRuntime;
78 /** The g1t scopes its token asks for (./scopes.ts), shown in plain words before install. */
79 scopes: string[];
80 /** What it can do, in plain words, as the install screen lists it. */
81 permissions: string[];
82 /**
83 * Every host outside g1t its data goes to. The install screen says
84 * "Data leaves g1t to …" for each; calls anywhere else are refused.
85 * Empty: its data stays in g1t.
86 */
87 domains: string[];
88 /**
89 * Its page inside g1t: a path served from the user-content domain
90 * (g1tusercontent.com, or a self-hosted instance's own), loaded in a
91 * sandboxed frame that reaches g1t only through the bridge. Null: no page.
92 */
93 ui: { entry: string } | null;
94 adds: ExtensionAdds;
95 /** Empty while every listing is free; a later price list goes here. */
96 pricing: null;
97};
98
99/** An extension installed in a workspace. */
100export type ExtensionInstall = {
101 id: string;
102 /** `extension:<id>`. */
103 listing: string;
104 /** The version installed; updates are offered, never forced. */
105 version: string;
106 /** The plan it is on: `free` until listings have prices. */
107 plan: string;
108 installed_by: string;
109 /** RFC 3339. */
110 installed_at: string;
111 /** Off: the kill switch. Its token stops working and its page doesn't load. */
112 enabled: boolean;
113 /** Who last turned it off, and when; null while it is on. */
114 disabled_by: string | null;
115 disabled_at: string | null;
116 /** Its monthly spend cap, in micro-dollars; null: the workspace's limit applies. */
117 budget_monthly_micros: number | null;
118};
119
120/** The plan an install records while nothing is paid. */
121export const FREE_PLAN = "free";
122
123/** A manifest's checks, as publishing applies them; `knownScope` says which scopes exist (./scopes.ts `isScope`). */
124export function checkManifest(raw: unknown, knownScope: (scope: string) => boolean): { ok: true; value: ExtensionManifest } | { ok: false; message: string } {
125 const m = raw as Partial<ExtensionManifest> | null;
126 const bad = (message: string) => ({ ok: false as const, message });
127 if (!m || typeof m !== "object") return bad("The manifest isn't a JSON object.");
128 if (typeof m.id !== "string" || !/^[a-z][a-z0-9-]{1,31}$/.test(m.id)) return bad("`id` is 2 to 32 lowercase letters, digits and hyphens, starting with a letter.");
129 for (const field of ["name", "tagline", "description", "category"] as const) {
130 if (typeof m[field] !== "string" || !m[field]!.trim()) return bad(`\`${field}\` is required.`);
131 }
132 if (!m.publisher || typeof m.publisher.name !== "string" || !LISTING_TIERS.includes(m.publisher.tier as ListingTier)) return bad("`publisher` needs a name and a tier.");
133 if (m.status !== "available" && m.status !== "soon") return bad("`status` is available or soon.");
134 if (m.runtime !== "hosted" && m.runtime !== "connected") return bad("`runtime` is hosted or connected.");
135 if (m.runtime === "hosted" && m.publisher.tier === "community") return bad("Community extensions run on their publisher's servers (`connected`) until hosted code has an isolated sandbox.");
136 if (m.status === "available" && (!m.source || !m.version)) return bad("A published extension names its source repository, tag and version.");
137 if (m.source && (typeof m.source.repo !== "string" || !/^[a-z0-9-]+\/[A-Za-z0-9._-]+$/.test(m.source.repo) || typeof m.source.tag !== "string" || !m.source.tag)) return bad("`source` is a repository (`owner/name`) and a tag.");
138 if (!Array.isArray(m.scopes) || m.scopes.some((scope) => typeof scope !== "string" || !knownScope(scope))) return bad("Every scope must be one g1t has.");
139 if (!Array.isArray(m.permissions) || m.permissions.length === 0) return bad("List what it can do in `permissions`, in plain words.");
140 if (!Array.isArray(m.domains) || m.domains.some((domain) => typeof domain !== "string" || !isHost(domain))) return bad("`domains` are host names, such as api.example.com.");
141 if (m.runtime === "connected" && m.domains.length === 0) return bad("A connected extension declares the domains it runs on.");
142 if (m.ui !== null && (typeof m.ui !== "object" || typeof m.ui?.entry !== "string" || !m.ui.entry.startsWith("/"))) return bad("`ui.entry` is a path, such as /index.html.");
143 if (!m.adds || !["pages", "agent_roles", "tools", "notifications"].every((k) => Array.isArray((m.adds as Record<string, unknown>)[k]))) return bad("`adds` lists pages, agent_roles, tools and notifications.");
144 if (m.pricing !== null) return bad("Listings are free for now: `pricing` is null.");
145 return { ok: true, value: m as ExtensionManifest };
146}
147
148function isHost(value: string): boolean {
149 return /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(value);
150}
151
152/** What the install screen says about where an extension's data goes. */
153export function dataDisclosure(manifest: Pick<ExtensionManifest, "domains">): string {
154 if (manifest.domains.length === 0) return "Its data stays in g1t.";
155 return `Data leaves g1t to ${manifest.domains.join(", ")}.`;
156}
157
158/** Where an extension's page loads from: its entry on the user-content origin, under its id and version. */
159export function extensionFrameUrl(manifest: Pick<ExtensionManifest, "id" | "version" | "ui">, usercontentOrigin: string): string | null {
160 if (!manifest.ui || !manifest.version) return null;
161 return `${usercontentOrigin.replace(/\/+$/, "")}/x/${manifest.id}/${manifest.version}${manifest.ui.entry}`;
162}
163
164/** A version as a tag names it: `v1.4.0` and `1.4.0` are both `1.4.0`; null when it isn't one. */
165export function versionOfTag(tag: string): string | null {
166 const match = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(tag.trim());
167 return match ? match[1]! : null;
168}
169
170/**
171 * g1t's own extensions. None is published yet: each is listed so people
172 * see what is coming, and can't be installed until its first release.
173 */
174export const FIRST_PARTY_EXTENSIONS: ExtensionManifest[] = [
175 {
176 id: "support",
177 name: "Support",
178 tagline: "Customer conversations an agent answers from your docs, escalating the rest.",
179 description:
180 "Conversations, escalations, macros and a knowledge base. A support agent answers what your docs cover, turns bug reports into issues in Code, and hands anything else to a person. Email to customers waits for approval.",
181 category: "Customers",
182 publisher: { name: "g1t", tier: "official" },
183 status: "soon",
184 source: null,
185 version: null,
186 runtime: "hosted",
187 scopes: ["issues:write", "artifacts:read", "notifications:write"],
188 permissions: ["Read and reply to support email, with your approval", "Store conversations in workspace data", "Open issues from bug reports"],
189 domains: [],
190 ui: { entry: "/index.html" },
191 adds: { pages: ["Conversations", "Escalations", "Macros", "Knowledge"], agent_roles: ["Support Specialist"], tools: ["conversation", "macro"], notifications: ["Escalations"] },
192 pricing: null,
193 },
194 {
195 id: "recruiting",
196 name: "Recruiting",
197 tagline: "Openings, candidates and interview loops. Agents screen, people decide.",
198 description: "A pipeline board, scorecards and scheduling. A recruiting agent screens applicants against your scorecard and books interview loops; offers and decisions stay with people.",
199 category: "People",
200 publisher: { name: "g1t", tier: "official" },
201 status: "soon",
202 source: null,
203 version: null,
204 runtime: "hosted",
205 scopes: ["artifacts:write", "notifications:write"],
206 permissions: ["Store candidates and openings in workspace data", "Read and write interview events on connected calendars", "Email candidates from a shared address, with your approval"],
207 domains: [],
208 ui: { entry: "/index.html" },
209 adds: { pages: ["Pipeline", "Openings", "Candidates", "Interviews", "Offers"], agent_roles: ["Recruiter"], tools: ["candidate", "opening"], notifications: ["Offers to sign"] },
210 pricing: null,
211 },
212 {
213 id: "mail",
214 name: "Mail",
215 tagline: "Email on your own domain, with shared inboxes agents work in.",
216 description:
217 "Email that g1t runs on your domain. Shared inboxes such as support@ and sales@ work like channels: agents sort and draft, and sending needs approval or a rule you set. Spam and phishing filtering is included, at what it costs to run. Gmail and Outlook keep working alongside it.",
218 category: "Communication",
219 publisher: { name: "g1t", tier: "official" },
220 status: "soon",
221 source: null,
222 version: null,
223 runtime: "hosted",
224 scopes: ["notifications:write", "artifacts:read"],
225 permissions: ["Host email for your domain", "Let agents read shared inboxes and draft replies", "Send only with approval or a rule you set"],
226 domains: [],
227 ui: { entry: "/index.html" },
228 adds: { pages: ["Inbox", "Shared inboxes", "Sent", "Rules for agents"], agent_roles: [], tools: ["mail", "thread"], notifications: ["Drafts to approve"] },
229 pricing: null,
230 },
231 {
232 id: "on-call",
233 name: "On-call",
234 tagline: "Rotations, pages and who is on call now.",
235 description: "Rotations and schedules, pages through notifications, and an incident channel an operations agent keeps a timeline in.",
236 category: "Engineering",
237 publisher: { name: "g1t", tier: "official" },
238 status: "soon",
239 source: null,
240 version: null,
241 runtime: "hosted",
242 scopes: ["notifications:write", "issues:read"],
243 permissions: ["Store rotations in workspace data", "Page people through their notifications", "Read issues an incident links to"],
244 domains: [],
245 ui: { entry: "/index.html" },
246 adds: { pages: ["Rotations", "Pages", "Incidents"], agent_roles: [], tools: ["rotation", "page"], notifications: ["Pages", "Your shift"] },
247 pricing: null,
248 },
249];
250
251/** A first-party extension by id. */
252export function extensionById(id: string): ExtensionManifest | undefined {
253 return FIRST_PARTY_EXTENSIONS.find((extension) => extension.id === id);
254}
255
256/**
257 * Who stands behind a listing: g1t itself, a reviewed publisher, anyone,
258 * or the workspace's own people.
259 */
260export type ListingTier = "official" | "verified" | "community" | "internal";
261
262export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"];
263
264/** A listing's reference, split: `agent:engineering` is `{ kind: "agent", id: "engineering" }`. */
265export type ListingRef = { kind: ListingKind; id: string };
266
267/** Where a request stands: waiting on an owner, added, or turned down. */
268export type InstallRequestStatus = "open" | "done" | "declined";
269
270export const INSTALL_REQUEST_STATUSES: readonly InstallRequestStatus[] = ["open", "done", "declined"];
271
272/** A member's request that the workspace add a listing. */
273export type InstallRequest = {
274 id: string;
275 /** `<kind>:<id>`. */
276 listing: string;
277 kind: ListingKind;
278 /** The listing's name when it was asked for, such as `Software Engineer` or `Sentry`. */
279 name: string;
280 /** Why they want it, in their words, or null. */
281 note: string | null;
282 /** Who asked, by username. */
283 requested_by: string;
284 /** RFC 3339. */
285 requested_at: string;
286 status: InstallRequestStatus;
287 /** The owner who added it or turned it down, by username. */
288 resolved_by: string | null;
289 /** RFC 3339. */
290 resolved_at: string | null;
291};
292
293/**
294 * Requests as one person sees them: an owner, every request in the
295 * workspace (`can_resolve`); anyone else, their own. Open ones first, then
296 * the latest; answered ones for 30 days.
297 */
298export type InstallRequests = { requests: InstallRequest[]; can_resolve: boolean };
299
300/** The longest note a request carries. */
301export const MAX_REQUEST_NOTE = 280;
302/** The most requests one person keeps open in a workspace. */
303export const MAX_OPEN_REQUESTS = 20;
304/** How long an answered request stays listed. */
305export const ANSWERED_REQUESTS_DAYS = 30;
306
307/** A listing's reference: `agent:engineering`. */
308export function listingRef(kind: ListingKind, id: string): string {
309 return `${kind}:${id}`;
310}
311
312/** A reference split into kind and id; null when it is not one. */
313export function parseListing(value: unknown): ListingRef | null {
314 if (typeof value !== "string") return null;
315 const match = /^(agent|integration|extension):([a-z0-9][a-z0-9_-]{0,63})$/.exec(value.trim());
316 return match ? { kind: match[1] as ListingKind, id: match[2]! } : null;
317}
318
319/** A note as kept: trimmed, at most `MAX_REQUEST_NOTE` characters, null when empty. */
320export function cleanRequestNote(value: unknown): string | null {
321 if (typeof value !== "string") return null;
322 const note = value.replace(/\s+/g, " ").trim().slice(0, MAX_REQUEST_NOTE);
323 return note || null;
324}