@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.
- package/LICENSE +21 -0
- package/dist/{chunk-EGWWQ7AT.cjs → chunk-5X2VX3RQ.cjs} +15 -4
- package/dist/chunk-5X2VX3RQ.cjs.map +1 -0
- package/dist/{chunk-YSBXGHAM.js → chunk-ATLZ3SGJ.js} +20 -11
- package/dist/chunk-ATLZ3SGJ.js.map +1 -0
- package/dist/{chunk-KAYQFSR6.js → chunk-CHGX6UYR.js} +15 -5
- package/dist/chunk-CHGX6UYR.js.map +1 -0
- package/dist/chunk-EKOH5PRX.js +33 -0
- package/dist/chunk-EKOH5PRX.js.map +1 -0
- package/dist/{chunk-SNQW6TRF.cjs → chunk-ILPJFUU6.cjs} +20 -11
- package/dist/chunk-ILPJFUU6.cjs.map +1 -0
- package/dist/chunk-MTDQDB7M.cjs +81 -0
- package/dist/chunk-MTDQDB7M.cjs.map +1 -0
- package/dist/chunk-NE4GLVVT.js +77 -0
- package/dist/chunk-NE4GLVVT.js.map +1 -0
- package/dist/chunk-OHRATZZH.cjs +35 -0
- package/dist/chunk-OHRATZZH.cjs.map +1 -0
- package/dist/{chunk-5EAZOMVP.js → chunk-QWUGGF2B.js} +30 -2
- package/dist/chunk-QWUGGF2B.js.map +1 -0
- package/dist/{chunk-LSVBDYFQ.cjs → chunk-VXEXM6IY.cjs} +30 -2
- package/dist/chunk-VXEXM6IY.cjs.map +1 -0
- package/dist/data/archetype.d.ts.map +1 -1
- package/dist/data/index.cjs +7 -7
- package/dist/data/index.js +1 -1
- package/dist/i18n/index.cjs +10 -10
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/shell-translations.d.ts.map +1 -1
- package/dist/index.cjs +134 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +50 -8
- package/dist/index.js.map +1 -1
- package/dist/integrations/index.cjs +12 -0
- package/dist/integrations/index.cjs.map +1 -0
- package/dist/integrations/index.d.ts +119 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +3 -0
- package/dist/integrations/index.js.map +1 -0
- package/dist/plugin/index.cjs +7 -7
- package/dist/plugin/index.js +1 -1
- package/dist/plugin/runtime.d.ts +3 -0
- package/dist/plugin/runtime.d.ts.map +1 -1
- package/dist/router/history.d.ts +41 -0
- package/dist/router/history.d.ts.map +1 -0
- package/dist/testing/index.cjs +20 -0
- package/dist/testing/index.cjs.map +1 -0
- package/dist/testing/index.d.ts +19 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types/crud.d.ts +24 -1
- package/dist/types/crud.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/plugins.d.ts +72 -1
- package/dist/types/plugins.d.ts.map +1 -1
- package/package.json +14 -2
- package/src/i18n/index.ts +24 -0
- package/src/i18n/shell-translations.ts +4 -0
- package/src/index.ts +32 -3
- package/src/integrations/index.ts +170 -0
- package/src/manifest/index.ts +1 -1
- package/src/plugin/runtime.ts +16 -2
- package/src/router/history.ts +86 -0
- package/src/testing/index.ts +116 -0
- package/src/types/crud.ts +25 -0
- package/src/types/index.ts +2 -2
- package/src/types/plugins.ts +75 -1
- package/dist/chunk-5EAZOMVP.js.map +0 -1
- package/dist/chunk-EGWWQ7AT.cjs.map +0 -1
- package/dist/chunk-KAYQFSR6.js.map +0 -1
- package/dist/chunk-LSVBDYFQ.cjs.map +0 -1
- package/dist/chunk-SNQW6TRF.cjs.map +0 -1
- 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
|
+
}
|
package/src/manifest/index.ts
CHANGED
|
@@ -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/
|
|
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) {
|
package/src/plugin/runtime.ts
CHANGED
|
@@ -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/
|
|
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'
|
package/src/types/index.ts
CHANGED
|
@@ -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'
|
package/src/types/plugins.ts
CHANGED
|
@@ -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
|
}
|