| 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. */ |
| 31 | export type ListingKind = "agent" | "integration" | "extension"; |
| 32 | |
| 33 | export const LISTING_KINDS: readonly ListingKind[] = ["agent", "integration", "extension"]; |
| 34 | |
| 35 | /** Whether a listing can be added today, or is planned. */ |
| 36 | export 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 | */ |
| 43 | export type ExtensionRuntime = "hosted" | "connected"; |
| 44 | |
| 45 | /** The workspace areas an extension can add to. */ |
| 46 | export 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 | */ |
| 62 | export 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. */ |
| 100 | export 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. */ |
| 121 | export const FREE_PLAN = "free"; |
| 122 | |
| 123 | /** A manifest's checks, as publishing applies them; `knownScope` says which scopes exist (./scopes.ts `isScope`). */ |
| 124 | export 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 | |
| 148 | function 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. */ |
| 153 | export 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. */ |
| 159 | export 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. */ |
| 165 | export 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 | */ |
| 174 | export 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. */ |
| 252 | export 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 | */ |
| 260 | export type ListingTier = "official" | "verified" | "community" | "internal"; |
| 261 | |
| 262 | export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"]; |
| 263 | |
| 264 | /** A listing's reference, split: `agent:engineering` is `{ kind: "agent", id: "engineering" }`. */ |
| 265 | export type ListingRef = { kind: ListingKind; id: string }; |
| 266 | |
| 267 | /** Where a request stands: waiting on an owner, added, or turned down. */ |
| 268 | export type InstallRequestStatus = "open" | "done" | "declined"; |
| 269 | |
| 270 | export const INSTALL_REQUEST_STATUSES: readonly InstallRequestStatus[] = ["open", "done", "declined"]; |
| 271 | |
| 272 | /** A member's request that the workspace add a listing. */ |
| 273 | export 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 | */ |
| 298 | export type InstallRequests = { requests: InstallRequest[]; can_resolve: boolean }; |
| 299 | |
| 300 | /** The longest note a request carries. */ |
| 301 | export const MAX_REQUEST_NOTE = 280; |
| 302 | /** The most requests one person keeps open in a workspace. */ |
| 303 | export const MAX_OPEN_REQUESTS = 20; |
| 304 | /** How long an answered request stays listed. */ |
| 305 | export const ANSWERED_REQUESTS_DAYS = 30; |
| 306 | |
| 307 | /** A listing's reference: `agent:engineering`. */ |
| 308 | export 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. */ |
| 313 | export 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. */ |
| 320 | export 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 | } |