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