| 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. */ |
| 32 | export type ListingKind = "integration" | "extension"; |
| 33 | |
| 34 | export const LISTING_KINDS: readonly ListingKind[] = ["integration", "extension"]; |
| 35 | |
| 36 | /** Whether a listing can be added today, or is planned. */ |
| 37 | export 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 | */ |
| 44 | export type ExtensionRuntime = "hosted" | "connected"; |
| 45 | |
| 46 | /** The workspace areas an extension can add to. */ |
| 47 | export 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 | */ |
| 65 | export 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. */ |
| 109 | export 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. */ |
| 130 | export const FREE_PLAN = "free"; |
| 131 | |
| 132 | /** A manifest's checks, as publishing applies them; `knownScope` says which scopes exist (./scopes.ts `isScope`). */ |
| 133 | export 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 | |
| 158 | function 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. */ |
| 163 | export 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. */ |
| 170 | export 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. */ |
| 176 | export 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. */ |
| 182 | const 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 | */ |
| 198 | export 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. */ |
| 320 | export 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 | */ |
| 328 | export type ListingTier = "official" | "verified" | "community" | "internal"; |
| 329 | |
| 330 | export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"]; |
| 331 | |
| 332 | /** A listing's reference, split: `integration:sentry` is `{ kind: "integration", id: "sentry" }`. */ |
| 333 | export type ListingRef = { kind: ListingKind; id: string }; |
| 334 | |
| 335 | /** Where a request stands: waiting on an owner, added, or turned down. */ |
| 336 | export type InstallRequestStatus = "open" | "done" | "declined"; |
| 337 | |
| 338 | export const INSTALL_REQUEST_STATUSES: readonly InstallRequestStatus[] = ["open", "done", "declined"]; |
| 339 | |
| 340 | /** A member's request that the workspace add a listing. */ |
| 341 | export 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 | */ |
| 366 | export type InstallRequests = { requests: InstallRequest[]; can_resolve: boolean }; |
| 367 | |
| 368 | /** The longest note a request carries. */ |
| 369 | export const MAX_REQUEST_NOTE = 280; |
| 370 | /** The most requests one person keeps open in a workspace. */ |
| 371 | export const MAX_OPEN_REQUESTS = 20; |
| 372 | /** How long an answered request stays listed. */ |
| 373 | export const ANSWERED_REQUESTS_DAYS = 30; |
| 374 | |
| 375 | /** A listing's reference: `integration:sentry`. */ |
| 376 | export 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. */ |
| 381 | export 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. */ |
| 388 | export 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 | } |