@ekanos/sdk 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1165 -140
- package/api-report.md +206 -0
- package/dist/components/index.d.ts +3 -2
- package/dist/components/index.js +3 -2
- package/dist/components/index.js.map +1 -1
- package/dist/components/widgets/widget-context.js.map +1 -1
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/types.d.ts +1 -1
- package/dist/context/types.js.map +1 -1
- package/dist/hooks/activation-actions-context.d.ts +4 -4
- package/dist/hooks/activation-actions-context.js +1 -1
- package/dist/hooks/activation-actions-context.js.map +1 -1
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/use-oauth-connection-status.d.ts +18 -72
- package/dist/hooks/use-oauth-connection-status.js +1 -1
- package/dist/hooks/use-oauth-connection-status.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/integration/define-integration.d.ts +2 -3
- package/dist/integration/define-integration.js +2 -3
- package/dist/integration/define-integration.js.map +1 -1
- package/dist/integration/index.d.ts +3 -3
- package/dist/integration/index.js +2 -2
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/types.d.ts +1 -1
- package/dist/integration/types.js.map +1 -1
- package/dist/testing/index.d.ts +3 -3
- package/dist/testing/index.js +4 -3
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/invoke.d.ts +25 -1
- package/dist/testing/invoke.js +19 -0
- package/dist/testing/invoke.js.map +1 -1
- package/dist/types/index.d.ts +3 -4
- package/dist/types/index.js +3 -4
- package/dist/types/index.js.map +1 -1
- package/dist/types/integration.d.ts +2 -2
- package/dist/types/integration.js.map +1 -1
- package/dist/types/widget-ask-context.d.ts +1 -1
- package/dist/types/widget-ask-context.js.map +1 -1
- package/eslint.preset.mjs +2 -4
- package/package.json +14 -16
- package/dist/mcp/guards.d.ts +0 -40
- package/dist/mcp/guards.js +0 -99
- package/dist/mcp/guards.js.map +0 -1
- package/dist/mcp/index.d.ts +0 -22
- package/dist/mcp/index.js +0 -22
- package/dist/mcp/index.js.map +0 -1
- package/dist/mcp/ownership.d.ts +0 -74
- package/dist/mcp/ownership.js +0 -83
- package/dist/mcp/ownership.js.map +0 -1
- package/dist/mcp/types.d.ts +0 -145
- package/dist/mcp/types.js +0 -2
- package/dist/mcp/types.js.map +0 -1
package/dist/types/index.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SDK-owned type definitions
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `@kit/integrations-core`'s copies by `__tests__/core-parity.test-d.ts`.
|
|
2
|
+
* SDK-owned type definitions: the canonical partner-facing shapes live here,
|
|
3
|
+
* import nothing from `@kit/*`, and are held mutually assignable with the
|
|
4
|
+
* host's copies by a parity test. The full export list is api-report.md.
|
|
6
5
|
*/
|
|
7
6
|
export type { IconRenderer } from './icon.js';
|
|
8
7
|
export type { AccountProductData, AccountProductDataType, ActivationFormProps, ActivationResult, HealthCheckResult, HealthStatus, IntegrationCapability, IntegrationComponentProps, IntegrationConfig, IntegrationMetadata, IntegrationPermission, MarketplaceTileProps, WidgetConfig, } from './integration.js';
|
package/dist/types/index.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SDK-owned type definitions
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `@kit/integrations-core`'s copies by `__tests__/core-parity.test-d.ts`.
|
|
2
|
+
* SDK-owned type definitions: the canonical partner-facing shapes live here,
|
|
3
|
+
* import nothing from `@kit/*`, and are held mutually assignable with the
|
|
4
|
+
* host's copies by a parity test. The full export list is api-report.md.
|
|
6
5
|
*/
|
|
7
6
|
export {};
|
|
8
7
|
//# sourceMappingURL=index.js.map
|
package/dist/types/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG","sourcesContent":["/**\n * SDK-owned type definitions: the canonical partner-facing shapes live here,\n * import nothing from `@kit/*`, and are held mutually assignable with the\n * host's copies by a parity test. The full export list is api-report.md.\n */\n\nexport type { IconRenderer } from './icon';\n\nexport type {\n AccountProductData,\n AccountProductDataType,\n ActivationFormProps,\n ActivationResult,\n HealthCheckResult,\n HealthStatus,\n IntegrationCapability,\n IntegrationComponentProps,\n IntegrationConfig,\n IntegrationMetadata,\n IntegrationPermission,\n MarketplaceTileProps,\n WidgetConfig,\n} from './integration';\n\nexport type {\n WidgetAskContext,\n WidgetSnapshot,\n WidgetSuggestedPrompt,\n} from './widget-ask-context';\n\nexport type { WorkspaceTargetDefinition } from './workspace-target';\n"]}
|
|
@@ -112,7 +112,7 @@ export interface IntegrationMetadata {
|
|
|
112
112
|
* 'user' — credentials or identity are personal (ctx.user.id, personal
|
|
113
113
|
* OAuth, session reads like getSupabaseServerClient()/requireUser()).
|
|
114
114
|
* NEVER machine-executable: a workflow would silently ride one
|
|
115
|
-
* employee's personal grant
|
|
115
|
+
* employee's personal grant.
|
|
116
116
|
* 'source' — platform-level credentials (diagnostics-class integrations).
|
|
117
117
|
* Not exposed to account workflows.
|
|
118
118
|
*
|
|
@@ -143,7 +143,7 @@ export interface IntegrationMetadata {
|
|
|
143
143
|
* declare `getSidebarNav()` and MUST NOT declare `getWorkspaceTargets()` —
|
|
144
144
|
* its widgets are placed onto whatever workspace an admin chooses in the
|
|
145
145
|
* composer (`/admin/sources/[id]/layout`) rather than onto a workspace
|
|
146
|
-
* named after itself.
|
|
146
|
+
* named after itself. A host-side test
|
|
147
147
|
* fails the build on either violation.
|
|
148
148
|
*/
|
|
149
149
|
kind?: 'integration' | 'feature';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integration.js","sourceRoot":"","sources":["../../src/types/integration.ts"],"names":[],"mappings":"","sourcesContent":["import type { ComponentType } from 'react';\n\nimport type { IconRenderer } from './icon';\n\n// Capability shown on the integration detail page (numbered grid)\nexport interface IntegrationCapability {\n label: string;\n description: string;\n icon?: IconRenderer;\n}\n\n// Permission scope shown on the integration detail page\nexport interface IntegrationPermission {\n label: string;\n detail: string;\n type: 'read' | 'write';\n}\n\n// ---- Integration Health Monitoring (issue #474) -----------------------------\n\n// Higher = healthier. Used internally for \"least healthy wins\" merging.\nexport type HealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';\n\n/**\n * Result returned by `BaseIntegration.healthCheck(accountId)`. Routed through\n * `classifyHealth()` (server-side) before writing to\n * `integration_health_status`.\n */\nexport interface HealthCheckResult {\n status: HealthStatus;\n errorCode?: string;\n errorMessage?: string;\n /** Rolling 24h error rate from `account_product_usage_events`, 0..100. */\n errorRatePct?: number;\n /** Sample size used to compute errorRatePct; if below threshold, treated as unknown. */\n sampleSize?: number;\n /** Whether the most recent observed call succeeded. */\n lastObservationSucceeded?: boolean;\n /** Token/credential expiry, if known. Drives token-expiry warnings. */\n tokenExpiresAt?: string;\n /** Free-form per-integration data carried to the dashboard. */\n metadata?: Record<string, unknown>;\n}\n\n// Activation result from integration\nexport interface ActivationResult {\n success: boolean;\n configData?: Record<string, unknown>;\n externalId?: string;\n errorMessage?: string;\n metadata?: Record<string, unknown>;\n}\n\n// Base props that all integration components need\ninterface BaseIntegrationComponentProps {\n accountId: string;\n sourceId?: string;\n integrationConfig?: Record<string, unknown>;\n}\n\n// Discriminated union for product reference - only one allowed\ntype ProductReference =\n | { productId: string; productSlug?: never }\n | { productSlug: string; productId?: never };\n\n// New base type using discriminated union\nexport type IntegrationComponentProps = BaseIntegrationComponentProps &\n ProductReference;\n\n// Marketplace tile props\nexport type MarketplaceTileProps = IntegrationComponentProps & {\n onActivate?: () => void;\n isActivated?: boolean;\n /** Navigation URL for the integration detail page */\n href?: string;\n};\n\n// Activation form props\nexport type ActivationFormProps = IntegrationComponentProps & {\n onSuccess?: (result: ActivationResult) => void;\n onCancel?: () => void;\n isLoading?: boolean;\n /** Render inline instead of in a dialog */\n inline?: boolean;\n /**\n * The TEAM account context the user is activating within — separate from\n * `accountId`, which for `scope: 'user'` integrations is collapsed to the\n * personal account for activation storage/token purposes. Only consumed by\n * workspace materialization (`materializeWorkspaceOnActivation`), never for\n * activation/token storage itself (plan 009 Part A2). Optional: undefined\n * for team-scoped integrations (their `accountId` already IS the team) and\n * for any caller that hasn't threaded it yet — the server action falls back\n * to `accountId` when absent.\n */\n teamAccountId?: string;\n};\n\n// Integration metadata\nexport interface IntegrationMetadata {\n productId: string;\n productSlug: string;\n integrationName: string;\n description: string;\n category: string;\n version: string;\n icon?: string;\n /** Bundled logo URL — overrides product.logo_path on the marketplace */\n logoPath?: string;\n /** When 'user', activation data is stored under the user's personal account (user.id) instead of the team account */\n scope?: 'user' | 'team';\n /**\n * Set FALSE to assert this integration has NO self-serve activation surface —\n * the marketplace detail page then renders no activation form at all.\n *\n * This is deliberately distinct from \"has no CUSTOM activation form\": an\n * integration that omits `getActivationForm()` still gets the generic one, and\n * six integrations depend on that. Use this flag only when submitting ANY\n * activation form would be a dead end (e.g. Workflows, which is turned on by\n * the `source_configs.enable_workflows` feature flag and whose `activate()`\n * throws 'not implemented' — showing a form there hands the user a button that\n * cannot work). Undefined/true = normal self-serve activation.\n */\n selfServeActivation?: boolean;\n /**\n * How this integration's MCP tools resolve credentials — the integration's\n * SELF-DECLARATION of machine-executability, consumed dynamically by the\n * workflow engine's fail-closed tool allowlist (isGatewayExposableTool).\n *\n * 'account' — credentials/identity resolve from team- or source-level\n * config via the ToolContext (`getAccountId(ctx)` + `ctx.supabase`).\n * Safe for durable machine-principal execution (workflows): eligible\n * tools (read + public/internal sensitivity) become workflow-callable.\n * 'user' — credentials or identity are personal (ctx.user.id, personal\n * OAuth, session reads like getSupabaseServerClient()/requireUser()).\n * NEVER machine-executable: a workflow would silently ride one\n * employee's personal grant (N8N_INTEGRATION_PLAN adversarial #4).\n * 'source' — platform-level credentials (diagnostics-class integrations).\n * Not exposed to account workflows.\n *\n * UNDECLARED = fail closed (treated as not machine-executable). Declaring\n * 'account' is a security assertion: the integration's MCP run() handlers\n * must use ctx-based resolution ONLY — no session clients, no personal\n * grants. A guard test in @kit/integrations-workflows enforces the obvious\n * violations (session-client reads in mcp/ code of 'account' declarers).\n */\n credentialModel?: 'account' | 'user' | 'source';\n /**\n * What KIND of thing this integration is — the platform's presentation and\n * governance discriminant. Undefined means `'integration'`, which is what\n * every third-party connector is; the field only ever needs setting to opt\n * INTO feature treatment.\n *\n * 'integration' — connects Fusion to a third party. Has credentials, an\n * activation flow, and a marketplace presence. Its widgets live on\n * workspace dashboards and it declares its workspaces via\n * `getWorkspaceTargets()`.\n * 'feature' — a Fusion-native capability with a bespoke experience: no\n * third-party credentials, its own route tree under\n * `/home/[account]/…`, and its own sidebar section declared via\n * `getSidebarNav()` (optionally rendered by its own\n * `getSidebarComponent()`).\n *\n * The two are mutually exclusive on the nav question. A `'feature'` MUST\n * declare `getSidebarNav()` and MUST NOT declare `getWorkspaceTargets()` —\n * its widgets are placed onto whatever workspace an admin chooses in the\n * composer (`/admin/sources/[id]/layout`) rather than onto a workspace\n * named after itself. `apps/web/lib/workspaces/__tests__/sidebar-nav-declarations.test.ts`\n * fails the build on either violation.\n */\n kind?: 'integration' | 'feature';\n}\n\n// Widget configuration\nexport interface WidgetConfig {\n id: string;\n name: string;\n component: ComponentType<IntegrationComponentProps>;\n gridSize?: { cols: number; rows: number } | { cols: number; rows: number }[];\n gridPosition?: { col: number; row: number };\n widgetState: 'active' | 'inactive' | 'disabled';\n // React-grid-layout format\n layouts?: {\n lg?: { x: number; y: number; w: number; h: number; maxHeight?: number };\n md?: { x: number; y: number; w: number; h: number; maxHeight?: number };\n sm?: { x: number; y: number; w: number; h: number; maxHeight?: number };\n };\n // Integration metadata for widgets\n integrationMetadata?: IntegrationMetadata;\n category?: { id: string; name: string; slug: string; icon: string | null };\n // Database product ID (UUID) from widget_config table\n productId?: string;\n isCollapsible?: boolean;\n // For collapsed state persistence\n collapsed?: boolean;\n widgetConfigId?: string;\n // Resolved workspace placement (explicit widget_config.workspace_id\n // override, else the product's inheriting attachment). Populated by the\n // dashboard widgets loader; null = no workspace (unscoped dashboard).\n workspaceId?: string | null;\n isPinnable?: boolean;\n isPinned?: boolean;\n // When true, the dashboard renders the \"Ask about this\" AI footer bar for\n // this widget. The widget may still pass `askContext` to the wrapper for a\n // rich snapshot; otherwise a minimal default is used.\n aiFooterEnabled?: boolean;\n // Integration health for this widget's product. `unhealthy` downgrades the\n // widget to `disabled`; `degraded` surfaces a stale-data footer.\n health?: {\n status: HealthStatus;\n lastSuccessAt?: string | null;\n errorMessage?: string | null;\n };\n}\n\n// Database types for integration tables.\n//\n// PARTNER-VISIBLE SUBSET: these row types deliberately OMIT `vault_secret_id`.\n// A vault id is a host handle to a decryptable secret; the partner surface\n// never exposes one (docs/devex/sdk-export-map.md, \"Vault access\" —\n// `ctx.secrets` fences it off). `@kit/integrations-core` keeps the full row\n// (host code needs the handle); `types/__tests__/core-parity.test-d.ts` pins\n// the SDK type as core's row minus exactly the host-only fields, so a new\n// core column cannot drift in here unnoticed.\nexport interface IntegrationConfig {\n id: string;\n account_id: string | null;\n product_id: string;\n config_type: 'api_credentials' | 'activation_data' | 'settings';\n config_data: Record<string, unknown>;\n status: 'active' | 'inactive' | 'pending' | 'error' | 'suspended';\n is_active: boolean;\n created_at: string;\n updated_at: string;\n created_by: string | null;\n updated_by: string | null;\n}\n\n/**\n * The PARTNER-VISIBLE `data_type` values on `account_product_data` — a strict\n * SUBSET of `@kit/integrations-core`'s union.\n *\n * `'secret'` is deliberately absent. Secret rows are host-managed (one row per\n * secret name, value in Vault, `vault_secret_id` on the row), written only by\n * the capability context's `persistSecret` and read only through\n * `ctx.secrets`. Naming the kind here would advertise a row shape partner code\n * must never address: `ctx.storage` rejects it, and core's generic accessors\n * reject it too (`config-utils.ts`, `assertNotHostOnlyDataType`) precisely\n * because a write there would land the plaintext in the `data` column.\n *\n * When you add an integration-specific kind, add it in BOTH places (core owns\n * the full union) and keep this one partner-appropriate. Prefer `dataSubtype`\n * for orthogonal slicing within a kind (e.g. `data_type='cache'`,\n * `data_subtype='profile'`); reserve a new top-level kind for data that has a\n * distinct lifecycle or is queried independently.\n */\nexport type AccountProductDataType =\n | 'activation'\n | 'settings'\n | 'metrics_summary'\n | 'sync_state'\n | 'cache'\n // Integration-specific kinds — extend here when needed.\n | 'cash_forecast' // quickbooks — user-supplied manual cash-forecast estimates\n | 'tax_config'; // quickbooks — per-user federal/state tax rate config\n\nexport interface AccountProductData {\n id: string;\n account_id: string;\n product_id: string;\n data_type: AccountProductDataType;\n data_subtype?: string;\n data: Record<string, unknown>;\n expires_at?: string;\n integration_config_id?: string;\n external_id?: string;\n created_at: string;\n updated_at: string;\n created_by: string | null;\n updated_by: string | null;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"integration.js","sourceRoot":"","sources":["../../src/types/integration.ts"],"names":[],"mappings":"","sourcesContent":["import type { ComponentType } from 'react';\n\nimport type { IconRenderer } from './icon';\n\n// Capability shown on the integration detail page (numbered grid)\nexport interface IntegrationCapability {\n label: string;\n description: string;\n icon?: IconRenderer;\n}\n\n// Permission scope shown on the integration detail page\nexport interface IntegrationPermission {\n label: string;\n detail: string;\n type: 'read' | 'write';\n}\n\n// ---- Integration Health Monitoring (issue #474) -----------------------------\n\n// Higher = healthier. Used internally for \"least healthy wins\" merging.\nexport type HealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';\n\n/**\n * Result returned by `BaseIntegration.healthCheck(accountId)`. Routed through\n * `classifyHealth()` (server-side) before writing to\n * `integration_health_status`.\n */\nexport interface HealthCheckResult {\n status: HealthStatus;\n errorCode?: string;\n errorMessage?: string;\n /** Rolling 24h error rate from `account_product_usage_events`, 0..100. */\n errorRatePct?: number;\n /** Sample size used to compute errorRatePct; if below threshold, treated as unknown. */\n sampleSize?: number;\n /** Whether the most recent observed call succeeded. */\n lastObservationSucceeded?: boolean;\n /** Token/credential expiry, if known. Drives token-expiry warnings. */\n tokenExpiresAt?: string;\n /** Free-form per-integration data carried to the dashboard. */\n metadata?: Record<string, unknown>;\n}\n\n// Activation result from integration\nexport interface ActivationResult {\n success: boolean;\n configData?: Record<string, unknown>;\n externalId?: string;\n errorMessage?: string;\n metadata?: Record<string, unknown>;\n}\n\n// Base props that all integration components need\ninterface BaseIntegrationComponentProps {\n accountId: string;\n sourceId?: string;\n integrationConfig?: Record<string, unknown>;\n}\n\n// Discriminated union for product reference - only one allowed\ntype ProductReference =\n | { productId: string; productSlug?: never }\n | { productSlug: string; productId?: never };\n\n// New base type using discriminated union\nexport type IntegrationComponentProps = BaseIntegrationComponentProps &\n ProductReference;\n\n// Marketplace tile props\nexport type MarketplaceTileProps = IntegrationComponentProps & {\n onActivate?: () => void;\n isActivated?: boolean;\n /** Navigation URL for the integration detail page */\n href?: string;\n};\n\n// Activation form props\nexport type ActivationFormProps = IntegrationComponentProps & {\n onSuccess?: (result: ActivationResult) => void;\n onCancel?: () => void;\n isLoading?: boolean;\n /** Render inline instead of in a dialog */\n inline?: boolean;\n /**\n * The TEAM account context the user is activating within — separate from\n * `accountId`, which for `scope: 'user'` integrations is collapsed to the\n * personal account for activation storage/token purposes. Only consumed by\n * workspace materialization (`materializeWorkspaceOnActivation`), never for\n * activation/token storage itself (plan 009 Part A2). Optional: undefined\n * for team-scoped integrations (their `accountId` already IS the team) and\n * for any caller that hasn't threaded it yet — the server action falls back\n * to `accountId` when absent.\n */\n teamAccountId?: string;\n};\n\n// Integration metadata\nexport interface IntegrationMetadata {\n productId: string;\n productSlug: string;\n integrationName: string;\n description: string;\n category: string;\n version: string;\n icon?: string;\n /** Bundled logo URL — overrides product.logo_path on the marketplace */\n logoPath?: string;\n /** When 'user', activation data is stored under the user's personal account (user.id) instead of the team account */\n scope?: 'user' | 'team';\n /**\n * Set FALSE to assert this integration has NO self-serve activation surface —\n * the marketplace detail page then renders no activation form at all.\n *\n * This is deliberately distinct from \"has no CUSTOM activation form\": an\n * integration that omits `getActivationForm()` still gets the generic one, and\n * six integrations depend on that. Use this flag only when submitting ANY\n * activation form would be a dead end (e.g. Workflows, which is turned on by\n * the `source_configs.enable_workflows` feature flag and whose `activate()`\n * throws 'not implemented' — showing a form there hands the user a button that\n * cannot work). Undefined/true = normal self-serve activation.\n */\n selfServeActivation?: boolean;\n /**\n * How this integration's MCP tools resolve credentials — the integration's\n * SELF-DECLARATION of machine-executability, consumed dynamically by the\n * workflow engine's fail-closed tool allowlist (isGatewayExposableTool).\n *\n * 'account' — credentials/identity resolve from team- or source-level\n * config via the ToolContext (`getAccountId(ctx)` + `ctx.supabase`).\n * Safe for durable machine-principal execution (workflows): eligible\n * tools (read + public/internal sensitivity) become workflow-callable.\n * 'user' — credentials or identity are personal (ctx.user.id, personal\n * OAuth, session reads like getSupabaseServerClient()/requireUser()).\n * NEVER machine-executable: a workflow would silently ride one\n * employee's personal grant.\n * 'source' — platform-level credentials (diagnostics-class integrations).\n * Not exposed to account workflows.\n *\n * UNDECLARED = fail closed (treated as not machine-executable). Declaring\n * 'account' is a security assertion: the integration's MCP run() handlers\n * must use ctx-based resolution ONLY — no session clients, no personal\n * grants. A guard test in @kit/integrations-workflows enforces the obvious\n * violations (session-client reads in mcp/ code of 'account' declarers).\n */\n credentialModel?: 'account' | 'user' | 'source';\n /**\n * What KIND of thing this integration is — the platform's presentation and\n * governance discriminant. Undefined means `'integration'`, which is what\n * every third-party connector is; the field only ever needs setting to opt\n * INTO feature treatment.\n *\n * 'integration' — connects Fusion to a third party. Has credentials, an\n * activation flow, and a marketplace presence. Its widgets live on\n * workspace dashboards and it declares its workspaces via\n * `getWorkspaceTargets()`.\n * 'feature' — a Fusion-native capability with a bespoke experience: no\n * third-party credentials, its own route tree under\n * `/home/[account]/…`, and its own sidebar section declared via\n * `getSidebarNav()` (optionally rendered by its own\n * `getSidebarComponent()`).\n *\n * The two are mutually exclusive on the nav question. A `'feature'` MUST\n * declare `getSidebarNav()` and MUST NOT declare `getWorkspaceTargets()` —\n * its widgets are placed onto whatever workspace an admin chooses in the\n * composer (`/admin/sources/[id]/layout`) rather than onto a workspace\n * named after itself. A host-side test\n * fails the build on either violation.\n */\n kind?: 'integration' | 'feature';\n}\n\n// Widget configuration\nexport interface WidgetConfig {\n id: string;\n name: string;\n component: ComponentType<IntegrationComponentProps>;\n gridSize?: { cols: number; rows: number } | { cols: number; rows: number }[];\n gridPosition?: { col: number; row: number };\n widgetState: 'active' | 'inactive' | 'disabled';\n // React-grid-layout format\n layouts?: {\n lg?: { x: number; y: number; w: number; h: number; maxHeight?: number };\n md?: { x: number; y: number; w: number; h: number; maxHeight?: number };\n sm?: { x: number; y: number; w: number; h: number; maxHeight?: number };\n };\n // Integration metadata for widgets\n integrationMetadata?: IntegrationMetadata;\n category?: { id: string; name: string; slug: string; icon: string | null };\n // Database product ID (UUID) from widget_config table\n productId?: string;\n isCollapsible?: boolean;\n // For collapsed state persistence\n collapsed?: boolean;\n widgetConfigId?: string;\n // Resolved workspace placement (explicit widget_config.workspace_id\n // override, else the product's inheriting attachment). Populated by the\n // dashboard widgets loader; null = no workspace (unscoped dashboard).\n workspaceId?: string | null;\n isPinnable?: boolean;\n isPinned?: boolean;\n // When true, the dashboard renders the \"Ask about this\" AI footer bar for\n // this widget. The widget may still pass `askContext` to the wrapper for a\n // rich snapshot; otherwise a minimal default is used.\n aiFooterEnabled?: boolean;\n // Integration health for this widget's product. `unhealthy` downgrades the\n // widget to `disabled`; `degraded` surfaces a stale-data footer.\n health?: {\n status: HealthStatus;\n lastSuccessAt?: string | null;\n errorMessage?: string | null;\n };\n}\n\n// Database types for integration tables.\n//\n// PARTNER-VISIBLE SUBSET: these row types deliberately OMIT `vault_secret_id`.\n// A vault id is a host handle to a decryptable secret; the partner surface\n// never exposes one — `ctx.secrets` fences it off. Host code keeps the full\n// row because it needs the handle; a parity test pins the SDK type as the\n// host's row minus exactly the host-only fields, so a new column cannot drift\n// in here unnoticed.\nexport interface IntegrationConfig {\n id: string;\n account_id: string | null;\n product_id: string;\n config_type: 'api_credentials' | 'activation_data' | 'settings';\n config_data: Record<string, unknown>;\n status: 'active' | 'inactive' | 'pending' | 'error' | 'suspended';\n is_active: boolean;\n created_at: string;\n updated_at: string;\n created_by: string | null;\n updated_by: string | null;\n}\n\n/**\n * The PARTNER-VISIBLE `data_type` values on `account_product_data` — a strict\n * SUBSET of `@kit/integrations-core`'s union.\n *\n * `'secret'` is deliberately absent. Secret rows are host-managed (one row per\n * secret name, value in Vault, `vault_secret_id` on the row), written only by\n * the capability context's `persistSecret` and read only through\n * `ctx.secrets`. Naming the kind here would advertise a row shape partner code\n * must never address: `ctx.storage` rejects it, and core's generic accessors\n * reject it too (`config-utils.ts`, `assertNotHostOnlyDataType`) precisely\n * because a write there would land the plaintext in the `data` column.\n *\n * When you add an integration-specific kind, add it in BOTH places (core owns\n * the full union) and keep this one partner-appropriate. Prefer `dataSubtype`\n * for orthogonal slicing within a kind (e.g. `data_type='cache'`,\n * `data_subtype='profile'`); reserve a new top-level kind for data that has a\n * distinct lifecycle or is queried independently.\n */\nexport type AccountProductDataType =\n | 'activation'\n | 'settings'\n | 'metrics_summary'\n | 'sync_state'\n | 'cache'\n // Integration-specific kinds — extend here when needed.\n | 'cash_forecast' // quickbooks — user-supplied manual cash-forecast estimates\n | 'tax_config'; // quickbooks — per-user federal/state tax rate config\n\nexport interface AccountProductData {\n id: string;\n account_id: string;\n product_id: string;\n data_type: AccountProductDataType;\n data_subtype?: string;\n data: Record<string, unknown>;\n expires_at?: string;\n integration_config_id?: string;\n external_id?: string;\n created_at: string;\n updated_at: string;\n created_by: string | null;\n updated_by: string | null;\n}\n"]}
|
|
@@ -16,7 +16,7 @@ export interface WidgetSuggestedPrompt {
|
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* Context a widget passes to the AI assistant when its "Ask about this"
|
|
19
|
-
* bar is submitted. The bridge
|
|
19
|
+
* bar is submitted. The host's bridge attaches `snapshot` to the user
|
|
20
20
|
* message as AI SDK message metadata (NOT as visible chat text). The chat
|
|
21
21
|
* API route injects the snapshot into the system instruction so the model
|
|
22
22
|
* has authoritative widget state; the dock UI renders a small attachment
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"widget-ask-context.js","sourceRoot":"","sources":["../../src/types/widget-ask-context.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * A widget snapshot is a JSON-serializable view of what the user currently\n * sees in the widget. It MUST NOT contain React nodes, functions, class\n * instances, credentials, tokens, or internal-only IDs. The chat API route\n * validates the shape before injecting it into the system prompt.\n */\nexport type WidgetSnapshot = Record<string, unknown> | null;\n\n/**\n * A pre-canned prompt rendered as a pill inside the expanded \"Ask about this\"\n * footer. Clicking a suggestion dispatches the prompt to the assistant dock\n * along with the widget's snapshot.\n */\nexport interface WidgetSuggestedPrompt {\n label: string;\n prompt: string;\n}\n\n/**\n * Context a widget passes to the AI assistant when its \"Ask about this\"\n * bar is submitted. The bridge
|
|
1
|
+
{"version":3,"file":"widget-ask-context.js","sourceRoot":"","sources":["../../src/types/widget-ask-context.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * A widget snapshot is a JSON-serializable view of what the user currently\n * sees in the widget. It MUST NOT contain React nodes, functions, class\n * instances, credentials, tokens, or internal-only IDs. The chat API route\n * validates the shape before injecting it into the system prompt.\n */\nexport type WidgetSnapshot = Record<string, unknown> | null;\n\n/**\n * A pre-canned prompt rendered as a pill inside the expanded \"Ask about this\"\n * footer. Clicking a suggestion dispatches the prompt to the assistant dock\n * along with the widget's snapshot.\n */\nexport interface WidgetSuggestedPrompt {\n label: string;\n prompt: string;\n}\n\n/**\n * Context a widget passes to the AI assistant when its \"Ask about this\"\n * bar is submitted. The host's bridge attaches `snapshot` to the user\n * message as AI SDK message metadata (NOT as visible chat text). The chat\n * API route injects the snapshot into the system instruction so the model\n * has authoritative widget state; the dock UI renders a small attachment\n * chip above the user bubble using `title` as the label.\n */\nexport interface WidgetAskContext {\n widgetId: string;\n title: string;\n snapshot: WidgetSnapshot;\n suggestedPrompt: string;\n /**\n * Optional pre-canned prompts shown as pills above the input in the\n * expanded \"Ask about this\" footer. Use these to surface the same\n * contextual prompts that previously sat on the widget body.\n */\n suggestedPrompts?: WidgetSuggestedPrompt[];\n}\n"]}
|
package/eslint.preset.mjs
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* runs UNSANDBOXED, in-process, inside the host: the generated
|
|
6
6
|
* `partner-integration-bootstrap.ts` statically imports each partner package,
|
|
7
7
|
* so a partner module graph evaluates with full host authority before any
|
|
8
|
-
* validation runs
|
|
9
|
-
* `apps/web/scripts/generate-partner-integration-bootstrap.ts`).
|
|
8
|
+
* validation runs.
|
|
10
9
|
*
|
|
11
10
|
* The accepted T1 trust tier rests on exactly two controls:
|
|
12
11
|
*
|
|
@@ -16,8 +15,7 @@
|
|
|
16
15
|
* invitation to spot `globalThis.fetch` by eye.
|
|
17
16
|
*
|
|
18
17
|
* Runtime SANDBOXING of handlers is the deferred T3 tier. Until it exists,
|
|
19
|
-
* every rule below is load-bearing.
|
|
20
|
-
* (review outcome F1), `docs/devex/adversarial-review-2026-08-30.md`.
|
|
18
|
+
* every rule below is load-bearing.
|
|
21
19
|
*
|
|
22
20
|
* ── Why it lives here ────────────────────────────────────────────────────
|
|
23
21
|
* A partner already installs `@ekanos/sdk`, so the control ships with the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ekanos/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The official SDK for building Ekanos integrations.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
],
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
|
+
"!dist/mcp",
|
|
15
16
|
"README.md",
|
|
17
|
+
"api-report.md",
|
|
16
18
|
"LICENSE",
|
|
17
19
|
"eslint.preset.mjs"
|
|
18
20
|
],
|
|
@@ -29,10 +31,6 @@
|
|
|
29
31
|
"types": "./dist/hooks/index.d.ts",
|
|
30
32
|
"default": "./dist/hooks/index.js"
|
|
31
33
|
},
|
|
32
|
-
"./mcp": {
|
|
33
|
-
"types": "./dist/mcp/index.d.ts",
|
|
34
|
-
"default": "./dist/mcp/index.js"
|
|
35
|
-
},
|
|
36
34
|
"./context": {
|
|
37
35
|
"types": "./dist/context/index.d.ts",
|
|
38
36
|
"default": "./dist/context/index.js"
|
|
@@ -51,10 +49,10 @@
|
|
|
51
49
|
"access": "public"
|
|
52
50
|
},
|
|
53
51
|
"dependencies": {
|
|
52
|
+
"@ekanos/integration-schema": "0.1.4",
|
|
53
|
+
"@ekanos/ui": "0.1.4",
|
|
54
54
|
"@supabase/supabase-js": "2.87.1",
|
|
55
|
-
"server-only": "^0.0.1"
|
|
56
|
-
"@ekanos/integration-schema": "0.1.2",
|
|
57
|
-
"@ekanos/ui": "0.1.2"
|
|
55
|
+
"server-only": "^0.0.1"
|
|
58
56
|
},
|
|
59
57
|
"peerDependencies": {
|
|
60
58
|
"@hookform/resolvers": "^5.2.2",
|
|
@@ -65,18 +63,18 @@
|
|
|
65
63
|
"zod": "^3.25.76"
|
|
66
64
|
},
|
|
67
65
|
"devDependencies": {
|
|
68
|
-
"@hookform/resolvers": "^5.
|
|
69
|
-
"@
|
|
66
|
+
"@hookform/resolvers": "^5.9.1",
|
|
67
|
+
"@kit/eslint-config": "0.2.0",
|
|
68
|
+
"@kit/prettier-config": "0.1.0",
|
|
69
|
+
"@kit/tsconfig": "0.1.0",
|
|
70
|
+
"@tanstack/react-query": "5.102.8",
|
|
70
71
|
"@types/react": "19.2.18",
|
|
71
|
-
"next": "16.3.
|
|
72
|
+
"next": "16.3.3",
|
|
72
73
|
"react": "19.2.8",
|
|
73
|
-
"react-hook-form": "^7.
|
|
74
|
+
"react-hook-form": "^7.87.0",
|
|
74
75
|
"typescript": "^5.9.3",
|
|
75
76
|
"vitest": "4.1.10",
|
|
76
|
-
"zod": "
|
|
77
|
-
"@kit/prettier-config": "0.1.0",
|
|
78
|
-
"@kit/tsconfig": "0.1.0",
|
|
79
|
-
"@kit/eslint-config": "0.2.0"
|
|
77
|
+
"zod": "3.25.76"
|
|
80
78
|
},
|
|
81
79
|
"prettier": "@kit/prettier-config",
|
|
82
80
|
"typesVersions": {
|
package/dist/mcp/guards.d.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import type { ToolContext } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
* Account-resolution and membership guards for MCP tool modules.
|
|
4
|
-
*
|
|
5
|
-
* Every tool module MUST resolve the acting account via `getAccountId` and
|
|
6
|
-
* verify access with `ensureAccountMembership` (or the group variant) before
|
|
7
|
-
* touching data. Moved here from `@kit/integrations-core/mcp` by the SDK
|
|
8
|
-
* inversion; the HOST-only tool machinery (partner/first-party branding,
|
|
9
|
-
* account_slug schema injection) deliberately stays in integrations-core —
|
|
10
|
-
* branding is the host's provenance pen and must not ship in the
|
|
11
|
-
* partner-facing SDK.
|
|
12
|
-
*/
|
|
13
|
-
/**
|
|
14
|
-
* Resolve the account ID from context.
|
|
15
|
-
*
|
|
16
|
-
* Machine principal (Workflow Gateway): the account is fixed by the gateway
|
|
17
|
-
* token grant — never derived from args or the user. Otherwise: uses the
|
|
18
|
-
* pre-injected accountId from the MCP route, falling back to the user's
|
|
19
|
-
* personal account. See context/N8N_INTEGRATION_PLAN.md "Machine-principal design".
|
|
20
|
-
*/
|
|
21
|
-
export declare function getAccountId(ctx: ToolContext): string;
|
|
22
|
-
/**
|
|
23
|
-
* Verify the caller has access to the specified account.
|
|
24
|
-
*
|
|
25
|
-
* Machine principal (Workflow Gateway): access is granted iff the requested
|
|
26
|
-
* account matches the token's bound account — the token is the authorization,
|
|
27
|
-
* so no DB membership lookup is performed (and none would be correct, since a
|
|
28
|
-
* machine call has no signed-in user).
|
|
29
|
-
*
|
|
30
|
-
* User principal (default): unchanged — true for personal accounts
|
|
31
|
-
* (accountId === user.id) or a valid team membership row.
|
|
32
|
-
*/
|
|
33
|
-
export declare function ensureAccountMembership(ctx: ToolContext, accountId: string): Promise<boolean>;
|
|
34
|
-
/**
|
|
35
|
-
* Verify user is a member of a named group within the account.
|
|
36
|
-
* Returns true only if the named group exists AND the user is a member.
|
|
37
|
-
* Returns false if the group doesn't exist (deny by default — the group
|
|
38
|
-
* must be created and the user added before they gain access).
|
|
39
|
-
*/
|
|
40
|
-
export declare function ensureGroupMembership(ctx: ToolContext, accountId: string, groupName: string): Promise<boolean>;
|
package/dist/mcp/guards.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Account-resolution and membership guards for MCP tool modules.
|
|
3
|
-
*
|
|
4
|
-
* Every tool module MUST resolve the acting account via `getAccountId` and
|
|
5
|
-
* verify access with `ensureAccountMembership` (or the group variant) before
|
|
6
|
-
* touching data. Moved here from `@kit/integrations-core/mcp` by the SDK
|
|
7
|
-
* inversion; the HOST-only tool machinery (partner/first-party branding,
|
|
8
|
-
* account_slug schema injection) deliberately stays in integrations-core —
|
|
9
|
-
* branding is the host's provenance pen and must not ship in the
|
|
10
|
-
* partner-facing SDK.
|
|
11
|
-
*/
|
|
12
|
-
/**
|
|
13
|
-
* Resolve the account ID from context.
|
|
14
|
-
*
|
|
15
|
-
* Machine principal (Workflow Gateway): the account is fixed by the gateway
|
|
16
|
-
* token grant — never derived from args or the user. Otherwise: uses the
|
|
17
|
-
* pre-injected accountId from the MCP route, falling back to the user's
|
|
18
|
-
* personal account. See context/N8N_INTEGRATION_PLAN.md "Machine-principal design".
|
|
19
|
-
*/
|
|
20
|
-
export function getAccountId(ctx) {
|
|
21
|
-
if (ctx.principal === 'machine') {
|
|
22
|
-
// A machine principal MUST carry a grant. Falling back to ctx.user.id here
|
|
23
|
-
// would silently retarget the call at the token creator's PERSONAL account
|
|
24
|
-
// — fail closed instead: a machine context without a grant is a bug.
|
|
25
|
-
if (!ctx.machineGrant) {
|
|
26
|
-
throw new Error('Machine principal context is missing its account grant');
|
|
27
|
-
}
|
|
28
|
-
return ctx.machineGrant.accountId;
|
|
29
|
-
}
|
|
30
|
-
return ctx.accountId || ctx.user.id;
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Verify the caller has access to the specified account.
|
|
34
|
-
*
|
|
35
|
-
* Machine principal (Workflow Gateway): access is granted iff the requested
|
|
36
|
-
* account matches the token's bound account — the token is the authorization,
|
|
37
|
-
* so no DB membership lookup is performed (and none would be correct, since a
|
|
38
|
-
* machine call has no signed-in user).
|
|
39
|
-
*
|
|
40
|
-
* User principal (default): unchanged — true for personal accounts
|
|
41
|
-
* (accountId === user.id) or a valid team membership row.
|
|
42
|
-
*/
|
|
43
|
-
export async function ensureAccountMembership(ctx, accountId) {
|
|
44
|
-
var _a;
|
|
45
|
-
// Machine principal: the gateway token binds exactly one account. Access is
|
|
46
|
-
// allowed only for that account; there is no membership table to consult.
|
|
47
|
-
if (ctx.principal === 'machine') {
|
|
48
|
-
return ((_a = ctx.machineGrant) === null || _a === void 0 ? void 0 : _a.accountId) === accountId;
|
|
49
|
-
}
|
|
50
|
-
// Personal account - user always has access
|
|
51
|
-
if (accountId === ctx.user.id) {
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
// Team account - check membership
|
|
55
|
-
const { data: membershipRows } = await ctx.supabase
|
|
56
|
-
.from('accounts_memberships')
|
|
57
|
-
.select('account_id')
|
|
58
|
-
.eq('account_id', accountId)
|
|
59
|
-
.eq('user_id', ctx.user.id)
|
|
60
|
-
.limit(1);
|
|
61
|
-
return !!(membershipRows && membershipRows.length > 0);
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Verify user is a member of a named group within the account.
|
|
65
|
-
* Returns true only if the named group exists AND the user is a member.
|
|
66
|
-
* Returns false if the group doesn't exist (deny by default — the group
|
|
67
|
-
* must be created and the user added before they gain access).
|
|
68
|
-
*/
|
|
69
|
-
export async function ensureGroupMembership(ctx, accountId, groupName) {
|
|
70
|
-
// Machine principal (Workflow Gateway): group membership is a per-user
|
|
71
|
-
// concept and is not meaningful for a token-authenticated machine caller —
|
|
72
|
-
// checking it against ctx.user.id (the token creator) would be both wrong
|
|
73
|
-
// and a membership-probing leak. Deny; machine access is governed solely by
|
|
74
|
-
// the token grant (ensureAccountMembership).
|
|
75
|
-
if (ctx.principal === 'machine') {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
// Check if the named group exists for this account
|
|
79
|
-
const { data: groups } = await ctx.supabase
|
|
80
|
-
.from('account_groups')
|
|
81
|
-
.select('id')
|
|
82
|
-
.eq('account_id', accountId)
|
|
83
|
-
.eq('name', groupName)
|
|
84
|
-
.limit(1);
|
|
85
|
-
// Group doesn't exist → deny (must be configured first)
|
|
86
|
-
if (!groups || groups.length === 0) {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
const groupId = groups[0].id;
|
|
90
|
-
// Group exists → check if user is a member
|
|
91
|
-
const { data: membership } = await ctx.supabase
|
|
92
|
-
.from('account_group_members')
|
|
93
|
-
.select('id')
|
|
94
|
-
.eq('group_id', groupId)
|
|
95
|
-
.eq('user_id', ctx.user.id)
|
|
96
|
-
.limit(1);
|
|
97
|
-
return !!(membership && membership.length > 0);
|
|
98
|
-
}
|
|
99
|
-
//# sourceMappingURL=guards.js.map
|
package/dist/mcp/guards.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"guards.js","sourceRoot":"","sources":["../../src/mcp/guards.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AAEH;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,GAAgB;IAC3C,IAAI,GAAG,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QAChC,2EAA2E;QAC3E,2EAA2E;QAC3E,qEAAqE;QACrE,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC;YACtB,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;QAC5E,CAAC;QAED,OAAO,GAAG,CAAC,YAAY,CAAC,SAAS,CAAC;IACpC,CAAC;IAED,OAAO,GAAG,CAAC,SAAS,IAAI,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;AACtC,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,GAAgB,EAChB,SAAiB;;IAEjB,4EAA4E;IAC5E,0EAA0E;IAC1E,IAAI,GAAG,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QAChC,OAAO,CAAA,MAAA,GAAG,CAAC,YAAY,0CAAE,SAAS,MAAK,SAAS,CAAC;IACnD,CAAC;IAED,4CAA4C;IAC5C,IAAI,SAAS,KAAK,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;QAC9B,OAAO,IAAI,CAAC;IACd,CAAC;IAED,kCAAkC;IAClC,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG,MAAM,GAAG,CAAC,QAAQ;SAChD,IAAI,CAAC,sBAAsB,CAAC;SAC5B,MAAM,CAAC,YAAY,CAAC;SACpB,EAAE,CAAC,YAAY,EAAE,SAAS,CAAC;SAC3B,EAAE,CAAC,SAAS,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;SAC1B,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,OAAO,CAAC,CAAC,CAAC,cAAc,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACzD,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,GAAgB,EAChB,SAAiB,EACjB,SAAiB;IAEjB,uEAAuE;IACvE,2EAA2E;IAC3E,0EAA0E;IAC1E,4EAA4E;IAC5E,6CAA6C;IAC7C,IAAI,GAAG,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QAChC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,mDAAmD;IACnD,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,CAAC,QAAQ;SACxC,IAAI,CAAC,gBAAgB,CAAC;SACtB,MAAM,CAAC,IAAI,CAAC;SACZ,EAAE,CAAC,YAAY,EAAE,SAAS,CAAC;SAC3B,EAAE,CAAC,MAAM,EAAE,SAAS,CAAC;SACrB,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,wDAAwD;IACxD,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACnC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,CAAC,CAAC,CAAE,CAAC,EAAY,CAAC;IAExC,2CAA2C;IAC3C,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,MAAM,GAAG,CAAC,QAAQ;SAC5C,IAAI,CAAC,uBAAuB,CAAC;SAC7B,MAAM,CAAC,IAAI,CAAC;SACZ,EAAE,CAAC,UAAU,EAAE,OAAO,CAAC;SACvB,EAAE,CAAC,SAAS,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;SAC1B,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,OAAO,CAAC,CAAC,CAAC,UAAU,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACjD,CAAC","sourcesContent":["import type { ToolContext } from './types';\n\n/**\n * Account-resolution and membership guards for MCP tool modules.\n *\n * Every tool module MUST resolve the acting account via `getAccountId` and\n * verify access with `ensureAccountMembership` (or the group variant) before\n * touching data. Moved here from `@kit/integrations-core/mcp` by the SDK\n * inversion; the HOST-only tool machinery (partner/first-party branding,\n * account_slug schema injection) deliberately stays in integrations-core —\n * branding is the host's provenance pen and must not ship in the\n * partner-facing SDK.\n */\n\n/**\n * Resolve the account ID from context.\n *\n * Machine principal (Workflow Gateway): the account is fixed by the gateway\n * token grant — never derived from args or the user. Otherwise: uses the\n * pre-injected accountId from the MCP route, falling back to the user's\n * personal account. See context/N8N_INTEGRATION_PLAN.md \"Machine-principal design\".\n */\nexport function getAccountId(ctx: ToolContext): string {\n if (ctx.principal === 'machine') {\n // A machine principal MUST carry a grant. Falling back to ctx.user.id here\n // would silently retarget the call at the token creator's PERSONAL account\n // — fail closed instead: a machine context without a grant is a bug.\n if (!ctx.machineGrant) {\n throw new Error('Machine principal context is missing its account grant');\n }\n\n return ctx.machineGrant.accountId;\n }\n\n return ctx.accountId || ctx.user.id;\n}\n\n/**\n * Verify the caller has access to the specified account.\n *\n * Machine principal (Workflow Gateway): access is granted iff the requested\n * account matches the token's bound account — the token is the authorization,\n * so no DB membership lookup is performed (and none would be correct, since a\n * machine call has no signed-in user).\n *\n * User principal (default): unchanged — true for personal accounts\n * (accountId === user.id) or a valid team membership row.\n */\nexport async function ensureAccountMembership(\n ctx: ToolContext,\n accountId: string,\n): Promise<boolean> {\n // Machine principal: the gateway token binds exactly one account. Access is\n // allowed only for that account; there is no membership table to consult.\n if (ctx.principal === 'machine') {\n return ctx.machineGrant?.accountId === accountId;\n }\n\n // Personal account - user always has access\n if (accountId === ctx.user.id) {\n return true;\n }\n\n // Team account - check membership\n const { data: membershipRows } = await ctx.supabase\n .from('accounts_memberships')\n .select('account_id')\n .eq('account_id', accountId)\n .eq('user_id', ctx.user.id)\n .limit(1);\n\n return !!(membershipRows && membershipRows.length > 0);\n}\n\n/**\n * Verify user is a member of a named group within the account.\n * Returns true only if the named group exists AND the user is a member.\n * Returns false if the group doesn't exist (deny by default — the group\n * must be created and the user added before they gain access).\n */\nexport async function ensureGroupMembership(\n ctx: ToolContext,\n accountId: string,\n groupName: string,\n): Promise<boolean> {\n // Machine principal (Workflow Gateway): group membership is a per-user\n // concept and is not meaningful for a token-authenticated machine caller —\n // checking it against ctx.user.id (the token creator) would be both wrong\n // and a membership-probing leak. Deny; machine access is governed solely by\n // the token grant (ensureAccountMembership).\n if (ctx.principal === 'machine') {\n return false;\n }\n\n // Check if the named group exists for this account\n const { data: groups } = await ctx.supabase\n .from('account_groups')\n .select('id')\n .eq('account_id', accountId)\n .eq('name', groupName)\n .limit(1);\n\n // Group doesn't exist → deny (must be configured first)\n if (!groups || groups.length === 0) {\n return false;\n }\n\n const groupId = groups[0]!.id as string;\n\n // Group exists → check if user is a member\n const { data: membership } = await ctx.supabase\n .from('account_group_members')\n .select('id')\n .eq('group_id', groupId)\n .eq('user_id', ctx.user.id)\n .limit(1);\n\n return !!(membership && membership.length > 0);\n}\n"]}
|
package/dist/mcp/index.d.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @ekanos/sdk/mcp — MCP tool contract and security guards.
|
|
3
|
-
*
|
|
4
|
-
* Every tool module MUST resolve the acting account via `getAccountId` and
|
|
5
|
-
* verify access with `ensureAccountMembership` (or the group/resource
|
|
6
|
-
* variants) before touching data. `assertResourceOwnership` is exported so
|
|
7
|
-
* tools never hand-roll ownership checks.
|
|
8
|
-
*
|
|
9
|
-
* The SDK OWNS these implementations (the inversion);
|
|
10
|
-
* `@kit/integrations-core/mcp` re-exports them for first-party code. The
|
|
11
|
-
* HOST-only machinery stays in integrations-core on purpose:
|
|
12
|
-
* `brandPartnerTool` / `brandFirstPartyTool` (the provenance pen — exporting
|
|
13
|
-
* it here would hand partners the classification authority) and
|
|
14
|
-
* `injectAccountSlugParam(s)` (host route plumbing).
|
|
15
|
-
*
|
|
16
|
-
* Surface discipline: additions require an entry in
|
|
17
|
-
* docs/devex/sdk-export-map.md ("`@ekanos/sdk/mcp`").
|
|
18
|
-
*/
|
|
19
|
-
import 'server-only';
|
|
20
|
-
export type * from './types.js';
|
|
21
|
-
export { ensureAccountMembership, ensureGroupMembership, getAccountId, } from './guards.js';
|
|
22
|
-
export { ToolOwnershipError, assertResourceOwnership, resolveSelfOnly, } from './ownership.js';
|
package/dist/mcp/index.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @ekanos/sdk/mcp — MCP tool contract and security guards.
|
|
3
|
-
*
|
|
4
|
-
* Every tool module MUST resolve the acting account via `getAccountId` and
|
|
5
|
-
* verify access with `ensureAccountMembership` (or the group/resource
|
|
6
|
-
* variants) before touching data. `assertResourceOwnership` is exported so
|
|
7
|
-
* tools never hand-roll ownership checks.
|
|
8
|
-
*
|
|
9
|
-
* The SDK OWNS these implementations (the inversion);
|
|
10
|
-
* `@kit/integrations-core/mcp` re-exports them for first-party code. The
|
|
11
|
-
* HOST-only machinery stays in integrations-core on purpose:
|
|
12
|
-
* `brandPartnerTool` / `brandFirstPartyTool` (the provenance pen — exporting
|
|
13
|
-
* it here would hand partners the classification authority) and
|
|
14
|
-
* `injectAccountSlugParam(s)` (host route plumbing).
|
|
15
|
-
*
|
|
16
|
-
* Surface discipline: additions require an entry in
|
|
17
|
-
* docs/devex/sdk-export-map.md ("`@ekanos/sdk/mcp`").
|
|
18
|
-
*/
|
|
19
|
-
import 'server-only';
|
|
20
|
-
export { ensureAccountMembership, ensureGroupMembership, getAccountId, } from './guards.js';
|
|
21
|
-
export { ToolOwnershipError, assertResourceOwnership, resolveSelfOnly, } from './ownership.js';
|
|
22
|
-
//# sourceMappingURL=index.js.map
|
package/dist/mcp/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/mcp/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,aAAa,CAAC;AAIrB,OAAO,EACL,uBAAuB,EACvB,qBAAqB,EACrB,YAAY,GACb,MAAM,UAAU,CAAC;AAElB,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,eAAe,GAChB,MAAM,aAAa,CAAC","sourcesContent":["/**\n * @ekanos/sdk/mcp — MCP tool contract and security guards.\n *\n * Every tool module MUST resolve the acting account via `getAccountId` and\n * verify access with `ensureAccountMembership` (or the group/resource\n * variants) before touching data. `assertResourceOwnership` is exported so\n * tools never hand-roll ownership checks.\n *\n * The SDK OWNS these implementations (the inversion);\n * `@kit/integrations-core/mcp` re-exports them for first-party code. The\n * HOST-only machinery stays in integrations-core on purpose:\n * `brandPartnerTool` / `brandFirstPartyTool` (the provenance pen — exporting\n * it here would hand partners the classification authority) and\n * `injectAccountSlugParam(s)` (host route plumbing).\n *\n * Surface discipline: additions require an entry in\n * docs/devex/sdk-export-map.md (\"`@ekanos/sdk/mcp`\").\n */\nimport 'server-only';\n\nexport type * from './types';\n\nexport {\n ensureAccountMembership,\n ensureGroupMembership,\n getAccountId,\n} from './guards';\n\nexport {\n ToolOwnershipError,\n assertResourceOwnership,\n resolveSelfOnly,\n} from './ownership';\n"]}
|
package/dist/mcp/ownership.d.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ownership guards for member-facing MCP tools.
|
|
3
|
-
*
|
|
4
|
-
* The LLM controls tool arguments. Any member-facing tool that accepts an
|
|
5
|
-
* external per-user identifier (a Wild Apricot contactId, a Discourse
|
|
6
|
-
* username, a LearnWorlds learner id, etc.) is a cross-member data-exposure
|
|
7
|
-
* risk: the caller can pass *someone else's* id and read or act on their
|
|
8
|
-
* data. The org-membership gate (`ensureAccountMembership`) does not catch
|
|
9
|
-
* this — it answers "is this person in the org?", never "is this person's
|
|
10
|
-
* own data?".
|
|
11
|
-
*
|
|
12
|
-
* These helpers make the safe path the default. A tool author reaches for
|
|
13
|
-
* `resolveSelfOnly` instead of writing a bespoke "default to me" resolver,
|
|
14
|
-
* and gets ownership enforcement for free.
|
|
15
|
-
*
|
|
16
|
-
* Resolving "who am I" is integration-specific (each integration maps the
|
|
17
|
-
* Supabase user to its own id space), so the caller supplies `resolveSelf`.
|
|
18
|
-
*/
|
|
19
|
-
/**
|
|
20
|
-
* Thrown when a tool caller attempts to access or act on another user's
|
|
21
|
-
* data. Tool `run` bodies should let this propagate (or convert it to a
|
|
22
|
-
* `{ error }` result) — the assistant surfaces a "not permitted" message
|
|
23
|
-
* rather than the forbidden data.
|
|
24
|
-
*/
|
|
25
|
-
export declare class ToolOwnershipError extends Error {
|
|
26
|
-
readonly code = "FORBIDDEN_OWNERSHIP";
|
|
27
|
-
constructor(resourceLabel?: string);
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Self-only identity resolution for member-facing tools.
|
|
31
|
-
*
|
|
32
|
-
* - caller supplied no id → returns the caller's own id
|
|
33
|
-
* - caller supplied their own id → returns it
|
|
34
|
-
* - caller supplied a different id → throws ToolOwnershipError
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* // In a Wild Apricot read tool:
|
|
38
|
-
* const contactId = await resolveSelfOnly({
|
|
39
|
-
* requestedId: args.contactId,
|
|
40
|
-
* resolveSelf: () => resolveWaContactId(accountId),
|
|
41
|
-
* resourceLabel: 'member data',
|
|
42
|
-
* });
|
|
43
|
-
*/
|
|
44
|
-
export declare function resolveSelfOnly<TId extends string | number>(params: {
|
|
45
|
-
/** The identifier the LLM supplied as a tool argument, if any. */
|
|
46
|
-
requestedId?: TId | null;
|
|
47
|
-
/** Integration-specific resolver for the calling user's own id. */
|
|
48
|
-
resolveSelf: () => Promise<TId>;
|
|
49
|
-
/** Human-readable noun for the error message, e.g. 'member profile'. */
|
|
50
|
-
resourceLabel?: string;
|
|
51
|
-
}): Promise<TId>;
|
|
52
|
-
/**
|
|
53
|
-
* Ownership verification for tools that act on an opaque resource id (an
|
|
54
|
-
* event registration, a saved document, an invoice) where ownership cannot
|
|
55
|
-
* be inferred from the id itself. Resolves the resource's owner and the
|
|
56
|
-
* caller's own id and throws if they differ (or if the resource has no
|
|
57
|
-
* resolvable owner — fail closed).
|
|
58
|
-
*
|
|
59
|
-
* @example
|
|
60
|
-
* // In a Wild Apricot cancel tool:
|
|
61
|
-
* await assertResourceOwnership({
|
|
62
|
-
* resolveSelf: () => resolveWaContactId(accountId),
|
|
63
|
-
* resolveOwner: async () => {
|
|
64
|
-
* const regs = await getContactRegistrations(accountId, await resolveWaContactId(accountId));
|
|
65
|
-
* return regs.find((r) => r.Id === registrationId)?.Contact.Id;
|
|
66
|
-
* },
|
|
67
|
-
* resourceLabel: 'event registration',
|
|
68
|
-
* });
|
|
69
|
-
*/
|
|
70
|
-
export declare function assertResourceOwnership<TId extends string | number>(params: {
|
|
71
|
-
resolveSelf: () => Promise<TId>;
|
|
72
|
-
resolveOwner: () => Promise<TId | null | undefined>;
|
|
73
|
-
resourceLabel?: string;
|
|
74
|
-
}): Promise<void>;
|
package/dist/mcp/ownership.js
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ownership guards for member-facing MCP tools.
|
|
3
|
-
*
|
|
4
|
-
* The LLM controls tool arguments. Any member-facing tool that accepts an
|
|
5
|
-
* external per-user identifier (a Wild Apricot contactId, a Discourse
|
|
6
|
-
* username, a LearnWorlds learner id, etc.) is a cross-member data-exposure
|
|
7
|
-
* risk: the caller can pass *someone else's* id and read or act on their
|
|
8
|
-
* data. The org-membership gate (`ensureAccountMembership`) does not catch
|
|
9
|
-
* this — it answers "is this person in the org?", never "is this person's
|
|
10
|
-
* own data?".
|
|
11
|
-
*
|
|
12
|
-
* These helpers make the safe path the default. A tool author reaches for
|
|
13
|
-
* `resolveSelfOnly` instead of writing a bespoke "default to me" resolver,
|
|
14
|
-
* and gets ownership enforcement for free.
|
|
15
|
-
*
|
|
16
|
-
* Resolving "who am I" is integration-specific (each integration maps the
|
|
17
|
-
* Supabase user to its own id space), so the caller supplies `resolveSelf`.
|
|
18
|
-
*/
|
|
19
|
-
/**
|
|
20
|
-
* Thrown when a tool caller attempts to access or act on another user's
|
|
21
|
-
* data. Tool `run` bodies should let this propagate (or convert it to a
|
|
22
|
-
* `{ error }` result) — the assistant surfaces a "not permitted" message
|
|
23
|
-
* rather than the forbidden data.
|
|
24
|
-
*/
|
|
25
|
-
export class ToolOwnershipError extends Error {
|
|
26
|
-
constructor(resourceLabel = 'data') {
|
|
27
|
-
super(`Forbidden: you can only access your own ${resourceLabel}.`);
|
|
28
|
-
this.code = 'FORBIDDEN_OWNERSHIP';
|
|
29
|
-
this.name = 'ToolOwnershipError';
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Self-only identity resolution for member-facing tools.
|
|
34
|
-
*
|
|
35
|
-
* - caller supplied no id → returns the caller's own id
|
|
36
|
-
* - caller supplied their own id → returns it
|
|
37
|
-
* - caller supplied a different id → throws ToolOwnershipError
|
|
38
|
-
*
|
|
39
|
-
* @example
|
|
40
|
-
* // In a Wild Apricot read tool:
|
|
41
|
-
* const contactId = await resolveSelfOnly({
|
|
42
|
-
* requestedId: args.contactId,
|
|
43
|
-
* resolveSelf: () => resolveWaContactId(accountId),
|
|
44
|
-
* resourceLabel: 'member data',
|
|
45
|
-
* });
|
|
46
|
-
*/
|
|
47
|
-
export async function resolveSelfOnly(params) {
|
|
48
|
-
const selfId = await params.resolveSelf();
|
|
49
|
-
if (params.requestedId !== undefined &&
|
|
50
|
-
params.requestedId !== null &&
|
|
51
|
-
params.requestedId !== selfId) {
|
|
52
|
-
throw new ToolOwnershipError(params.resourceLabel);
|
|
53
|
-
}
|
|
54
|
-
return selfId;
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Ownership verification for tools that act on an opaque resource id (an
|
|
58
|
-
* event registration, a saved document, an invoice) where ownership cannot
|
|
59
|
-
* be inferred from the id itself. Resolves the resource's owner and the
|
|
60
|
-
* caller's own id and throws if they differ (or if the resource has no
|
|
61
|
-
* resolvable owner — fail closed).
|
|
62
|
-
*
|
|
63
|
-
* @example
|
|
64
|
-
* // In a Wild Apricot cancel tool:
|
|
65
|
-
* await assertResourceOwnership({
|
|
66
|
-
* resolveSelf: () => resolveWaContactId(accountId),
|
|
67
|
-
* resolveOwner: async () => {
|
|
68
|
-
* const regs = await getContactRegistrations(accountId, await resolveWaContactId(accountId));
|
|
69
|
-
* return regs.find((r) => r.Id === registrationId)?.Contact.Id;
|
|
70
|
-
* },
|
|
71
|
-
* resourceLabel: 'event registration',
|
|
72
|
-
* });
|
|
73
|
-
*/
|
|
74
|
-
export async function assertResourceOwnership(params) {
|
|
75
|
-
const [selfId, ownerId] = await Promise.all([
|
|
76
|
-
params.resolveSelf(),
|
|
77
|
-
params.resolveOwner(),
|
|
78
|
-
]);
|
|
79
|
-
if (ownerId === null || ownerId === undefined || ownerId !== selfId) {
|
|
80
|
-
throw new ToolOwnershipError(params.resourceLabel);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
//# sourceMappingURL=ownership.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ownership.js","sourceRoot":"","sources":["../../src/mcp/ownership.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;GAKG;AACH,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAG3C,YAAY,aAAa,GAAG,MAAM;QAChC,KAAK,CAAC,2CAA2C,aAAa,GAAG,CAAC,CAAC;QAH5D,SAAI,GAAG,qBAAqB,CAAC;QAIpC,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAA8B,MAOlE;IACC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,EAAE,CAAC;IAE1C,IACE,MAAM,CAAC,WAAW,KAAK,SAAS;QAChC,MAAM,CAAC,WAAW,KAAK,IAAI;QAC3B,MAAM,CAAC,WAAW,KAAK,MAAM,EAC7B,CAAC;QACD,MAAM,IAAI,kBAAkB,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;IACrD,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAE3C,MAID;IACC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QAC1C,MAAM,CAAC,WAAW,EAAE;QACpB,MAAM,CAAC,YAAY,EAAE;KACtB,CAAC,CAAC;IAEH,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,SAAS,IAAI,OAAO,KAAK,MAAM,EAAE,CAAC;QACpE,MAAM,IAAI,kBAAkB,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;IACrD,CAAC;AACH,CAAC","sourcesContent":["/**\n * Ownership guards for member-facing MCP tools.\n *\n * The LLM controls tool arguments. Any member-facing tool that accepts an\n * external per-user identifier (a Wild Apricot contactId, a Discourse\n * username, a LearnWorlds learner id, etc.) is a cross-member data-exposure\n * risk: the caller can pass *someone else's* id and read or act on their\n * data. The org-membership gate (`ensureAccountMembership`) does not catch\n * this — it answers \"is this person in the org?\", never \"is this person's\n * own data?\".\n *\n * These helpers make the safe path the default. A tool author reaches for\n * `resolveSelfOnly` instead of writing a bespoke \"default to me\" resolver,\n * and gets ownership enforcement for free.\n *\n * Resolving \"who am I\" is integration-specific (each integration maps the\n * Supabase user to its own id space), so the caller supplies `resolveSelf`.\n */\n\n/**\n * Thrown when a tool caller attempts to access or act on another user's\n * data. Tool `run` bodies should let this propagate (or convert it to a\n * `{ error }` result) — the assistant surfaces a \"not permitted\" message\n * rather than the forbidden data.\n */\nexport class ToolOwnershipError extends Error {\n readonly code = 'FORBIDDEN_OWNERSHIP';\n\n constructor(resourceLabel = 'data') {\n super(`Forbidden: you can only access your own ${resourceLabel}.`);\n this.name = 'ToolOwnershipError';\n }\n}\n\n/**\n * Self-only identity resolution for member-facing tools.\n *\n * - caller supplied no id → returns the caller's own id\n * - caller supplied their own id → returns it\n * - caller supplied a different id → throws ToolOwnershipError\n *\n * @example\n * // In a Wild Apricot read tool:\n * const contactId = await resolveSelfOnly({\n * requestedId: args.contactId,\n * resolveSelf: () => resolveWaContactId(accountId),\n * resourceLabel: 'member data',\n * });\n */\nexport async function resolveSelfOnly<TId extends string | number>(params: {\n /** The identifier the LLM supplied as a tool argument, if any. */\n requestedId?: TId | null;\n /** Integration-specific resolver for the calling user's own id. */\n resolveSelf: () => Promise<TId>;\n /** Human-readable noun for the error message, e.g. 'member profile'. */\n resourceLabel?: string;\n}): Promise<TId> {\n const selfId = await params.resolveSelf();\n\n if (\n params.requestedId !== undefined &&\n params.requestedId !== null &&\n params.requestedId !== selfId\n ) {\n throw new ToolOwnershipError(params.resourceLabel);\n }\n\n return selfId;\n}\n\n/**\n * Ownership verification for tools that act on an opaque resource id (an\n * event registration, a saved document, an invoice) where ownership cannot\n * be inferred from the id itself. Resolves the resource's owner and the\n * caller's own id and throws if they differ (or if the resource has no\n * resolvable owner — fail closed).\n *\n * @example\n * // In a Wild Apricot cancel tool:\n * await assertResourceOwnership({\n * resolveSelf: () => resolveWaContactId(accountId),\n * resolveOwner: async () => {\n * const regs = await getContactRegistrations(accountId, await resolveWaContactId(accountId));\n * return regs.find((r) => r.Id === registrationId)?.Contact.Id;\n * },\n * resourceLabel: 'event registration',\n * });\n */\nexport async function assertResourceOwnership<\n TId extends string | number,\n>(params: {\n resolveSelf: () => Promise<TId>;\n resolveOwner: () => Promise<TId | null | undefined>;\n resourceLabel?: string;\n}): Promise<void> {\n const [selfId, ownerId] = await Promise.all([\n params.resolveSelf(),\n params.resolveOwner(),\n ]);\n\n if (ownerId === null || ownerId === undefined || ownerId !== selfId) {\n throw new ToolOwnershipError(params.resourceLabel);\n }\n}\n"]}
|