@useatlas/plugin-sdk
Advanced tools
| /** | ||
| * Semantic-layer whitelist load policy for datasource query tools. | ||
| * | ||
| * Every dialect query tool (ES Query DSL, SOQL, future datasource plugins) | ||
| * gates member access on the semantic layer via `ctx.connections.tables(id)`. | ||
| * That accessor has three outcomes, and the policy for each is a SECURITY | ||
| * contract (#3243 / #3313) — stated here once instead of re-implemented (and | ||
| * re-explained in comments) by every plugin: | ||
| * | ||
| * - **throws** — the semantic-layer scan FAILED: the whitelist load is | ||
| * incomplete. The query must be REFUSED (fail closed) rather than the error | ||
| * swallowed into an empty set, which would silently widen access to | ||
| * structural-only — the "false negative on a security check" anti-pattern. | ||
| * {@link gateOnSemanticWhitelist} returns the canonical refusal. | ||
| * - **empty** — a legitimately-empty layer: STRUCTURAL-ONLY mode. The dialect | ||
| * validator's always-on rails still apply, but any explicitly named member | ||
| * the credential can read is queryable. Surfaced to the operator once at | ||
| * registration via {@link warnIfStructuralOnly}. | ||
| * - **non-empty** — the membership whitelist the dialect validator checks | ||
| * names against. | ||
| */ | ||
| import type { PluginLogger } from "./types"; | ||
| /** | ||
| * The dialect's vocabulary for whitelist policy copy. Declared once per | ||
| * plugin and shared by the query-time gate and the registration-time warning | ||
| * so the two surfaces can't drift. | ||
| */ | ||
| export interface SemanticWhitelistSubject { | ||
| /** Registered tool name, e.g. `"queryElasticsearch"`. */ | ||
| readonly toolName: string; | ||
| /** Singular noun for one queryable member: `"index"`, `"object"`, `"table"`. */ | ||
| readonly member: string; | ||
| /** | ||
| * What structural-only mode exposes, e.g. `"any explicitly-named, | ||
| * non-system index"`. Defaults to `any explicitly-named ${member}`. | ||
| */ | ||
| readonly structuralExposure?: string; | ||
| /** Plural query kind for log copy, e.g. `"DSL queries"`, `"SOQL queries"`. */ | ||
| readonly queryKind: string; | ||
| /** Short label for query-time refusal logs, e.g. `"ES DSL"`, `"SOQL"`. */ | ||
| readonly logLabel: string; | ||
| } | ||
| /** Result of the query-time whitelist load. */ | ||
| export type SemanticWhitelistGate = { | ||
| readonly ok: true; | ||
| /** Member names to validate against. Empty = structural-only mode. */ | ||
| readonly allowed: Set<string>; | ||
| /** True when the layer is legitimately empty (structural-only mode). */ | ||
| readonly structuralOnly: boolean; | ||
| } | { | ||
| readonly ok: false; | ||
| /** Canonical agent-facing refusal — return it as the tool error. */ | ||
| readonly error: string; | ||
| }; | ||
| /** | ||
| * Load the semantic whitelist at query time, owning the fail-closed policy. | ||
| * | ||
| * Call this at the top of a dialect tool's `execute`; on `ok: false` return | ||
| * `{ success: false, error: gate.error }` without issuing any request. The | ||
| * scan-failure path is logged here (`logger.error`) so callers don't repeat | ||
| * the policy. | ||
| */ | ||
| export declare function gateOnSemanticWhitelist(subject: SemanticWhitelistSubject, read: () => Iterable<string>, logger?: PluginLogger, logContext?: Record<string, unknown>): SemanticWhitelistGate; | ||
| /** | ||
| * One-time operator signal at tool registration (#3313). | ||
| * | ||
| * - Empty whitelist → warn that the tool runs in STRUCTURAL-ONLY mode, naming | ||
| * the consequence so operators know to add entity YAMLs. | ||
| * - Whitelist read throws → warn that the scan failed and queries will fail | ||
| * closed (the query-time gate logs each refusal; this only flags the state | ||
| * at registration). | ||
| * - Non-empty → silent. | ||
| */ | ||
| export declare function warnIfStructuralOnly(subject: SemanticWhitelistSubject, read: () => Iterable<string>, logger: PluginLogger): void; |
+13
-2
@@ -18,2 +18,5 @@ // src/helpers.ts | ||
| } | ||
| if (plugin.onUninstall !== undefined && typeof plugin.onUninstall !== "function") { | ||
| throw new Error('Plugin "onUninstall" must be a function when provided'); | ||
| } | ||
| if (plugin.types.includes("datasource")) { | ||
@@ -24,5 +27,13 @@ const ds = plugin; | ||
| } | ||
| if (typeof ds.connection.create !== "function") { | ||
| throw new Error('Datasource plugin connection must have a "create()" factory function'); | ||
| const hasCreate = ds.connection.create !== undefined; | ||
| const hasCreateFromConfig = ds.connection.createFromConfig !== undefined; | ||
| if (!hasCreate && !hasCreateFromConfig) { | ||
| throw new Error('Datasource plugin connection must have a "create()" or "createFromConfig()" factory function'); | ||
| } | ||
| if (hasCreate && typeof ds.connection.create !== "function") { | ||
| throw new Error('Datasource plugin connection "create" must be a function when provided'); | ||
| } | ||
| if (hasCreateFromConfig && typeof ds.connection.createFromConfig !== "function") { | ||
| throw new Error('Datasource plugin connection "createFromConfig" must be a function when provided'); | ||
| } | ||
| if (ds.entities !== undefined && !Array.isArray(ds.entities) && typeof ds.entities !== "function") { | ||
@@ -29,0 +40,0 @@ throw new Error('Datasource plugin "entities" must be an array or a function'); |
+3
-1
@@ -8,7 +8,9 @@ /** | ||
| */ | ||
| export type { PluginQueryResult, PluginDBConnection, QueryValidationResult, PluginDBType, ParserDialect, PluginType, PluginStatus, PluginHealthResult, PluginLogger, AtlasPluginContext, PluginHookEntry, QueryHookContext, QueryHookMutation, AfterQueryHookContext, ExploreHookContext, ExploreHookMutation, AfterExploreHookContext, ToolCallSessionContext, ToolCallHookContext, AfterToolCallHookContext, ToolCallArgsMutation, ToolCallResultMutation, RequestHookContext, ResponseHookContext, PluginHooks, PluginFieldDefinition, PluginTableDefinition, AtlasPluginBase, PluginEntity, EntityProvider, AtlasDatasourcePlugin, AtlasContextPlugin, AtlasInteractionPlugin, PluginAction, AtlasActionPlugin, PluginExecResult, PluginExploreBackend, AtlasSandboxPlugin, ActionApprovalMode, ConfigSchemaField, AtlasPlugin, $InferServerPlugin, } from "./types"; | ||
| export type { PluginQueryResult, PluginDBConnection, QueryValidationResult, PluginDBType, ParserDialect, PluginType, PluginStatus, PluginHealthResult, PluginLogger, AtlasPluginContext, PluginHookEntry, QueryHookContext, QueryHookMutation, AfterQueryHookContext, ExploreHookContext, ExploreHookMutation, AfterExploreHookContext, ToolCallSessionContext, ToolCallHookContext, AfterToolCallHookContext, ToolCallArgsMutation, ToolCallResultMutation, RequestHookContext, ResponseHookContext, PluginHooks, PluginFieldDefinition, PluginTableDefinition, AtlasPluginBase, PluginEntity, EntityProvider, AtlasDatasourcePlugin, AtlasContextPlugin, AtlasInteractionPlugin, PluginAction, AtlasActionPlugin, PluginExecResult, PluginExploreBackend, AtlasSandboxPlugin, ActionApprovalMode, ConfigSchemaField, AtlasPlugin, $InferServerPlugin, AtlasMcpTool, McpToolContext, McpToolAuditEntry, PluginZodSchema, } from "./types"; | ||
| export { SANDBOX_DEFAULT_PRIORITY } from "./types"; | ||
| export { definePlugin, createPlugin, isDatasourcePlugin, isContextPlugin, isInteractionPlugin, isActionPlugin, isSandboxPlugin, } from "./helpers"; | ||
| export type { CreatePluginOptions } from "./helpers"; | ||
| export { gateOnSemanticWhitelist, warnIfStructuralOnly } from "./semantic-whitelist"; | ||
| export type { SemanticWhitelistSubject, SemanticWhitelistGate } from "./semantic-whitelist"; | ||
| export type { ToolSet, Tool } from "./ai"; | ||
| export type { Context, MiddlewareHandler } from "./hono"; |
+39
-2
@@ -21,2 +21,5 @@ // src/types.ts | ||
| } | ||
| if (plugin.onUninstall !== undefined && typeof plugin.onUninstall !== "function") { | ||
| throw new Error('Plugin "onUninstall" must be a function when provided'); | ||
| } | ||
| if (plugin.types.includes("datasource")) { | ||
@@ -27,5 +30,13 @@ const ds = plugin; | ||
| } | ||
| if (typeof ds.connection.create !== "function") { | ||
| throw new Error('Datasource plugin connection must have a "create()" factory function'); | ||
| const hasCreate = ds.connection.create !== undefined; | ||
| const hasCreateFromConfig = ds.connection.createFromConfig !== undefined; | ||
| if (!hasCreate && !hasCreateFromConfig) { | ||
| throw new Error('Datasource plugin connection must have a "create()" or "createFromConfig()" factory function'); | ||
| } | ||
| if (hasCreate && typeof ds.connection.create !== "function") { | ||
| throw new Error('Datasource plugin connection "create" must be a function when provided'); | ||
| } | ||
| if (hasCreateFromConfig && typeof ds.connection.createFromConfig !== "function") { | ||
| throw new Error('Datasource plugin connection "createFromConfig" must be a function when provided'); | ||
| } | ||
| if (ds.entities !== undefined && !Array.isArray(ds.entities) && typeof ds.entities !== "function") { | ||
@@ -131,3 +142,28 @@ throw new Error('Datasource plugin "entities" must be an array or a function'); | ||
| } | ||
| // src/semantic-whitelist.ts | ||
| function gateOnSemanticWhitelist(subject, read, logger, logContext) { | ||
| let allowed; | ||
| try { | ||
| allowed = new Set(read()); | ||
| } catch (err) { | ||
| logger?.error({ ...logContext, error: err instanceof Error ? err.message : String(err) }, `${subject.logLabel} refused — semantic layer unavailable (scan failed)`); | ||
| return { | ||
| ok: false, | ||
| error: `The semantic layer is temporarily unavailable (its scan failed), so ${subject.member} access cannot be verified. Refusing the query to avoid unsafe access — retry once it recovers.` | ||
| }; | ||
| } | ||
| return { ok: true, allowed, structuralOnly: allowed.size === 0 }; | ||
| } | ||
| function warnIfStructuralOnly(subject, read, logger) { | ||
| try { | ||
| if (new Set(read()).size === 0) { | ||
| const exposure = subject.structuralExposure ?? `any explicitly-named ${subject.member}`; | ||
| logger.warn(`${subject.toolName} registered with an empty semantic-layer whitelist — running in STRUCTURAL-ONLY mode: ${exposure} the credential can read is queryable. Add entity YAMLs to enforce a per-${subject.member} allow-list.`); | ||
| } | ||
| } catch (err) { | ||
| logger.warn(`${subject.toolName}: semantic-layer scan failed at registration — ${subject.queryKind} will fail closed until it recovers (${err instanceof Error ? err.message : String(err)}).`); | ||
| } | ||
| } | ||
| export { | ||
| warnIfStructuralOnly, | ||
| isSandboxPlugin, | ||
@@ -138,2 +174,3 @@ isInteractionPlugin, | ||
| isActionPlugin, | ||
| gateOnSemanticWhitelist, | ||
| definePlugin, | ||
@@ -140,0 +177,0 @@ createPlugin, |
+2
-1
@@ -102,3 +102,4 @@ // src/testing.ts | ||
| }), | ||
| list: overrides.connections?.list ?? (() => []) | ||
| list: overrides.connections?.list ?? (() => []), | ||
| tables: overrides.connections?.tables ?? (() => []) | ||
| }, | ||
@@ -105,0 +106,0 @@ tools: { |
+251
-3
@@ -78,3 +78,27 @@ /** | ||
| get(id: string): PluginDBConnection; | ||
| /** Registered connection IDs — NOT semantic-layer object names. */ | ||
| list(): string[]; | ||
| /** | ||
| * Semantic-layer entity/table (for ES: index) names registered for a | ||
| * connection — the per-object membership whitelist a plugin query tool must | ||
| * enforce. In self-host / static-datasource mode this mirrors the | ||
| * filesystem whitelist `executeSQL` validates against, so a plugin's bespoke | ||
| * query tool (SOQL / Query DSL) honors the same boundary as the SQL path. | ||
| * (Org-scoped SaaS validates `executeSQL` against the DB-backed whitelist; | ||
| * the static-config tools this serves are a self-host surface.) | ||
| * | ||
| * `id` must be a registered connection id (typically the plugin's own `id`); | ||
| * an unrecognized id returns `[]`. Returns `[]` when the connection has no | ||
| * semantic layer configured — a tool fed an empty set falls back to | ||
| * structural-only validation (the intended behavior for an unconfigured | ||
| * layer). See #3307. | ||
| * | ||
| * THROWS when the whitelist is empty because a semantic-layer directory scan | ||
| * FAILED (#3243), rather than returning `[]`. This lets a bespoke query tool | ||
| * FAIL CLOSED (refuse the query) on a scan failure instead of silently | ||
| * dropping to structural-only — which would widen access to any | ||
| * explicitly-named, non-system object the credential can read. A tool should | ||
| * call this inside a try/catch and surface a clean refusal on throw (#3313). | ||
| */ | ||
| tables(id: string): readonly string[]; | ||
| }; | ||
@@ -300,2 +324,45 @@ /** Tool registry — plugins can register additional tools. */ | ||
| /** | ||
| * Per-workspace uninstall hook (#3188). Invoked when a workspace | ||
| * uninstalls this plugin — on both uninstall paths (the marketplace | ||
| * `DELETE /api/v1/admin/marketplace/:id` route and | ||
| * `WorkspaceInstaller.uninstall`) — BEFORE Atlas removes the install | ||
| * row and credential stores. At call time the plugin can therefore | ||
| * still authenticate against the external platform to revoke webhook | ||
| * subscriptions, OAuth grants, or any other external state it | ||
| * registered for that workspace. | ||
| * | ||
| * Contrast with {@link teardown}, which runs once at process shutdown: | ||
| * `onUninstall` is per-workspace and fires at uninstall time. A plugin | ||
| * that registers an external webhook subscription (Slack, GitHub, | ||
| * Stripe, …) MUST revoke it here — an un-revoked webhook keeps | ||
| * delivering events to a workspace that no longer has the plugin | ||
| * installed, and Atlas cannot revoke it for you. | ||
| * | ||
| * Attribution rule: NEVER revoke an external subscription you cannot | ||
| * positively attribute to the uninstalling workspace (a recorded id, | ||
| * a metadata workspace tag, or a workspace marker in the callback | ||
| * URL). The hook may fire against a credential shared with other | ||
| * workspaces or out-of-band tooling — bulk-deleting everything the | ||
| * credential can see destroys state that isn't yours. | ||
| * | ||
| * Best-effort contract: a thrown error is logged by the host (with | ||
| * plugin id + workspaceId) and does NOT abort the uninstall — the | ||
| * install-row removal proceeds; each invocation also runs against a | ||
| * host-side deadline (15s), so don't rely on this hook for | ||
| * load-bearing cleanup. | ||
| * | ||
| * Coverage carve-outs: the hook does NOT fire on datasource | ||
| * disconnects (datasource installs are removed via the | ||
| * datasource-specific delete paths) or on a workspace purge — only | ||
| * the two plugin-uninstall paths above invoke it. | ||
| * | ||
| * Resolution: the host invokes the hook on the per-workspace plugin | ||
| * instance built by its lazy loader (SaaS / marketplace installs), and | ||
| * on any globally-registered plugin whose `id` equals the uninstalled | ||
| * catalog entry's slug, catalog id, or `<slug>-<type>` (the naming | ||
| * convention used by the bundled plugins, e.g. `jira-action` for | ||
| * catalog slug `jira`). | ||
| */ | ||
| onUninstall?(workspaceId: string): Promise<void> | void; | ||
| /** | ||
| * Agent lifecycle and HTTP hooks using the matcher + handler pattern. | ||
@@ -315,4 +382,152 @@ * Matchers are optional — omit to always fire. | ||
| cacheBackend?: PluginCacheBackend; | ||
| /** | ||
| * Register MCP tools the plugin contributes. Called once at boot after | ||
| * `initialize()` resolves. The host namespaces each returned tool's | ||
| * `name` as `<plugin-id>.<name>` and registers it on the MCP server so | ||
| * it appears in `tools/list` alongside Atlas's own (`executeSQL`, | ||
| * `explore`, the typed semantic tools). | ||
| * | ||
| * Plugin tool descriptions go through the same `withErrorContract` + | ||
| * word-count rubric as native tools — drift fails CI. Inputs are | ||
| * zod-validated before the handler runs; handler errors get wrapped in | ||
| * the standard `AtlasMcpToolError` envelope so a misbehaving plugin | ||
| * cannot return arbitrary error shapes. Audit + OTel coverage matches | ||
| * native tools with no special-case path. | ||
| */ | ||
| mcpTools?(): readonly AtlasMcpTool[]; | ||
| } | ||
| /** | ||
| * Structural Zod-shaped schema accepted by `AtlasMcpTool.inputSchema`. | ||
| * | ||
| * Defined structurally so the plugin SDK does not take a hard runtime | ||
| * dependency on a specific Zod version (plugins ship their own zod). Any | ||
| * object exposing `parse()`, `safeParse()`, and `_def` (the marker the AI | ||
| * SDK / MCP SDK use to detect Zod schemas) satisfies the contract. | ||
| */ | ||
| export interface PluginZodSchema<TOut = unknown> { | ||
| parse(input: unknown): TOut; | ||
| safeParse(input: unknown): { | ||
| success: true; | ||
| data: TOut; | ||
| } | { | ||
| success: false; | ||
| error: { | ||
| issues: ReadonlyArray<{ | ||
| path: ReadonlyArray<PropertyKey>; | ||
| message: string; | ||
| }>; | ||
| message: string; | ||
| }; | ||
| }; | ||
| /** | ||
| * Zod's internal definition object — the MCP SDK and the AI SDK both | ||
| * introspect this to derive a JSON Schema for the tool's input. | ||
| * Required (not optional) so a structural impostor with only | ||
| * `parse` / `safeParse` cannot typecheck through `register()` and | ||
| * later fail at MCP `tools/list` generation far from the authoring | ||
| * site. Type is `unknown` because the shape is opaque across Zod | ||
| * versions; the contract is "this field exists and the host's | ||
| * schema-introspection layer will probe it." | ||
| */ | ||
| readonly _def: unknown; | ||
| } | ||
| /** | ||
| * Per-dispatch context passed to a plugin MCP tool's `handler`. | ||
| * | ||
| * The host populates this from the same RequestContext that backs the | ||
| * audit log and OTel attribution. `clientId` is set for hosted MCP (per | ||
| * #2067) and undefined for stdio. `audit` is a fire-and-forget structured | ||
| * event emitter — it never throws back to the handler. | ||
| */ | ||
| export interface McpToolContext { | ||
| /** Resolved workspace id (`actor.activeOrganizationId` or `actor.id`). */ | ||
| readonly workspaceId: string; | ||
| /** Bound MCP actor user id. */ | ||
| readonly userId: string; | ||
| /** Hosted-MCP OAuth `client_id`. Undefined for stdio dispatches. */ | ||
| readonly clientId?: string; | ||
| /** Per-dispatch request id — surfaces in `audit_log.request_id` / OTel spans. */ | ||
| readonly requestId: string; | ||
| /** Owning plugin id (matches the `<plugin-id>.<name>` namespace). */ | ||
| readonly pluginId: string; | ||
| /** | ||
| * #2345 — group-aware routing surfaced additively to plugin tools. | ||
| * | ||
| * `connectionId` is the conversation's *execution target* (or the | ||
| * per-turn override) — pass through to `executeSQL` or any other | ||
| * connection-keyed tool the plugin invokes. Absent when the | ||
| * dispatch is not chat-routed (legacy single-connection deploy, | ||
| * scheduler context, ad-hoc MCP call with no env picker). | ||
| * | ||
| * `connectionGroupId` is the *content scope* — the connection group | ||
| * whose entities, dashboards, and approvals resolve for this turn. | ||
| * Decoupled from `connectionId` (a multi-member "prod" group may | ||
| * resolve content while `connectionId` targets a single replica), | ||
| * so a plugin that overlays group-scoped content should read this | ||
| * field rather than reaching for `connectionId`. | ||
| */ | ||
| readonly connectionId?: string; | ||
| readonly connectionGroupId?: string; | ||
| /** Pino-compatible child logger scoped to the plugin + tool. */ | ||
| readonly logger: PluginLogger; | ||
| /** | ||
| * Fire-and-forget structured event emitter. Plugins log domain-specific | ||
| * audit signals here; the host binds the `mcp` actor on the request | ||
| * context so any nested `executeSQL` (or any other code path that | ||
| * writes its own `audit_log` row) is stamped with the plugin tool's | ||
| * `qualifiedName`, `clientId`, and request id consistently. The host | ||
| * does NOT itself write a row to `audit_log` for the dispatch — pure | ||
| * plugin tools that don't invoke `executeSQL` produce zero rows in | ||
| * `audit_log`, just structured pino events via this `audit()` call. | ||
| * Failures inside `audit` are swallowed and logged — they never | ||
| * propagate. | ||
| */ | ||
| audit(entry: McpToolAuditEntry): void; | ||
| } | ||
| /** Structured audit event a plugin handler can emit via `McpToolContext.audit`. */ | ||
| export interface McpToolAuditEntry { | ||
| /** Short event name, e.g. `"runbooks.search"`, `"runbook.fetched"`. */ | ||
| readonly event: string; | ||
| /** Whether the operation the event describes succeeded. */ | ||
| readonly success: boolean; | ||
| /** Optional duration in ms for timing-shaped events. */ | ||
| readonly durationMs?: number; | ||
| /** Optional structured payload. Avoid sensitive data — values land in pino logs. */ | ||
| readonly metadata?: Readonly<Record<string, unknown>>; | ||
| } | ||
| /** | ||
| * A single MCP tool contributed by a plugin via `AtlasPluginBase.mcpTools()`. | ||
| * | ||
| * The `name` is the local (un-namespaced) tool identifier. The host | ||
| * registers it as `<plugin-id>.<name>` to avoid collisions with Atlas's | ||
| * own tools (`executeSQL`, `explore`, `listEntities`, `describeEntity`, | ||
| * `searchGlossary`, `runMetric`). Local names must match | ||
| * `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$` — no dots, slashes, or whitespace. | ||
| * | ||
| * `inputSchema` is enforced before `handler` runs: a parse failure short- | ||
| * circuits with a `validation_failed` error envelope and the handler is | ||
| * never invoked. Handler throws are wrapped in an `internal_error` | ||
| * envelope carrying the dispatch's `request_id` so an LLM agent can | ||
| * correlate the failure with server logs. | ||
| */ | ||
| export interface AtlasMcpTool<TInput = unknown, TOutput = unknown> { | ||
| /** Local tool name. Host namespaces as `<plugin-id>.<name>`. */ | ||
| readonly name: string; | ||
| /** | ||
| * LLM-facing description. Goes through the same rubric as native tools | ||
| * (80–150 words, `Use this when …`, `Don't use this …`/`Avoid …`, at | ||
| * least one inline JSON example) and gets the same `Error contract:` | ||
| * appendage at registration time. Drift fails CI. | ||
| */ | ||
| readonly description: string; | ||
| /** Per-tool error catalog appended to the description via `withErrorContract`. */ | ||
| readonly errorCodes?: ReadonlyArray<string>; | ||
| /** Zod schema for validating the LLM-supplied arguments. */ | ||
| readonly inputSchema: PluginZodSchema<TInput>; | ||
| /** Optional Zod schema describing the structured response shape. */ | ||
| readonly outputSchema?: PluginZodSchema<TOutput>; | ||
| /** Tool handler. Receives the parsed args and a per-dispatch context. */ | ||
| handler(args: TInput, context: McpToolContext): Promise<TOutput>; | ||
| } | ||
| /** | ||
| * A single entity definition shipped by a datasource plugin. | ||
@@ -334,4 +549,37 @@ * Uses the same YAML format as `semantic/entities/*.yml`. | ||
| readonly connection: { | ||
| /** Factory: create a DBConnection for the registry. */ | ||
| create(): Promise<PluginDBConnection> | PluginDBConnection; | ||
| /** | ||
| * Factory: create a DBConnection for the registry from the plugin's | ||
| * config-time config (the object passed to the plugin factory in | ||
| * `atlas.config.ts`). Used by the boot-time static wiring path | ||
| * (`wireDatasourcePlugins`) to register a single config-defined | ||
| * connection. | ||
| * | ||
| * Optional: a plugin registered as an ADAPTER ONLY — the SaaS | ||
| * per-workspace model where every datasource is DB-stored | ||
| * (admin-UI-registered, encrypted), not baked into operator config — | ||
| * omits `create` and implements only {@link createFromConfig}. | ||
| * `wireDatasourcePlugins` skips adapter-only plugins for static wiring; | ||
| * the datasource bridge still finds them via the registry's `getAll()` | ||
| * to build per-(workspace, install) connections on demand. At least one | ||
| * of `create` / `createFromConfig` must be present — enforced by | ||
| * `validatePluginShape`. | ||
| */ | ||
| create?(): Promise<PluginDBConnection> | PluginDBConnection; | ||
| /** | ||
| * Factory: create a DBConnection from a runtime config resolved from a | ||
| * DB-stored datasource install (admin-UI-registered, persisted in | ||
| * `workspace_plugins`). Unlike {@link create}, which closes over the | ||
| * config-time config, this accepts the per-(workspace, install) config | ||
| * decrypted from the database — enabling multi-tenant, DB-driven | ||
| * datasources of this plugin's `dbType` rather than a single static | ||
| * connection. | ||
| * | ||
| * The `config` is the raw decrypted config record; the plugin validates | ||
| * it with its own schema (typically the same `configSchema`) and builds | ||
| * the connection. Throw a clear error when required fields are missing. | ||
| * | ||
| * Plugins that only support static config-defined connections may omit | ||
| * this; DB-stored installs of their `dbType` then remain unsupported. | ||
| */ | ||
| createFromConfig?(config: Readonly<Record<string, unknown>>): Promise<PluginDBConnection> | PluginDBConnection; | ||
| /** Database type identifier (used for SQL dialect selection). */ | ||
@@ -529,3 +777,3 @@ dbType: PluginDBType; | ||
| * type CH = $InferServerPlugin<typeof clickhousePlugin>; | ||
| * // CH["Config"] → { url: string; database?: string } | ||
| * // CH["Config"] → { url?: string; database?: string } | ||
| * // CH["Types"] → readonly ["datasource"] | ||
@@ -532,0 +780,0 @@ * // CH["Id"] → string |
+9
-8
| { | ||
| "name": "@useatlas/plugin-sdk", | ||
| "version": "0.0.7", | ||
| "version": "0.0.9", | ||
| "description": "Type definitions and helpers for authoring Atlas plugins", | ||
| "type": "module", | ||
| "scripts": { | ||
| "build": "rm -rf dist && bun build src/index.ts src/types.ts src/helpers.ts src/ai.ts src/hono.ts src/testing.ts --outdir dist --target node --packages external && bun x tsc -p tsconfig.build.json", | ||
| "prepublishOnly": "bun run build", | ||
| "test": "bun test src/__tests__/types.test.ts && bun test src/__tests__/infer.test.ts && bun test src/__tests__/testing.test.ts" | ||
| "build": "rm -rf dist && bun build src/index.ts src/types.ts src/helpers.ts src/ai.ts src/hono.ts src/testing.ts --outdir dist --target node --packages external && ./node_modules/.bin/tsc -p tsconfig.build.json", | ||
| "prepare": "bun run build", | ||
| "test": "bun test src/__tests__/types.test.ts && bun test src/__tests__/infer.test.ts && bun test src/__tests__/testing.test.ts && bun test src/__tests__/semantic-whitelist.test.ts" | ||
| }, | ||
@@ -60,3 +60,3 @@ "exports": { | ||
| }, | ||
| "homepage": "https://useatlas.dev", | ||
| "homepage": "https://www.useatlas.dev", | ||
| "bugs": { | ||
@@ -79,6 +79,7 @@ "url": "https://github.com/AtlasDevHQ/atlas/issues" | ||
| "devDependencies": { | ||
| "ai": "^6.0.141", | ||
| "hono": "^4.12.9", | ||
| "zod": "^4.3.6" | ||
| "ai": "^6.0.193", | ||
| "hono": "^4.12.23", | ||
| "typescript": "^6.0.3", | ||
| "zod": "^4.4.3" | ||
| } | ||
| } |
+25
-5
@@ -45,5 +45,8 @@ # @useatlas/plugin-sdk | ||
| A datasource plugin must provide **at least one** connection factory: `create()` for a static config-defined connection, `createFromConfig()` for a DB-stored (admin-registered) per-workspace connection, or both. A plugin with only `createFromConfig()` is an **adapter-only** plugin — registered for use but with no static datasource (the multi-tenant SaaS model, where every connection is added per workspace). | ||
| | Field | Type | Required | Description | | ||
| |-------|------|----------|-------------| | ||
| | `connection.create()` | `() => PluginDBConnection` | Yes | Factory that returns a connection with `query()` and `close()` | | ||
| | `connection.create()` | `() => PluginDBConnection \| Promise<PluginDBConnection>` | One of create/createFromConfig | Factory that returns a connection with `query()` and `close()` from the plugin's config-time config (static / self-host) | | ||
| | `connection.createFromConfig()` | `(config) => PluginDBConnection \| Promise<PluginDBConnection>` | One of create/createFromConfig | Factory that builds a connection from a DB-stored per-(workspace, install) config (adapter-only / SaaS) | | ||
| | `connection.dbType` | `PluginDBType` | Yes | Database type identifier (`postgres`, `mysql`, `clickhouse`, `snowflake`, `duckdb`, or custom) | | ||
@@ -74,3 +77,3 @@ | `entities` | `EntityProvider` | No | Semantic layer fragments merged into the table whitelist at boot | | ||
| **Reference:** [`slack`](../../plugins/slack/src/index.ts), [`mcp`](../../plugins/mcp/src/index.ts) | ||
| **Reference:** [`chat`](../../plugins/chat/src/index.ts), [`mcp`](../../plugins/mcp/src/index.ts) | ||
@@ -119,2 +122,3 @@ ### Action (`AtlasActionPlugin`) | ||
| | `teardown()` | `() => Promise<void>` | No | Graceful shutdown (LIFO order) | | ||
| | `onUninstall(workspaceId)` | `(string) => Promise<void> \| void` | No | Per-workspace uninstall hook — revoke external webhook subscriptions / OAuth grants the plugin registered for that workspace | | ||
| | `hooks` | `PluginHooks` | No | Agent lifecycle and HTTP hooks | | ||
@@ -127,2 +131,3 @@ | `schema` | `Record<string, PluginTableDefinition>` | No | Declarative table definitions for the internal DB | | ||
| register → initialize(ctx) → healthCheck() → ... → teardown() | ||
| └── onUninstall(workspaceId) (per-workspace, at uninstall time) | ||
| ``` | ||
@@ -137,7 +142,22 @@ | ||
| - `ctx.config` — Resolved Atlas configuration. | ||
| 3. **Health check** — Periodic probe. Return `{ healthy: false, message }` to signal degradation. Never throw. | ||
| 4. **Teardown** — Graceful shutdown in reverse registration order (LIFO). | ||
| 3. **Health check** — Periodic probe. Return `{ healthy: false, message }` to signal degradation. Never throw. Results surface in the `plugins` component of `GET /api/health` — a failing probe shifts the top-level status to `degraded` (HTTP 200, never 503). | ||
| 4. **Teardown** — Graceful shutdown in reverse registration order (LIFO). Use `teardown()` to release process-level state — third-party connections, drained queues, timers. **Note:** `teardown()` runs on server shutdown, *not* on a per-workspace uninstall (uninstall is a DB-row removal, not a process event) — per-workspace cleanup belongs in `onUninstall`. | ||
| 5. **Per-workspace uninstall** — `onUninstall(workspaceId)` fires when a workspace uninstalls the plugin (both the marketplace `DELETE` route and `WorkspaceInstaller.uninstall`), **before** Atlas removes the install row and credential stores — so the plugin can still authenticate against the external platform. Use it to revoke webhook subscriptions, OAuth grants, or any other external state registered for that workspace. **Attribution rule: never revoke a subscription you can't positively attribute to the uninstalling workspace** (recorded id, metadata tag, or workspace marker in the callback URL) — the credential may be shared with other workspaces or out-of-band tooling. Best-effort: a thrown error is logged with the plugin id + workspaceId and the uninstall proceeds (each invocation also runs against a 15s host-side deadline); never rely on it for load-bearing cleanup. It does **not** fire on datasource disconnects or a workspace purge — only the two plugin-uninstall paths above. See `packages/api/src/lib/integrations/jira/lazy-builder.ts` for a reference implementation (revokes only workspace-attributed Jira dynamic webhook subscriptions). | ||
| > **v1.1 note:** `AtlasPluginContext` will gain `executeQuery`, `conversations`, and `actions` fields for full host-level decoupling. Currently, interaction plugins that need these inject them via config callbacks. | ||
| ## Uninstall Contract | ||
| `DELETE /api/v1/admin/marketplace/:id` removes a plugin from a workspace. The cleanup contract: | ||
| | State | Survives uninstall? | Notes | | ||
| |-------|---------------------|-------| | ||
| | `workspace_plugins` row | No (deleted) | Canonical "is this plugin installed?" record. | | ||
| | `scheduled_tasks` rows tagged with the plugin's `catalog_id` | No (deleted) | Scoped by `(plugin_id, org_id)` so cleanup never crosses workspaces. `scheduled_task_runs` cascade via FK. **Cleanup runs in a separate statement after the install row is removed; partial failure leaves the uninstall committed and is recorded as a `cleanupFailed: true` audit event.** | | ||
| | `plugin_<pluginId>_*` tables (declared via `schema`) | **Yes** (retained) | Reinstall picks up where it left off — cached digest history, sync cursors, etc. Hard-reset only via workspace purge. | | ||
| | In-process hook registrations | **Not detached** on uninstall — `teardown()` runs only at server shutdown | Hooks become inert for the uninstalled workspace because dispatch checks `workspace_plugins` for the installation. | | ||
| | Webhook subscriptions registered with external platforms | **Yes — unless your `onUninstall(workspaceId)` revokes them** | Atlas has no visibility into external state. Implement `onUninstall` to revoke the subscription — it fires before the install row and credentials are removed, so your plugin can still authenticate. Revoke only subscriptions attributable to the uninstalling workspace. An un-revoked webhook keeps delivering events to a workspace that no longer has the plugin installed. Note the hook fires only on plugin uninstalls — datasource disconnects and workspace purges skip it. | | ||
| If your plugin creates `scheduled_tasks` rows, set `plugin_id = $catalogId` and `org_id = $orgId` on insert so the uninstall cleanup picks them up. Untagged tasks (`plugin_id IS NULL`) are treated as user-created and survive uninstall. See the [authoring guide](https://docs.useatlas.dev/plugins/authoring-guide#uninstall-contract) for the full lifecycle. | ||
| ## Config Validation | ||
@@ -296,3 +316,3 @@ | ||
| | [mcp](../../plugins/mcp/) | Interaction | `@useatlas/mcp` | MCP server lifecycle (stdio + SSE) | | ||
| | [slack](../../plugins/slack/) | Interaction | `@useatlas/slack` | Slack bot (slash commands, threads, OAuth) | | ||
| | [chat](../../plugins/chat/) | Interaction | `@useatlas/chat` | Chat SDK bridge — Slack, Teams, Discord, etc. via unified adapter | | ||
| | [jira](../../plugins/jira/) | Action | `@useatlas/jira` | Create JIRA tickets from analysis | | ||
@@ -299,0 +319,0 @@ | [email](../../plugins/email/) | Action | `@useatlas/email` | Send email reports via Resend | |
86856
39.98%16
6.67%1565
31.29%323
6.6%4
33.33%