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