@fayz-ai/core 0.1.7 → 0.2.0

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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/dist/{chunk-EGWWQ7AT.cjs → chunk-5X2VX3RQ.cjs} +15 -4
  3. package/dist/chunk-5X2VX3RQ.cjs.map +1 -0
  4. package/dist/{chunk-YSBXGHAM.js → chunk-ATLZ3SGJ.js} +20 -11
  5. package/dist/chunk-ATLZ3SGJ.js.map +1 -0
  6. package/dist/{chunk-KAYQFSR6.js → chunk-CHGX6UYR.js} +15 -5
  7. package/dist/chunk-CHGX6UYR.js.map +1 -0
  8. package/dist/chunk-EKOH5PRX.js +33 -0
  9. package/dist/chunk-EKOH5PRX.js.map +1 -0
  10. package/dist/{chunk-SNQW6TRF.cjs → chunk-ILPJFUU6.cjs} +20 -11
  11. package/dist/chunk-ILPJFUU6.cjs.map +1 -0
  12. package/dist/chunk-MTDQDB7M.cjs +81 -0
  13. package/dist/chunk-MTDQDB7M.cjs.map +1 -0
  14. package/dist/chunk-NE4GLVVT.js +77 -0
  15. package/dist/chunk-NE4GLVVT.js.map +1 -0
  16. package/dist/chunk-OHRATZZH.cjs +35 -0
  17. package/dist/chunk-OHRATZZH.cjs.map +1 -0
  18. package/dist/{chunk-5EAZOMVP.js → chunk-QWUGGF2B.js} +30 -2
  19. package/dist/chunk-QWUGGF2B.js.map +1 -0
  20. package/dist/{chunk-LSVBDYFQ.cjs → chunk-VXEXM6IY.cjs} +30 -2
  21. package/dist/chunk-VXEXM6IY.cjs.map +1 -0
  22. package/dist/data/archetype.d.ts.map +1 -1
  23. package/dist/data/index.cjs +7 -7
  24. package/dist/data/index.js +1 -1
  25. package/dist/i18n/index.cjs +10 -10
  26. package/dist/i18n/index.d.ts.map +1 -1
  27. package/dist/i18n/index.js +1 -1
  28. package/dist/i18n/shell-translations.d.ts.map +1 -1
  29. package/dist/index.cjs +134 -67
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.d.ts +8 -3
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +50 -8
  34. package/dist/index.js.map +1 -1
  35. package/dist/integrations/index.cjs +12 -0
  36. package/dist/integrations/index.cjs.map +1 -0
  37. package/dist/integrations/index.d.ts +119 -0
  38. package/dist/integrations/index.d.ts.map +1 -0
  39. package/dist/integrations/index.js +3 -0
  40. package/dist/integrations/index.js.map +1 -0
  41. package/dist/plugin/index.cjs +7 -7
  42. package/dist/plugin/index.js +1 -1
  43. package/dist/plugin/runtime.d.ts +3 -0
  44. package/dist/plugin/runtime.d.ts.map +1 -1
  45. package/dist/router/history.d.ts +41 -0
  46. package/dist/router/history.d.ts.map +1 -0
  47. package/dist/testing/index.cjs +20 -0
  48. package/dist/testing/index.cjs.map +1 -0
  49. package/dist/testing/index.d.ts +19 -0
  50. package/dist/testing/index.d.ts.map +1 -0
  51. package/dist/testing/index.js +3 -0
  52. package/dist/testing/index.js.map +1 -0
  53. package/dist/types/crud.d.ts +24 -1
  54. package/dist/types/crud.d.ts.map +1 -1
  55. package/dist/types/index.d.ts +2 -2
  56. package/dist/types/index.d.ts.map +1 -1
  57. package/dist/types/plugins.d.ts +72 -1
  58. package/dist/types/plugins.d.ts.map +1 -1
  59. package/package.json +14 -2
  60. package/src/i18n/index.ts +24 -0
  61. package/src/i18n/shell-translations.ts +4 -0
  62. package/src/index.ts +32 -3
  63. package/src/integrations/index.ts +170 -0
  64. package/src/manifest/index.ts +1 -1
  65. package/src/plugin/runtime.ts +16 -2
  66. package/src/router/history.ts +86 -0
  67. package/src/testing/index.ts +116 -0
  68. package/src/types/crud.ts +25 -0
  69. package/src/types/index.ts +2 -2
  70. package/src/types/plugins.ts +75 -1
  71. package/dist/chunk-5EAZOMVP.js.map +0 -1
  72. package/dist/chunk-EGWWQ7AT.cjs.map +0 -1
  73. package/dist/chunk-KAYQFSR6.js.map +0 -1
  74. package/dist/chunk-LSVBDYFQ.cjs.map +0 -1
  75. package/dist/chunk-SNQW6TRF.cjs.map +0 -1
  76. package/dist/chunk-YSBXGHAM.js.map +0 -1
@@ -0,0 +1,170 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Integration Connector spine
3
+ // ---------------------------------------------------------------------------
4
+ // An integration is a connector that keeps canonical Fayz data in agreement
5
+ // with an external provider (Google Calendar, open banking, Shopify, …). This
6
+ // module is the thin shared contract both native (SDK) and client-built
7
+ // connectors implement. The heavy lifting (auth, fetch/push, idempotent
8
+ // upserts) runs in the data plane — typically a Supabase Edge Function — while
9
+ // the control plane (a settings tab + this contract) lives in the app/plugin.
10
+ //
11
+ // Design notes live in docs/bling-integration-brief.md. Proven precedents:
12
+ // the Fayz runtime OAuth broker (packages/core/src/runtime/oauth.ts) and the
13
+ // bank-statement edge functions in the predecessor app.
14
+ import type * as React from 'react'
15
+
16
+ /** How a connector authenticates to its provider. */
17
+ export type IntegrationAuthKind = 'oauth' | 'api-key' | 'mtls'
18
+
19
+ /** Which way data flows for a given capability. */
20
+ export type SyncDirection = 'inbound' | 'outbound' | 'bidirectional'
21
+
22
+ /** What causes a sync to run. */
23
+ export type SyncTrigger = 'on-write' | 'scheduled' | 'manual' | 'webhook'
24
+
25
+ /** One thing a connector can sync (an entity + its direction + triggers). */
26
+ export interface ConnectorCapability {
27
+ /** Canonical entity synced, e.g. 'booking', 'movement'. */
28
+ entity: string
29
+ direction: SyncDirection
30
+ triggers: SyncTrigger[]
31
+ }
32
+
33
+ /** The state of a tenant's connection to a provider (one row in a *_integrations table). */
34
+ export interface ConnectionConfig {
35
+ /** Provider key, e.g. 'google', 'plugbank'. */
36
+ provider: string
37
+ /** Whether the connection is active and should sync. */
38
+ active: boolean
39
+ /** Opaque, provider-specific config (calendar id, account id, cursor, …). Never secrets. */
40
+ settings?: Record<string, unknown>
41
+ /** Last successful sync, ISO timestamp. */
42
+ lastSyncAt?: string
43
+ }
44
+
45
+ /** Audit record of one sync execution (generalizes *_sync_log / *_sync_runs). */
46
+ export interface SyncRun {
47
+ id?: string
48
+ provider: string
49
+ direction: SyncDirection
50
+ trigger: SyncTrigger
51
+ status: 'success' | 'partial' | 'error'
52
+ /** Items pulled/pushed and how many were written. */
53
+ fetched?: number
54
+ written?: number
55
+ /** Pagination/incremental cursor advanced by this run. */
56
+ cursor?: string
57
+ error?: string
58
+ startedAt?: string
59
+ finishedAt?: string
60
+ }
61
+
62
+ export interface TestConnectionResult {
63
+ ok: boolean
64
+ message?: string
65
+ }
66
+
67
+ /**
68
+ * A connector's control-plane descriptor. The data-plane verbs (testConnection,
69
+ * sync) are usually thin clients that invoke a Supabase Edge Function — the
70
+ * function holds credentials and does the real work.
71
+ */
72
+ export interface Connector {
73
+ /** Stable connector id, e.g. 'google-calendar', 'plugbank'. */
74
+ id: string
75
+ /** Provider key matching ConnectionConfig.provider. */
76
+ provider: string
77
+ /** Plugin this connector extends, e.g. 'agenda', 'financial'. */
78
+ pluginId: string
79
+ authKind: IntegrationAuthKind
80
+ capabilities: ConnectorCapability[]
81
+ /** Validate the stored credentials/config without syncing. */
82
+ testConnection?(config: ConnectionConfig): Promise<TestConnectionResult>
83
+ /** Run a sync for the given direction; returns the audit record. */
84
+ sync?(input: { config: ConnectionConfig; direction: SyncDirection; trigger: SyncTrigger }): Promise<SyncRun>
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Connector UI contract — how an addon plugin EXTENDS a host plugin's settings
89
+ // ---------------------------------------------------------------------------
90
+ // An addon plugin declares one or more ConnectorDefinitions on its manifest
91
+ // (`connectors: [...]`). The runtime groups them by `hostPluginId`, and the host
92
+ // plugin's settings panel renders them in a unified "Integrations" tab — the
93
+ // same connect/credentials experience for every connector. The connector-
94
+ // specific bits (import a statement, sync now, history) live in `ExtraPanel`.
95
+
96
+ /** A credential input the unified setup form renders for an api-key/mtls connector. */
97
+ export interface ConnectorField {
98
+ key: string
99
+ label: string
100
+ type: 'text' | 'password'
101
+ placeholder?: string
102
+ }
103
+
104
+ export interface ConnectorStatus {
105
+ connected: boolean
106
+ detail?: string
107
+ }
108
+
109
+ /**
110
+ * UI-facing connector descriptor. Built on the data-plane `Connector` concept
111
+ * (`pluginId` → `hostPluginId`), plus the control-plane hooks the unified
112
+ * Integrations hub calls. Lives in core so `PluginManifest.connectors` can type
113
+ * it; the rendering hub lives in `@fayz-ai/saas`.
114
+ */
115
+ export interface ConnectorDefinition {
116
+ /** Stable connector id, e.g. 'google-calendar', 'plugbank'. */
117
+ id: string
118
+ /** The plugin this connector extends, e.g. 'financial', 'agenda'. */
119
+ hostPluginId: string
120
+ name: string
121
+ description?: string
122
+ /** Lucide icon name. */
123
+ icon?: string
124
+ authKind: IntegrationAuthKind
125
+ /** Declarative credentials — the unified form renders these (api-key / mtls). */
126
+ fields?: ConnectorField[]
127
+ /** Current connection state for the status badge. */
128
+ getStatus(): Promise<ConnectorStatus>
129
+ /** Validate credentials without persisting (api-key / mtls). */
130
+ testConnection?(values: Record<string, string>): Promise<TestConnectionResult>
131
+ /** Persist the connection (api-key / mtls). */
132
+ saveConnection?(values: Record<string, string>): Promise<void>
133
+ /** Begin an OAuth connect flow — returns the consent URL to redirect to. */
134
+ startOAuth?(redirectTo?: string): Promise<string>
135
+ /** Tear down the connection. */
136
+ disconnect?(): Promise<void>
137
+ /** Optional connector-specific UI below the connect panel (import / sync / history). */
138
+ ExtraPanel?: React.ComponentType
139
+ }
140
+
141
+ /**
142
+ * Wrap an object's async methods so a handler fires AFTER selected methods
143
+ * resolve — the non-invasive hook for outbound, event-driven sync when there's
144
+ * no app-level event bus. Example: wrap an AgendaDataProvider so that after
145
+ * createBooking/updateBooking/deleteBooking succeed, an external calendar is
146
+ * updated. The handler runs fire-and-forget; its failures never break the call.
147
+ */
148
+ export function withAfterHooks<T extends object>(
149
+ target: T,
150
+ methods: Partial<Record<keyof T, (args: unknown[], result: unknown) => void | Promise<void>>>,
151
+ ): T {
152
+ return new Proxy(target, {
153
+ get(obj, prop, receiver) {
154
+ const orig = Reflect.get(obj, prop, receiver)
155
+ const hook = (methods as Record<string | symbol, ((args: unknown[], result: unknown) => void | Promise<void>) | undefined>)[prop]
156
+ if (typeof orig !== 'function' || !hook) return orig
157
+ return (...args: unknown[]) => {
158
+ const result = (orig as (...a: unknown[]) => unknown).apply(obj, args)
159
+ if (result instanceof Promise) {
160
+ return result.then((value) => {
161
+ try { void Promise.resolve(hook(args, value)).catch(() => {}) } catch { /* never break the call */ }
162
+ return value
163
+ })
164
+ }
165
+ try { void Promise.resolve(hook(args, result)).catch(() => {}) } catch { /* noop */ }
166
+ return result
167
+ }
168
+ },
169
+ })
170
+ }
@@ -129,7 +129,7 @@ export function migrateManifest(input: AnyManifest): AppManifest {
129
129
  let version = typeof m.manifestVersion === 'number' ? m.manifestVersion : 1
130
130
  if (version > CURRENT_MANIFEST_VERSION) {
131
131
  throw new Error(
132
- `Manifest version ${version} is newer than this SDK supports (${CURRENT_MANIFEST_VERSION}). Upgrade @fayz-ai/app-runtime.`,
132
+ `Manifest version ${version} is newer than this SDK supports (${CURRENT_MANIFEST_VERSION}). Upgrade @fayz-ai/core.`,
133
133
  )
134
134
  }
135
135
  while (version < CURRENT_MANIFEST_VERSION) {
@@ -20,6 +20,7 @@ import type {
20
20
  DashboardSurface,
21
21
  } from '../types/plugins'
22
22
  import type { FeatureDeclaration } from '../types/permissions'
23
+ import type { ConnectorDefinition } from '../integrations'
23
24
  import { getComponent } from '../registry'
24
25
 
25
26
  /** The plugin contract version this runtime understands. A plugin declaring a
@@ -107,6 +108,7 @@ function createEmptyRuntime(context: PluginRuntimeContext = EMPTY_CONTEXT): Plug
107
108
  capabilities: [],
108
109
  aiTools: [],
109
110
  registries: new Map(),
111
+ connectorsByHost: new Map(),
110
112
  pluginFeatures: [],
111
113
  issues: [],
112
114
  }
@@ -136,7 +138,7 @@ export function resolvePluginRuntime({
136
138
  issues.push({
137
139
  type: 'incompatible_api_version',
138
140
  pluginId: plugin.id,
139
- message: `Plugin "${plugin.id}" requires plugin API v${plugin.apiVersion}, but this runtime supports v${PLUGIN_API_VERSION}. Upgrade @fayz-ai/app-runtime.`,
141
+ message: `Plugin "${plugin.id}" requires plugin API v${plugin.apiVersion}, but this runtime supports v${PLUGIN_API_VERSION}. Upgrade @fayz-ai/core.`,
140
142
  })
141
143
  continue
142
144
  }
@@ -238,6 +240,7 @@ export function resolvePluginRuntime({
238
240
  const capabilities: PluginCapability[] = []
239
241
  const aiTools: PluginAITool[] = []
240
242
  const registries = new Map<string, PluginRegistryDef[]>()
243
+ const connectorsByHost = new Map<string, ConnectorDefinition[]>()
241
244
  const pluginFeatures: FeatureDeclaration[] = []
242
245
 
243
246
  for (const plugin of activePlugins) {
@@ -251,6 +254,12 @@ export function resolvePluginRuntime({
251
254
  capabilities.push(...(plugin.capabilities ?? []))
252
255
  aiTools.push(...plugin.resolvedAITools)
253
256
  if (plugin.resolvedRegistries.length > 0) registries.set(plugin.id, plugin.resolvedRegistries)
257
+ // Group each addon's connectors under the host plugin they extend.
258
+ for (const connector of plugin.connectors ?? []) {
259
+ const list = connectorsByHost.get(connector.hostPluginId) ?? []
260
+ list.push(connector)
261
+ connectorsByHost.set(connector.hostPluginId, list)
262
+ }
254
263
  if (plugin.declaredFeatures) pluginFeatures.push(...plugin.declaredFeatures)
255
264
  widgets.push(...plugin.widgets.map((w, i) => ({
256
265
  ...w,
@@ -274,7 +283,7 @@ export function resolvePluginRuntime({
274
283
  widgets.sort((a, b) => a.zone !== b.zone ? a.zone.localeCompare(b.zone) : a.order - b.order)
275
284
  dashboardWidgets.sort((a, b) => a.order - b.order)
276
285
 
277
- return { context, plugins: resolvedPlugins, activePlugins, routes, navigation, settingsTabs, widgets, dashboardWidgets, capabilities, aiTools, issues, registries, pluginFeatures }
286
+ return { context, plugins: resolvedPlugins, activePlugins, routes, navigation, settingsTabs, widgets, dashboardWidgets, capabilities, aiTools, issues, registries, connectorsByHost, pluginFeatures }
278
287
  }
279
288
 
280
289
  export function getWidgetsForZone(
@@ -311,3 +320,8 @@ export function usePluginRuntime(): PluginRuntime {
311
320
  export function usePluginRuntimeOptional(): PluginRuntime | null {
312
321
  return React.useContext(PluginRuntimeContext)
313
322
  }
323
+
324
+ /** Connectors contributed by addon plugins that extend the given host plugin. */
325
+ export function useConnectorsForPlugin(hostPluginId: string): ConnectorDefinition[] {
326
+ return usePluginRuntime().connectorsByHost.get(hostPluginId) ?? []
327
+ }
@@ -0,0 +1,86 @@
1
+ import { create } from 'zustand'
2
+
3
+ /**
4
+ * Global, cross-module navigation history.
5
+ *
6
+ * Per-module nav hooks (useModuleNavigation) only remember views *within* one
7
+ * module, so they can't tell you where you came from after crossing a module
8
+ * boundary (e.g. Financial statements → a client/person page). This store sits
9
+ * above all modules: the app shell records every route change here with a
10
+ * human label, and the shared SubpageHeader reads it to render a context-aware
11
+ * "back to {where you came from}" link that returns to the actual previous page.
12
+ */
13
+
14
+ export interface NavHistoryEntry {
15
+ /** Route path without the leading '#', e.g. '/financial/statements'. */
16
+ path: string
17
+ /** Human, module-level label for the page, e.g. 'Financeiro'. */
18
+ label: string
19
+ }
20
+
21
+ interface NavHistoryState {
22
+ stack: NavHistoryEntry[]
23
+ /** Record a visit. Consecutive visits to the same path collapse (label refreshes). */
24
+ record: (entry: NavHistoryEntry) => void
25
+ }
26
+
27
+ const MAX_ENTRIES = 20
28
+
29
+ export const useNavHistoryStore = create<NavHistoryState>((set) => ({
30
+ stack: [],
31
+ record: (entry) =>
32
+ set((s) => {
33
+ const top = s.stack[s.stack.length - 1]
34
+ if (top && top.path === entry.path) {
35
+ if (top.label === entry.label) return s
36
+ const next = s.stack.slice()
37
+ next[next.length - 1] = entry
38
+ return { stack: next }
39
+ }
40
+ const next = [...s.stack, entry]
41
+ return { stack: next.length > MAX_ENTRIES ? next.slice(-MAX_ENTRIES) : next }
42
+ }),
43
+ }))
44
+
45
+ /** Record a route visit. Call from the app shell whenever the route changes. */
46
+ export function recordNavigation(path: string, label: string): void {
47
+ useNavHistoryStore.getState().record({ path, label })
48
+ }
49
+
50
+ // Dev-only debug handle: inspect history in the console via `__navHistory.getState().stack`.
51
+ if (typeof window !== 'undefined') {
52
+ ;(window as unknown as { __navHistory?: typeof useNavHistoryStore }).__navHistory = useNavHistoryStore
53
+ }
54
+
55
+ /**
56
+ * The page the user most recently came from, relative to `currentPath`.
57
+ *
58
+ * Returns the latest recorded entry whose path differs from `currentPath`. This
59
+ * is intentionally robust to ordering: it gives the right referrer whether or
60
+ * not the current page has been recorded yet, avoiding a stale first paint.
61
+ */
62
+ export function useNavReferrer(currentPath: string): NavHistoryEntry | null {
63
+ return useNavHistoryStore((s) => {
64
+ for (let i = s.stack.length - 1; i >= 0; i--) {
65
+ if (s.stack[i].path !== currentPath) return s.stack[i]
66
+ }
67
+ return null
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Return to the actual previous page via the browser history. Falls back to the
73
+ * provided callback when there's no in-app history to pop (e.g. a deep link).
74
+ */
75
+ export function navHistoryBack(fallback?: () => void): void {
76
+ if (useNavHistoryStore.getState().stack.length >= 2) {
77
+ window.history.back()
78
+ } else {
79
+ fallback?.()
80
+ }
81
+ }
82
+
83
+ /** Top-level module segment of a route path ('/financial/statements' → 'financial'). */
84
+ export function routeModule(path: string): string {
85
+ return path.replace(/^\/+/, '').split('/')[0] ?? ''
86
+ }
@@ -0,0 +1,116 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Plugin contract assertions — the integrity half of the deploy gate.
3
+ // ---------------------------------------------------------------------------
4
+ // Framework-agnostic runtime checks (no test-runner dependency) that any
5
+ // plugin's *.test.ts calls to prove its manifest/connector conform to the
6
+ // platform contract BEFORE it can ship. A plugin that fails these would break
7
+ // the host app at mount time (missing route component, duplicate ids, malformed
8
+ // settings tab). Pair with a capability test (does the plugin do its job?) — see
9
+ // PLUGIN_PATTERNS.md → capability anatomy. These throw on the first violation.
10
+ import type { PluginManifest } from '../types/plugins'
11
+ import type { Connector } from '../integrations'
12
+
13
+ export class PluginContractError extends Error {
14
+ constructor(message: string) {
15
+ super(message)
16
+ this.name = 'PluginContractError'
17
+ }
18
+ }
19
+
20
+ function fail(scope: string, msg: string): never {
21
+ throw new PluginContractError(`[${scope}] ${msg}`)
22
+ }
23
+
24
+ function nonEmptyString(v: unknown): v is string {
25
+ return typeof v === 'string' && v.trim().length > 0
26
+ }
27
+
28
+ /**
29
+ * Assert a PluginManifest is structurally sound enough to mount safely.
30
+ * Checks identity, navigation/route wiring, settings tabs, declared features,
31
+ * and id uniqueness. Throws PluginContractError on the first problem.
32
+ */
33
+ export function assertPluginManifestContract(manifest: PluginManifest): void {
34
+ const id = manifest?.id
35
+ const scope = nonEmptyString(id) ? id : '<unknown plugin>'
36
+
37
+ for (const field of ['id', 'name', 'icon', 'version'] as const) {
38
+ if (!nonEmptyString(manifest?.[field])) fail(scope, `manifest.${field} must be a non-empty string`)
39
+ }
40
+
41
+ if (!Array.isArray(manifest.navigation)) fail(scope, 'manifest.navigation must be an array (use [] for settings-only plugins)')
42
+ if (!Array.isArray(manifest.routes)) fail(scope, 'manifest.routes must be an array (use [] for settings-only plugins)')
43
+
44
+ // Every nav entry must point at a route; every route needs a path + component.
45
+ for (const [i, nav] of manifest.navigation.entries()) {
46
+ if (!nonEmptyString((nav as any)?.route)) fail(scope, `navigation[${i}].route must be a non-empty string`)
47
+ }
48
+ const routePaths = new Set<string>()
49
+ for (const [i, route] of manifest.routes.entries()) {
50
+ if (!nonEmptyString((route as any)?.path)) fail(scope, `routes[${i}].path must be a non-empty string`)
51
+ if (typeof (route as any)?.component !== 'function') fail(scope, `routes[${i}].component must be a component`)
52
+ if (routePaths.has((route as any).path)) fail(scope, `duplicate route path "${(route as any).path}"`)
53
+ routePaths.add((route as any).path)
54
+ }
55
+ // A nav entry that links to a route the plugin doesn't declare is dead UI.
56
+ for (const [i, nav] of manifest.navigation.entries()) {
57
+ const r = (nav as any).route
58
+ if (routePaths.size > 0 && !routePaths.has(r)) {
59
+ fail(scope, `navigation[${i}].route "${r}" has no matching route in manifest.routes`)
60
+ }
61
+ }
62
+
63
+ // Settings tabs (the only UI surface integrations need) must be renderable.
64
+ const settingIds = new Set<string>()
65
+ for (const [i, tab] of (manifest.settings ?? []).entries()) {
66
+ if (!nonEmptyString((tab as any)?.id)) fail(scope, `settings[${i}].id must be a non-empty string`)
67
+ if (!nonEmptyString((tab as any)?.label)) fail(scope, `settings[${i}].label must be a non-empty string`)
68
+ if (typeof (tab as any)?.component !== 'function') fail(scope, `settings[${i}].component must be a component`)
69
+ if (settingIds.has((tab as any).id)) fail(scope, `duplicate settings tab id "${(tab as any).id}"`)
70
+ settingIds.add((tab as any).id)
71
+ }
72
+
73
+ for (const [i, feat] of (manifest.declaredFeatures ?? []).entries()) {
74
+ if (!nonEmptyString((feat as any)?.id)) fail(scope, `declaredFeatures[${i}].id must be a non-empty string`)
75
+ }
76
+
77
+ // A plugin that contributes nothing mountable is almost always a mistake.
78
+ // (Connector-only addon plugins contribute via `connectors` instead.)
79
+ if (
80
+ manifest.navigation.length === 0 &&
81
+ manifest.routes.length === 0 &&
82
+ (manifest.settings ?? []).length === 0 &&
83
+ (manifest.connectors ?? []).length === 0
84
+ ) {
85
+ fail(scope, 'plugin contributes no navigation, routes, settings, or connectors — it would mount but do nothing')
86
+ }
87
+ }
88
+
89
+ const AUTH_KINDS = new Set(['oauth', 'api-key', 'mtls'])
90
+ const DIRECTIONS = new Set(['inbound', 'outbound', 'bidirectional'])
91
+ const TRIGGERS = new Set(['on-write', 'scheduled', 'manual', 'webhook'])
92
+
93
+ /**
94
+ * Assert an integration Connector descriptor is well-formed: identity present,
95
+ * a known auth kind, and at least one capability with a valid direction +
96
+ * trigger set. This is the contract a connector must satisfy BEFORE its
97
+ * data-plane (edge function) exists — the TDD anchor for an integration.
98
+ */
99
+ export function assertConnectorContract(connector: Connector): void {
100
+ const scope = nonEmptyString(connector?.id) ? `connector:${connector.id}` : '<unknown connector>'
101
+ for (const field of ['id', 'provider', 'pluginId'] as const) {
102
+ if (!nonEmptyString(connector?.[field])) fail(scope, `connector.${field} must be a non-empty string`)
103
+ }
104
+ if (!AUTH_KINDS.has(connector.authKind)) fail(scope, `connector.authKind "${connector.authKind}" is not one of ${[...AUTH_KINDS].join(', ')}`)
105
+ if (!Array.isArray(connector.capabilities) || connector.capabilities.length === 0) {
106
+ fail(scope, 'connector.capabilities must list at least one capability')
107
+ }
108
+ for (const [i, cap] of connector.capabilities.entries()) {
109
+ if (!nonEmptyString(cap?.entity)) fail(scope, `capabilities[${i}].entity must be a non-empty string`)
110
+ if (!DIRECTIONS.has(cap.direction)) fail(scope, `capabilities[${i}].direction "${cap.direction}" is invalid`)
111
+ if (!Array.isArray(cap.triggers) || cap.triggers.length === 0) fail(scope, `capabilities[${i}].triggers must be non-empty`)
112
+ for (const trig of cap.triggers) {
113
+ if (!TRIGGERS.has(trig)) fail(scope, `capabilities[${i}] has unknown trigger "${trig}"`)
114
+ }
115
+ }
116
+ }
package/src/types/crud.ts CHANGED
@@ -5,10 +5,30 @@ export type FieldType =
5
5
  | 'text' | 'email' | 'phone' | 'url' | 'image'
6
6
  | 'number' | 'currency'
7
7
  | 'select' | 'multiselect' | 'segmented'
8
+ | 'relation'
8
9
  | 'date' | 'datetime' | 'time'
9
10
  | 'boolean' | 'textarea'
10
11
  | 'color' | 'computed'
11
12
 
13
+ /** Foreign-key source for a `relation` field: the option list is loaded from a
14
+ * table at runtime ({value→label}) so the stored value is a real id (e.g. a
15
+ * uuid), not a static string. Use for FK columns the user must pick from a
16
+ * seeded/managed table (e.g. payment_method_type_id → payment_method_types). */
17
+ export interface FieldRelation {
18
+ /** Table to read options from (e.g. 'payment_method_types'). */
19
+ table: string
20
+ /** Column stored as the field value. Default 'id'. */
21
+ valueField?: string
22
+ /** Column shown as the option label. Default 'name'. */
23
+ labelField?: string
24
+ /** Scope options to the active tenant via tenant_id. Default true. */
25
+ tenantScoped?: boolean
26
+ /** Schema the table lives in (e.g. 'saas_core'). Default public. */
27
+ schema?: string
28
+ /** Extra equality filters applied to the option query. */
29
+ filter?: Record<string, unknown>
30
+ }
31
+
12
32
  /** Read-only display produced by a `computed` field's `compute()`. */
13
33
  export interface ComputedFieldValue {
14
34
  display: string
@@ -27,6 +47,8 @@ export interface FieldDef {
27
47
  /** Options for `select` / `multiselect` / `segmented`. The optional
28
48
  * `description` is shown under the label on `segmented` radio-cards. */
29
49
  options?: string[] | { label: string; value: string; description?: string }[]
50
+ /** Required for `relation` fields — where to load the option list from. */
51
+ relation?: FieldRelation
30
52
  min?: number
31
53
  max?: number
32
54
  /** ISO currency code for `currency` fields (e.g. 'BRL'). */
@@ -71,6 +93,9 @@ export interface DetailTab {
71
93
  componentId?: string
72
94
  visibleFor?: string[]
73
95
  props?: Record<string, unknown>
96
+ /** Only show this tab when a plugin has contributed a widget to this zone (e.g. a
97
+ * financial plugin enabling a per-person statement tab). Hidden when no widget. */
98
+ requiresWidgetZone?: string
74
99
  }
75
100
 
76
101
  export type FormLayout = 'person' | 'product' | 'service' | 'location' | 'order' | 'subject' | 'generic'
@@ -1,7 +1,7 @@
1
1
  // Re-export all core types
2
2
  export type { EntityArchetype, BaseEntity, PersonEntity, ProductEntity, ServiceEntity, OrderEntity, TransactionEntity, LocationEntity, ScheduleEntity, CategoryEntity } from './entities'
3
- export type { EntityDef, FieldDef, FieldType, FieldGroup, DetailTab, FormLayout, ComputedFieldValue } from './crud'
4
- export type { PluginManifest, PluginScope, PluginStatus, ResolvedPluginManifest, PluginRuntime, PluginRuntimeContext, PluginWidgetZone, PluginWidgetDefinition, PluginNavigationEntry, PluginSettingsTab, PluginRouteDefinition, PluginAITool, PluginRegistryDef, PluginMigration, VerticalId, ScaffoldType } from './plugins'
3
+ export type { EntityDef, FieldDef, FieldType, FieldRelation, FieldGroup, DetailTab, FormLayout, ComputedFieldValue } from './crud'
4
+ export type { PluginManifest, PluginScope, PluginStatus, ResolvedPluginManifest, PluginRuntime, PluginRuntimeContext, PluginWidgetZone, PluginWidgetDefinition, PluginNavigationEntry, PluginSettingsTab, PluginRouteDefinition, PluginAITool, PluginRegistryDef, PluginMigration, PluginServerAction, PluginCustomFieldsDef, PluginDiagnostic, VerticalId, ScaffoldType } from './plugins'
5
5
  export type { AuthAdapter, AuthUser, AuthSession } from './auth'
6
6
  export type { OrgAdapter, Organization, OrgMember } from './org'
7
7
  export type { PermissionsConfig, FeatureDeclaration, PermissionAction } from './permissions'
@@ -1,6 +1,7 @@
1
1
  import type React from 'react'
2
- import type { EntityDef } from './crud'
2
+ import type { EntityDef, FieldDef } from './crud'
3
3
  import type { FeatureDeclaration, PermissionAction } from './permissions'
4
+ import type { ConnectorDefinition } from '../integrations'
4
5
 
5
6
  export type VerticalId = 'beauty' | 'food' | 'health' | 'services' | 'retail' | 'education' | (string & {})
6
7
  export type PluginScope = 'core' | 'vertical' | 'universal' | 'addon' | 'tenant'
@@ -260,6 +261,65 @@ export interface PluginOnboarding {
260
261
  description?: string
261
262
  }
262
263
 
264
+ /**
265
+ * A server-side action or workflow a plugin exposes. The action runs *behind the
266
+ * Fayz boundary* — a Supabase Edge Function or an RPC, never in the app — so
267
+ * provider credentials stay server-side (see docs/architecture-boundaries.md §4).
268
+ * Declared as data so the AppManifest can bind triggers→actions and the AI can
269
+ * call them safely. Implementation wiring is intentionally lazy; this reserves the
270
+ * contract shape.
271
+ */
272
+ export interface PluginServerAction {
273
+ id: string
274
+ name: string
275
+ description?: string
276
+ /** How the action is executed behind the Fayz boundary. */
277
+ kind: 'edge-function' | 'rpc'
278
+ /** Edge function name or RPC name to invoke. */
279
+ handler: string
280
+ /** JSON Schema for the action input (editor / AI / validation). */
281
+ inputSchema?: Record<string, unknown>
282
+ /** JSON Schema for the action result. */
283
+ outputSchema?: Record<string, unknown>
284
+ permission?: PluginPermissionRequirement
285
+ /** Event name(s) that trigger this action when emitted on the bus. */
286
+ triggers?: string[]
287
+ }
288
+
289
+ /**
290
+ * Custom fields a plugin adds to an entity *without editing that entity's owner*.
291
+ * The fields are persisted on an extension table / JSONB column resolved by the
292
+ * data provider — the declarative seam behind layer-C (private extension) custom
293
+ * fields. See docs/architecture-boundaries.md §5.
294
+ */
295
+ export interface PluginCustomFieldsDef {
296
+ /** Entity key these fields extend, e.g. 'crm.client'. */
297
+ entity: string
298
+ fields: FieldDef[]
299
+ /** Extension table that stores them; provider convention applies when omitted. */
300
+ table?: string
301
+ }
302
+
303
+ /**
304
+ * A health check a plugin contributes to the diagnostics / boot report surfaced by
305
+ * `fayz doctor`. Declared as data so the platform can verify a plugin's backend
306
+ * prerequisites are present without running it.
307
+ */
308
+ export interface PluginDiagnostic {
309
+ id: string
310
+ description?: string
311
+ /** What the runtime should verify exists for this plugin to work. */
312
+ requires: {
313
+ rpcs?: string[]
314
+ views?: string[]
315
+ tables?: string[]
316
+ migrations?: string[]
317
+ env?: string[]
318
+ }
319
+ /** Severity when the requirement is missing. Defaults to 'warn'. */
320
+ level?: 'error' | 'warn' | 'info'
321
+ }
322
+
263
323
  export interface PluginManifest {
264
324
  id: string
265
325
  name: string
@@ -291,7 +351,19 @@ export interface PluginManifest {
291
351
  permissions?: string[]
292
352
  declaredFeatures?: FeatureDeclaration[]
293
353
  registries?: PluginRegistryDef[]
354
+ /**
355
+ * Connectors this plugin contributes. An ADDON plugin declares its connector(s)
356
+ * here, each naming the `hostPluginId` it extends; the runtime groups them by
357
+ * host and the host plugin's settings render them in a unified Integrations tab.
358
+ */
359
+ connectors?: ConnectorDefinition[]
294
360
  migrations?: PluginMigration[]
361
+ /** Server-side actions/workflows the plugin exposes behind the Fayz boundary. */
362
+ serverActions?: PluginServerAction[]
363
+ /** Custom fields this plugin adds to entities it does not own. */
364
+ customFields?: PluginCustomFieldsDef[]
365
+ /** Backend prerequisites the plugin needs; surfaced by `fayz doctor`. */
366
+ diagnostics?: PluginDiagnostic[]
295
367
  onboarding?: PluginOnboarding
296
368
  locales?: Record<string, Record<string, string>>
297
369
  /** Marketplace metadata for npm discovery */
@@ -359,5 +431,7 @@ export interface PluginRuntime {
359
431
  aiTools: PluginAITool[]
360
432
  issues: PluginRuntimeIssue[]
361
433
  registries: Map<string, PluginRegistryDef[]>
434
+ /** Connectors contributed by active plugins, grouped by the host plugin they extend. */
435
+ connectorsByHost: Map<string, ConnectorDefinition[]>
362
436
  pluginFeatures: FeatureDeclaration[]
363
437
  }