@fayz-ai/core 0.8.0 → 0.8.2

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 (98) hide show
  1. package/dist/access/denial.d.ts +21 -0
  2. package/dist/access/denial.d.ts.map +1 -0
  3. package/dist/access/engine.d.ts +63 -0
  4. package/dist/access/engine.d.ts.map +1 -0
  5. package/dist/access/index.cjs +60 -0
  6. package/dist/access/index.cjs.map +1 -0
  7. package/dist/access/index.d.ts +6 -0
  8. package/dist/access/index.d.ts.map +1 -0
  9. package/dist/access/index.js +51 -0
  10. package/dist/access/index.js.map +1 -0
  11. package/dist/access/limits.d.ts +10 -0
  12. package/dist/access/limits.d.ts.map +1 -0
  13. package/dist/address/index.d.ts +52 -0
  14. package/dist/address/index.d.ts.map +1 -0
  15. package/dist/{chunk-WKZVSKEU.js → chunk-CU64QI4A.js} +2 -2
  16. package/dist/chunk-CU64QI4A.js.map +1 -0
  17. package/dist/{chunk-54SRL7AJ.cjs → chunk-CYAWDW44.cjs} +141 -84
  18. package/dist/chunk-CYAWDW44.cjs.map +1 -0
  19. package/dist/{chunk-KNIVT6XZ.js → chunk-JHGXROKV.js} +230 -2
  20. package/dist/chunk-JHGXROKV.js.map +1 -0
  21. package/dist/{chunk-QR7U2URY.cjs → chunk-SO3VUKGZ.cjs} +230 -2
  22. package/dist/chunk-SO3VUKGZ.cjs.map +1 -0
  23. package/dist/{chunk-2NTHNWEH.cjs → chunk-UKU4RIWM.cjs} +2 -2
  24. package/dist/chunk-UKU4RIWM.cjs.map +1 -0
  25. package/dist/{chunk-ZBIWYDAR.js → chunk-XJKW2JRW.js} +140 -85
  26. package/dist/chunk-XJKW2JRW.js.map +1 -0
  27. package/dist/{chunk-CHGX6UYR.js → chunk-YH25Y4FW.js} +6 -3
  28. package/dist/chunk-YH25Y4FW.js.map +1 -0
  29. package/dist/{chunk-5X2VX3RQ.cjs → chunk-ZOPJB5FN.cjs} +6 -3
  30. package/dist/chunk-ZOPJB5FN.cjs.map +1 -0
  31. package/dist/data/count.d.ts +34 -0
  32. package/dist/data/count.d.ts.map +1 -0
  33. package/dist/data/index.cjs +18 -10
  34. package/dist/data/index.d.ts +2 -0
  35. package/dist/data/index.d.ts.map +1 -1
  36. package/dist/data/index.js +1 -1
  37. package/dist/data/resolve.d.ts.map +1 -1
  38. package/dist/entity/index.cjs +6 -6
  39. package/dist/entity/index.js +1 -1
  40. package/dist/entity/registry.d.ts +9 -0
  41. package/dist/entity/registry.d.ts.map +1 -1
  42. package/dist/i18n/index.cjs +10 -10
  43. package/dist/i18n/index.js +1 -1
  44. package/dist/i18n/shell-translations.d.ts.map +1 -1
  45. package/dist/index.cjs +852 -124
  46. package/dist/index.cjs.map +1 -1
  47. package/dist/index.d.ts +10 -4
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +765 -56
  50. package/dist/index.js.map +1 -1
  51. package/dist/manifest/index.d.ts +109 -3
  52. package/dist/manifest/index.d.ts.map +1 -1
  53. package/dist/manifest/serialize.d.ts +14 -0
  54. package/dist/manifest/serialize.d.ts.map +1 -0
  55. package/dist/plugin/index.cjs +7 -7
  56. package/dist/plugin/index.js +1 -1
  57. package/dist/plugin/runtime.d.ts.map +1 -1
  58. package/dist/types/billing.d.ts +20 -0
  59. package/dist/types/billing.d.ts.map +1 -1
  60. package/dist/types/crud.d.ts +48 -2
  61. package/dist/types/crud.d.ts.map +1 -1
  62. package/dist/types/entitlements.d.ts +45 -0
  63. package/dist/types/entitlements.d.ts.map +1 -0
  64. package/dist/types/index.d.ts +4 -3
  65. package/dist/types/index.d.ts.map +1 -1
  66. package/dist/types/plugins.d.ts +84 -0
  67. package/dist/types/plugins.d.ts.map +1 -1
  68. package/package.json +14 -4
  69. package/src/access/denial.ts +33 -0
  70. package/src/access/engine.test.ts +48 -0
  71. package/src/access/engine.ts +121 -0
  72. package/src/access/index.ts +13 -0
  73. package/src/access/limits.ts +18 -0
  74. package/src/address/index.ts +188 -0
  75. package/src/data/count.test.ts +152 -0
  76. package/src/data/count.ts +131 -0
  77. package/src/data/index.ts +2 -0
  78. package/src/data/resolve.ts +7 -1
  79. package/src/entity/registry.ts +7 -0
  80. package/src/i18n/shell-translations.ts +230 -0
  81. package/src/index.ts +18 -3
  82. package/src/manifest/app-manifest.schema.json +618 -45
  83. package/src/manifest/index.ts +127 -3
  84. package/src/manifest/serialize.ts +52 -0
  85. package/src/plugin/runtime.ts +5 -1
  86. package/src/types/billing.ts +22 -0
  87. package/src/types/crud.ts +49 -2
  88. package/src/types/entitlements.ts +45 -0
  89. package/src/types/index.ts +4 -3
  90. package/src/types/plugins.ts +79 -0
  91. package/dist/chunk-2NTHNWEH.cjs.map +0 -1
  92. package/dist/chunk-54SRL7AJ.cjs.map +0 -1
  93. package/dist/chunk-5X2VX3RQ.cjs.map +0 -1
  94. package/dist/chunk-CHGX6UYR.js.map +0 -1
  95. package/dist/chunk-KNIVT6XZ.js.map +0 -1
  96. package/dist/chunk-QR7U2URY.cjs.map +0 -1
  97. package/dist/chunk-WKZVSKEU.js.map +0 -1
  98. package/dist/chunk-ZBIWYDAR.js.map +0 -1
@@ -1,7 +1,9 @@
1
1
  import type { EntityDef } from '../types/crud'
2
2
  import type { LocaleConfig } from '../types/i18n'
3
- import type { PermissionsConfig } from '../types/permissions'
3
+ import type { PermissionsConfig, FeatureDeclaration } from '../types/permissions'
4
4
  import type { BillingConfig } from '../types/billing'
5
+ import type { LimitDeclaration } from '../types/entitlements'
6
+ import type { AIToolParameters, AIToolExecution, AgentRpcDeclaration } from '../types/plugins'
5
7
  import type { BlockNode } from '../blocks'
6
8
 
7
9
  // ---------------------------------------------------------------------------
@@ -18,7 +20,7 @@ import type { BlockNode } from '../blocks'
18
20
  // right is why W1 is the "point of no return".
19
21
  // ---------------------------------------------------------------------------
20
22
 
21
- export const CURRENT_MANIFEST_VERSION = 2
23
+ export const CURRENT_MANIFEST_VERSION = 3
22
24
 
23
25
  export type BackendProvider = 'supabase' | 'fayz-api' | 'fayz-shop' | 'mock' | 'custom'
24
26
 
@@ -68,6 +70,97 @@ export interface SurfaceManifest {
68
70
  options?: Record<string, unknown>
69
71
  }
70
72
 
73
+ // ---------------------------------------------------------------------------
74
+ // Agent Contract (manifest v3) — everything a SERVER-SIDE agent runtime needs
75
+ // to operate the app without a browser: the full entity surface, the tool
76
+ // catalog with its authorization requirements, the pool RPCs writes go
77
+ // through, and the structured domain knowledge the app wants in the prompt.
78
+ // Derived from app code at publish (never hand-written) and synced to the
79
+ // Fayz platform, where it becomes the versioned AgentContract the broker
80
+ // validates every turn against.
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /** JSON-safe projection of an EntityDef — schema only, no components/closures.
84
+ * `data` mirrors EntityDef.data: everything the server-plane read executor
85
+ * needs (tenant scoping, search/select column allowlists, fixed filters). */
86
+ export interface EntityContract {
87
+ key: string
88
+ label: string
89
+ labelPlural?: string
90
+ /** Plan-limit key rows of this entity consume (EntityDef.limitKey). */
91
+ limitKey?: string
92
+ /** Permission to READ via the generic data primitives (EntityDef.permission). */
93
+ permission?: { feature: string; action: string }
94
+ data: {
95
+ table: string
96
+ schema?: string
97
+ tenantScoped?: boolean
98
+ tenantIdColumn?: string
99
+ searchColumns?: string[]
100
+ selectColumns?: string
101
+ columnMap?: Record<string, string>
102
+ archetype?: string
103
+ archetypeKind?: string
104
+ filters?: Record<string, string>
105
+ }
106
+ fields: Array<{
107
+ key: string
108
+ label: string
109
+ type: string
110
+ required?: boolean
111
+ searchable?: boolean
112
+ /** FieldDef.hint — the field's helper text. Carried into the contract
113
+ * because it is what tells the agent what a non-obvious column expects:
114
+ * an id to look up, a default it should not restate, a format. */
115
+ hint?: string
116
+ /** Allowed values for select-like fields. */
117
+ options?: string[]
118
+ /** Table the relation field points at (FieldRelation.table). */
119
+ relationTable?: string
120
+ }>
121
+ }
122
+
123
+ /** JSON-safe projection of a PluginAITool + its owning plugin. */
124
+ export interface AIToolContract {
125
+ id: string
126
+ name: string
127
+ description: string
128
+ mode: 'read' | 'persist'
129
+ parameters?: AIToolParameters
130
+ permission?: { feature: string; action: string }
131
+ limitKey?: string
132
+ execution?: AIToolExecution
133
+ requiresConfirmation?: boolean
134
+ pluginId?: string
135
+ category?: string
136
+ }
137
+
138
+ /** A tenant-scoped pool RPC the server-plane executor may call. Canonical
139
+ * definition lives with the plugin types (plugins declare these). */
140
+ export type { AgentRpcDeclaration as RpcContract } from '../types/plugins'
141
+
142
+ /** Structured domain knowledge rendered into the agent's prompt as labelled
143
+ * sections — statuses with transitions, scheduling defaults, free-form rules. */
144
+ export interface AgentDomainKnowledge {
145
+ statuses?: Record<string, Array<{ id: string; label: string; availableWhen?: unknown }>>
146
+ scheduleDefaults?: Record<string, unknown>
147
+ businessRules?: Array<{ id: string; description: string }>
148
+ }
149
+
150
+ export interface AgentContract {
151
+ persona?: { name?: string; systemPrompt?: string; icon?: string }
152
+ /** When 'server', surfaces stop shipping data-tool executors: the broker
153
+ * executes reads/writes server-plane and the surface only handles
154
+ * client-plane tools. Flipped per app — no lockstep release. */
155
+ executionPlane?: 'client' | 'server'
156
+ entities?: EntityContract[]
157
+ tools?: AIToolContract[]
158
+ registries?: Array<{ pluginId: string; id: string; entityKey?: string; readOnly?: boolean }>
159
+ declaredFeatures?: FeatureDeclaration[]
160
+ rpcs?: AgentRpcDeclaration[]
161
+ domainKnowledge?: AgentDomainKnowledge
162
+ }
163
+
71
164
  export interface AppManifest {
72
165
  manifestVersion: number
73
166
  id: string
@@ -83,6 +176,13 @@ export interface AppManifest {
83
176
  entities?: EntityDef[]
84
177
  permissions?: PermissionsConfig
85
178
  billing?: BillingConfig
179
+ /** The RESOLVED limit declarations (4-layer merge: core < entity-derived <
180
+ * plugin < app), so a server runtime counts exactly what the client counts. */
181
+ limitDeclarations?: LimitDeclaration[]
182
+ /** v3: the agent contract — see the section comment above. */
183
+ agent?: AgentContract
184
+ /** sha256 of the canonical serialization (excluding this field). */
185
+ contractHash?: string
86
186
  }
87
187
 
88
188
  // ---------------------------------------------------------------------------
@@ -108,6 +208,9 @@ const allowedManifestKeys = new Set([
108
208
  'billing',
109
209
  'entities',
110
210
  'surfaces',
211
+ 'limitDeclarations',
212
+ 'agent',
213
+ 'contractHash',
111
214
  ])
112
215
  const allowedBackendKeys = new Set(['provider', 'projectRef', 'url', 'adapterId', 'options'])
113
216
  const allowedSurfaceKeys = new Set(['scaffold', 'options', 'plugins', 'pages'])
@@ -122,6 +225,12 @@ export function registerManifestMigration(fromVersion: number, fn: ManifestMigra
122
225
  migrations.set(fromVersion, fn)
123
226
  }
124
227
 
228
+ // v2 → v3 is purely additive (limitDeclarations/agent/contractHash are optional):
229
+ // a v2 manifest is a valid v3 manifest with those sections absent. Registered
230
+ // here so every consumer migrates for free; `fayz manifest emit` fills the new
231
+ // sections on the next regeneration.
232
+ registerManifestMigration(2, (m) => m)
233
+
125
234
  /** Bring any manifest up to CURRENT_MANIFEST_VERSION, or throw if it was built
126
235
  * for a newer SDK than this runtime understands. */
127
236
  export function migrateManifest(input: AnyManifest): AppManifest {
@@ -167,6 +276,21 @@ export function validateManifest(m: AppManifest): string[] {
167
276
  validateLooseObject(manifest.theme, 'manifest.theme', problems)
168
277
  validateLooseObject(manifest.permissions, 'manifest.permissions', problems)
169
278
  validateLooseObject(manifest.billing, 'manifest.billing', problems)
279
+ validateLooseObject(manifest.agent, 'manifest.agent', problems)
280
+ if (manifest.contractHash !== undefined && !isNonEmptyString(manifest.contractHash)) {
281
+ problems.push('manifest.contractHash must be a non-empty string')
282
+ }
283
+ if (manifest.limitDeclarations !== undefined) {
284
+ if (!Array.isArray(manifest.limitDeclarations)) {
285
+ problems.push('manifest.limitDeclarations must be an array')
286
+ } else {
287
+ manifest.limitDeclarations.forEach((decl, index) => {
288
+ if (!isRecord(decl) || !isNonEmptyString(decl.key) || !isNonEmptyString(decl.table)) {
289
+ problems.push(`manifest.limitDeclarations[${index}] must declare key and table`)
290
+ }
291
+ })
292
+ }
293
+ }
170
294
  if (manifest.entities !== undefined) {
171
295
  if (!Array.isArray(manifest.entities)) {
172
296
  problems.push('manifest.entities must be an array')
@@ -368,7 +492,7 @@ function addUnsupportedKeys(
368
492
  ): void {
369
493
  for (const key of Object.keys(value)) {
370
494
  if (!allowedKeys.has(key)) {
371
- problems.push(`${path}.${key} is not part of AppManifest v2`)
495
+ problems.push(`${path}.${key} is not part of AppManifest v${CURRENT_MANIFEST_VERSION}`)
372
496
  }
373
497
  }
374
498
  }
@@ -0,0 +1,52 @@
1
+ import type { AppManifest } from './index'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Deterministic serialization — the SAME manifest object must always produce
5
+ // the SAME bytes, because the sha256 of those bytes (`contractHash`) is what
6
+ // makes `fayz manifest sync` idempotent and lets `fayz doctor` prove the
7
+ // committed app.manifest.json is not stale. Rules: object keys sorted, arrays
8
+ // kept in declared order (order is meaning: nav position, migration order),
9
+ // 2-space indent, trailing newline, `contractHash` itself excluded.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function sortValue(value: unknown): unknown {
13
+ if (Array.isArray(value)) return value.map(sortValue)
14
+ if (value !== null && typeof value === 'object') {
15
+ const record = value as Record<string, unknown>
16
+ const out: Record<string, unknown> = {}
17
+ for (const key of Object.keys(record).sort()) {
18
+ const v = record[key]
19
+ if (v !== undefined) out[key] = sortValue(v)
20
+ }
21
+ return out
22
+ }
23
+ return value
24
+ }
25
+
26
+ /** Canonical JSON of a manifest (contractHash excluded, keys sorted). */
27
+ export function serializeManifest(manifest: AppManifest): string {
28
+ const { contractHash: _omit, ...rest } = manifest
29
+ return `${JSON.stringify(sortValue(rest), null, 2)}\n`
30
+ }
31
+
32
+ /**
33
+ * sha256 (hex) of the canonical serialization. Async because it runs on
34
+ * WebCrypto (`globalThis.crypto.subtle`), which exists in every runtime we
35
+ * target (browsers, Node ≥ 18, edge) — no node:crypto import, keeping this
36
+ * module bundleable everywhere.
37
+ */
38
+ export async function computeContractHash(manifest: AppManifest): Promise<string> {
39
+ const bytes = new TextEncoder().encode(serializeManifest(manifest))
40
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)
41
+ return Array.from(new Uint8Array(digest))
42
+ .map((b) => b.toString(16).padStart(2, '0'))
43
+ .join('')
44
+ }
45
+
46
+ /** Serialize WITH the freshly computed contractHash embedded — what
47
+ * `fayz manifest emit` writes to app.manifest.json. */
48
+ export async function serializeManifestWithHash(manifest: AppManifest): Promise<string> {
49
+ const contractHash = await computeContractHash(manifest)
50
+ const { contractHash: _omit, ...rest } = manifest
51
+ return `${JSON.stringify(sortValue({ ...rest, contractHash }), null, 2)}\n`
52
+ }
@@ -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 { LimitDeclaration } from '../types/entitlements'
23
24
  import type { ConnectorDefinition } from '../integrations'
24
25
  import { getComponent } from '../registry'
25
26
 
@@ -110,6 +111,7 @@ function createEmptyRuntime(context: PluginRuntimeContext = EMPTY_CONTEXT): Plug
110
111
  registries: new Map(),
111
112
  connectorsByHost: new Map(),
112
113
  pluginFeatures: [],
114
+ pluginLimits: [],
113
115
  issues: [],
114
116
  }
115
117
  }
@@ -242,6 +244,7 @@ export function resolvePluginRuntime({
242
244
  const registries = new Map<string, PluginRegistryDef[]>()
243
245
  const connectorsByHost = new Map<string, ConnectorDefinition[]>()
244
246
  const pluginFeatures: FeatureDeclaration[] = []
247
+ const pluginLimits: LimitDeclaration[] = []
245
248
 
246
249
  for (const plugin of activePlugins) {
247
250
  routes.push(...plugin.routes.map((r) => ({ ...r, plugin })))
@@ -261,6 +264,7 @@ export function resolvePluginRuntime({
261
264
  connectorsByHost.set(connector.hostPluginId, list)
262
265
  }
263
266
  if (plugin.declaredFeatures) pluginFeatures.push(...plugin.declaredFeatures)
267
+ if (plugin.declaredLimits) pluginLimits.push(...plugin.declaredLimits)
264
268
  widgets.push(...plugin.widgets.map((w, i) => ({
265
269
  ...w,
266
270
  order: w.order ?? i,
@@ -283,7 +287,7 @@ export function resolvePluginRuntime({
283
287
  widgets.sort((a, b) => a.zone !== b.zone ? a.zone.localeCompare(b.zone) : a.order - b.order)
284
288
  dashboardWidgets.sort((a, b) => a.order - b.order)
285
289
 
286
- return { context, plugins: resolvedPlugins, activePlugins, routes, navigation, settingsTabs, widgets, dashboardWidgets, capabilities, aiTools, issues, registries, connectorsByHost, pluginFeatures }
290
+ return { context, plugins: resolvedPlugins, activePlugins, routes, navigation, settingsTabs, widgets, dashboardWidgets, capabilities, aiTools, issues, registries, connectorsByHost, pluginFeatures, pluginLimits }
287
291
  }
288
292
 
289
293
  export function getWidgetsForZone(
@@ -1,3 +1,5 @@
1
+ import type { PlanEntitlements } from './entitlements'
2
+
1
3
  export interface Plan {
2
4
  id: string
3
5
  name: string
@@ -5,13 +7,33 @@ export interface Plan {
5
7
  price: number
6
8
  currency: string
7
9
  interval: 'month' | 'year'
10
+ /** Marketing bullets shown on the pricing page (display only). */
8
11
  features: string[]
9
12
  highlighted?: boolean
10
13
  stripePriceId?: string
14
+ /**
15
+ * Structured access grants for this plan (feature gates + quantity caps). This
16
+ * is the enforcement source of truth — distinct from `features` (display
17
+ * bullets). See {@link PlanEntitlements}.
18
+ */
19
+ entitlements?: PlanEntitlements
11
20
  }
12
21
 
22
+ /**
23
+ * Signature of the checkout seam. When `BillingConfig.onCheckout` is defined the
24
+ * shell's Subscription page calls it (passing the target plan id) INSTEAD of
25
+ * writing `plan` straight onto the org via `adapter.updateOrg`. This is where a
26
+ * real payment gateway (Stripe Checkout, Pix, Mercado Pago, …) plugs in: return
27
+ * once the user is redirected / the intent is created, and let the webhook be the
28
+ * source of truth for the persisted plan. Left undefined, the shell mutates the
29
+ * plan optimistically (dev / self-serve / free-tier flows).
30
+ */
31
+ export type BillingCheckoutFn = (planId: string) => Promise<void> | void
32
+
13
33
  export interface BillingConfig {
14
34
  plans: Plan[]
15
35
  stripePublishableKey?: string
16
36
  portalUrl?: string
37
+ /** Payment-gateway seam — see {@link BillingCheckoutFn}. */
38
+ onCheckout?: BillingCheckoutFn
17
39
  }
package/src/types/crud.ts CHANGED
@@ -7,7 +7,7 @@ export type FieldType =
7
7
  | 'select' | 'multiselect' | 'segmented'
8
8
  | 'relation'
9
9
  | 'date' | 'datetime' | 'time'
10
- | 'boolean' | 'textarea'
10
+ | 'boolean' | 'textarea' | 'markdown'
11
11
  | 'color' | 'computed'
12
12
 
13
13
  /** Foreign-key source for a `relation` field: the option list is loaded from a
@@ -79,7 +79,8 @@ export interface FieldGroup {
79
79
  label: string
80
80
  description?: string
81
81
  columns?: 1 | 2 | 3
82
- /** Renders a decorative image slot to the left of the group's fields. */
82
+ /** Renders an image slot to the left of the group's fields. Functional when
83
+ * the EntityDef supplies `image`; a plain placeholder otherwise. */
83
84
  imageSlot?: boolean
84
85
  }
85
86
 
@@ -104,6 +105,28 @@ export interface DetailTab {
104
105
 
105
106
  export type FormLayout = 'person' | 'product' | 'service' | 'location' | 'order' | 'subject' | 'generic'
106
107
 
108
+ /**
109
+ * Makes a field group's `imageSlot` functional. Without this the slot renders as
110
+ * a decorative placeholder — it always did, which is why product forms showed an
111
+ * empty thumbnail even for products that have images.
112
+ *
113
+ * Upload needs the record to exist (the file is attached to its id), so the slot
114
+ * stays read-only in create mode and becomes editable after the first save.
115
+ */
116
+ export interface EntityImageConfig {
117
+ // Rows are loosely typed here on purpose: EntityDef<T> is consumed as
118
+ // EntityDef<Record<string, unknown>> by the generic CRUD machinery, and a
119
+ // T-parameterised callback makes the whole EntityDef invariant in T.
120
+ /** Current image URL for this record, if any. */
121
+ get: (row: Record<string, any>) => string | undefined
122
+ /** Persist a new image and return its URL. Omit to keep the slot read-only. */
123
+ upload?: (file: File, row: Record<string, any>) => Promise<string>
124
+ /** Remove the current image. Omit to hide the remove affordance. */
125
+ remove?: (row: Record<string, any>) => Promise<void>
126
+ /** Accepted mime types. Defaults to image/*. */
127
+ accept?: string
128
+ }
129
+
107
130
  export interface EntityDef<T = Record<string, unknown>> {
108
131
  name: string
109
132
  namePlural?: string
@@ -111,6 +134,8 @@ export interface EntityDef<T = Record<string, unknown>> {
111
134
  layout?: FormLayout
112
135
  fields: FieldDef[]
113
136
  fieldGroups?: FieldGroup[]
137
+ /** Wiring for the `imageSlot` of whichever field group declares one. */
138
+ image?: EntityImageConfig
114
139
  detailTabs?: DetailTab[]
115
140
  data?: {
116
141
  table: string
@@ -134,4 +159,26 @@ export interface EntityDef<T = Record<string, unknown>> {
134
159
  /** Faceted filters shown as pills below the list search box. Each references a
135
160
  * field whose `options` supply the pills; an "All" pill clears the filter. */
136
161
  facets?: { field: string; allLabel?: string }[]
162
+ /**
163
+ * Plan quantity-limit key gating creation of this entity (matches a
164
+ * `PlanEntitlements.limits` key + a `LimitDeclaration`). When set, the CRUD
165
+ * engine gates the "+ New" button (LimitGate), guards the create-form submit
166
+ * and the CSV import (useLimitGuard) against the plan cap, and invalidates the
167
+ * live count after a successful create. Absent ⇒ no plan gating (RBAC only).
168
+ */
169
+ limitKey?: string
170
+ /**
171
+ * Permission required to READ this entity through the agent's generic data
172
+ * primitives (searchRecords/queryData) — checked per TARGET at execution
173
+ * time, so one generic tool still honors per-user access. Absent ⇒ readable
174
+ * by any signed-in member (same as the CRUD list today).
175
+ */
176
+ permission?: { feature: string; action: string }
177
+ /**
178
+ * Exclude this entity from the agent's data primitives. For entities whose
179
+ * table is an extension/join shape the generic provider can't read usefully
180
+ * (e.g. staff_members without a name column) — expose a queryEntities
181
+ * read-model instead and hide the raw one here.
182
+ */
183
+ agentHidden?: boolean
137
184
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Entitlements — the plan side of the access decision (the role side lives in
3
+ * permissions.ts). A plan grants FEATURES (gate a page/module) and caps LIMITS
4
+ * (quantity ceilings). Feature ids are the SAME id space as RBAC/nav
5
+ * (`permission.feature`), so a single id threads role → nav → plan.
6
+ */
7
+ export interface PlanEntitlements {
8
+ /**
9
+ * feature id (same id as RBAC/nav) → whether the plan grants it. Absent = the
10
+ * plan does not gate the feature (allowed). Only an explicit `false` denies —
11
+ * this keeps plans additive: features unknown to a plan stay open.
12
+ */
13
+ features?: Record<string, boolean>
14
+ /**
15
+ * limitKey → cap. `-1` = unlimited. Keys are app/plugin-defined (e.g.
16
+ * `clients`, `users`, `locations`, `bookings_month`). A key absent here means
17
+ * "no cap on this plan" (unlimited). See {@link LimitDeclaration} for how a
18
+ * key is bound to a countable table.
19
+ */
20
+ limits?: Record<string, number>
21
+ }
22
+
23
+ /**
24
+ * Declares HOW a limit key is counted. Contributed by plugins via
25
+ * `PluginManifest.declaredLimits` (aggregated by the plugin runtime) and/or
26
+ * overridden per-app via `billing.limitDeclarations`. The access engine reads a
27
+ * declaration to resolve a `limitKey` → a live `count(*)` against `table` for
28
+ * the active tenant, with no per-entity code.
29
+ */
30
+ export interface LimitDeclaration {
31
+ /** Limit key — matches a key in {@link PlanEntitlements.limits}. */
32
+ key: string
33
+ /** Human label for paywalls / usage banners (e.g. "Patients"). */
34
+ label: string
35
+ /** Table the count runs against (tenant-scoped via `tenant_id`). */
36
+ table: string
37
+ /** Optional `kind` column filter for tables that hold several entity kinds. */
38
+ kindFilter?: string
39
+ /**
40
+ * Counting window. `'month'` counts rows created since the start of the
41
+ * current month (recurring quotas like bookings/month); `'total'` (default)
42
+ * counts every row (stock quantities like clients/users).
43
+ */
44
+ period?: 'month' | 'total'
45
+ }
@@ -1,10 +1,11 @@
1
1
  // Re-export all core types
2
2
  export type { EntityArchetype, BaseEntity, PersonEntity, ProductEntity, ServiceEntity, OrderEntity, TransactionEntity, LocationEntity, ScheduleEntity, CategoryEntity } from './entities'
3
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, PluginDiagnostic, VerticalId, ScaffoldType } from './plugins'
4
+ export type { PluginManifest, PluginScope, PluginStatus, ResolvedPluginManifest, PluginRuntime, PluginRuntimeContext, PluginWidgetZone, PluginWidgetDefinition, PluginNavigationEntry, PluginSettingsTab, PluginRouteDefinition, PluginAITool, AIToolMode, AIToolExecution, AgentRpcDeclaration, AIToolParameterProperty, AIToolParameters, AIToolSuggestion, PluginRegistryDef, PluginMigration, PluginDiagnostic, VerticalId, ScaffoldType } from './plugins'
5
5
  export type { AuthAdapter, AuthUser, AuthSession } from './auth'
6
6
  export type { OrgAdapter, Organization, OrgMember } from './org'
7
- export type { PermissionsConfig, FeatureDeclaration, PermissionAction } from './permissions'
8
- export type { Plan, BillingConfig } from './billing'
7
+ export type { PermissionsConfig, FeatureDeclaration, PermissionAction, PermissionProfile, SystemPermission } from './permissions'
8
+ export type { Plan, BillingConfig, BillingCheckoutFn } from './billing'
9
+ export type { PlanEntitlements, LimitDeclaration } from './entitlements'
9
10
  export type { ThemeBrand, ThemeRadius, ThemeMode } from './theme'
10
11
  export type { LocaleConfig } from './i18n'
@@ -1,6 +1,7 @@
1
1
  import type React from 'react'
2
2
  import type { EntityDef } from './crud'
3
3
  import type { FeatureDeclaration, PermissionAction } from './permissions'
4
+ import type { LimitDeclaration } from './entitlements'
4
5
  import type { ConnectorDefinition } from '../integrations'
5
6
 
6
7
  export type VerticalId = 'beauty' | 'food' | 'health' | 'services' | 'retail' | 'education' | (string & {})
@@ -28,6 +29,14 @@ export interface PluginNavigationEntry {
28
29
  icon?: string
29
30
  badge?: string | number
30
31
  permission?: PluginPermissionRequirement
32
+ /**
33
+ * When this entry gains sub-path children, the shell also synthesizes a child
34
+ * pointing back at the parent's own route (so /courses stays reachable under
35
+ * the "Courses" group). Set false when the parent route is a duplicate of its
36
+ * first child — e.g. /shop and /shop/products render the same catalog, and
37
+ * listing both reads as a bug. Defaults to true.
38
+ */
39
+ indexChild?: boolean
31
40
  }
32
41
 
33
42
  export interface PluginSettingsTab {
@@ -225,6 +234,27 @@ export interface AIToolSuggestion {
225
234
  verticalId?: VerticalId
226
235
  }
227
236
 
237
+ export type AIToolExecution =
238
+ | { plane: 'server'; kind: 'rpc'; rpc: string }
239
+ | { plane: 'server'; kind: 'entity_read'; entity: string }
240
+ | { plane: 'client' }
241
+
242
+ /**
243
+ * A tenant-scoped pool RPC (`agent_<domain>_<verb>`) this plugin ships for the
244
+ * server-plane agent executor. Surfaces in the manifest as
245
+ * `agent.rpcs` (RpcContract) — the broker only ever calls declared functions.
246
+ */
247
+ export interface AgentRpcDeclaration {
248
+ /** Function name in the pool, e.g. 'agent_agenda_create_appointment'. */
249
+ name: string
250
+ kind: 'read' | 'write'
251
+ description: string
252
+ /** JSON Schema of the p_payload argument. */
253
+ payloadSchema?: AIToolParameters
254
+ /** Whether the function writes audit_logs itself. */
255
+ audits?: boolean
256
+ }
257
+
228
258
  export interface PluginAITool {
229
259
  id: string
230
260
  name: string
@@ -233,6 +263,31 @@ export interface PluginAITool {
233
263
  mode: AIToolMode
234
264
  parameters?: AIToolParameters
235
265
  permission?: PluginPermissionRequirement
266
+ /**
267
+ * Quantity-limit key this tool's writes consume (e.g. 'bookings_month' for a
268
+ * booking-creating tool). Persist-mode tools that create countable rows MUST
269
+ * declare it so the agent executor can run the SAME plan-limit guard the UI
270
+ * runs (guard before execute, invalidate after) — an AI agent never clicks a
271
+ * gated button, so the declarative contract is its only gate.
272
+ */
273
+ limitKey?: string
274
+ /**
275
+ * Where the tool runs. Server-plane is what makes browserless channels
276
+ * (WhatsApp, MCP) able to run the tool at all:
277
+ * - `{ plane:'server', kind:'rpc', rpc }` — the Fayz broker calls the named
278
+ * `agent_*` RPC in the tenant pool (writes / guarded reads).
279
+ * - `{ plane:'server', kind:'entity_read', entity }` — the broker runs the
280
+ * generic tenant-scoped read against the entity's table (derived tools).
281
+ * - `{ plane:'client' }` (or absent) — a locally registered executor runs it
282
+ * in the surface (navigation, modals).
283
+ */
284
+ execution?: AIToolExecution
285
+ /**
286
+ * Require an explicit human confirmation before executing. Defaults to true
287
+ * for `mode: 'persist'` tools — the channel renders it natively (FAB modal,
288
+ * WhatsApp yes/no reply).
289
+ */
290
+ requiresConfirmation?: boolean
236
291
  suggestions?: AIToolSuggestion[]
237
292
  category?: string
238
293
  tags?: string[]
@@ -314,6 +369,28 @@ export interface PluginManifest {
314
369
  entities?: string[]
315
370
  permissions?: string[]
316
371
  declaredFeatures?: FeatureDeclaration[]
372
+ /**
373
+ * Quantity limits this plugin knows how to count. Aggregated by the plugin
374
+ * runtime into `PluginRuntime.pluginLimits` and consumed by the access engine
375
+ * (useLimit / useLimitGuard) so a plugin "is born compatible" with freemium
376
+ * caps without any per-app wiring. See {@link LimitDeclaration}.
377
+ */
378
+ declaredLimits?: LimitDeclaration[]
379
+ /** Pool RPCs this plugin ships for the server-plane agent executor. */
380
+ declaredRpcs?: AgentRpcDeclaration[]
381
+ /**
382
+ * Read-models this plugin exposes to the agent's generic data primitives
383
+ * (searchRecords/queryData) BEYOND its CRUD registries — typically its
384
+ * views/ledgers (v_appointments, plg_financial_movements). `key` is a stable
385
+ * ASCII id (never derived from a translated label); the entity's own
386
+ * `permission` gates each call.
387
+ */
388
+ queryEntities?: Array<{
389
+ key: string
390
+ entity: EntityDef
391
+ /** Base tables may opt into createRecord; views/ledgers stay read-only. */
392
+ writable?: boolean
393
+ }>
317
394
  registries?: PluginRegistryDef[]
318
395
  /**
319
396
  * Connectors this plugin contributes. An ADDON plugin declares its connector(s)
@@ -386,4 +463,6 @@ export interface PluginRuntime {
386
463
  /** Connectors contributed by active plugins, grouped by the host plugin they extend. */
387
464
  connectorsByHost: Map<string, ConnectorDefinition[]>
388
465
  pluginFeatures: FeatureDeclaration[]
466
+ /** Limit declarations aggregated from every active plugin's `declaredLimits`. */
467
+ pluginLimits: LimitDeclaration[]
389
468
  }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/entity/registry.ts"],"names":[],"mappings":";;;AAgBA,IAAM,cAAA,uBAAqB,GAAA,EAA8B;AAElD,SAAS,eAAe,KAAA,EAA+B;AAC5D,EAAA,cAAA,CAAe,GAAA,CAAI,KAAA,CAAM,SAAA,EAAW,KAAK,CAAA;AAC3C;AAEO,SAAS,eAAe,GAAA,EAA2C;AACxE,EAAA,OAAO,cAAA,CAAe,IAAI,GAAG,CAAA;AAC/B;AAEO,SAAS,cAAA,GAAqC;AACnD,EAAA,OAAO,KAAA,CAAM,IAAA,CAAK,cAAA,CAAe,MAAA,EAAQ,CAAA;AAC3C;AAEO,SAAS,mBAAA,GAA4B;AAC1C,EAAA,cAAA,CAAe,KAAA,EAAM;AACvB;AAEO,SAAS,gBAAgB,SAAA,EAA8B;AAC5D,EAAA,MAAM,OAAO,SAAA,CAAU,IAAA,CAAK,aAAY,CAAE,OAAA,CAAQ,QAAQ,GAAG,CAAA;AAC7D,EAAA,IAAI,SAAA,CAAU,IAAA,EAAM,SAAA,IAAa,SAAA,CAAU,MAAM,aAAA,EAAe;AAC9D,IAAA,OAAO,GAAG,SAAA,CAAU,IAAA,CAAK,SAAS,CAAA,CAAA,EAAI,SAAA,CAAU,KAAK,aAAa,CAAA,CAAA;AAAA,EACpE;AACA,EAAA,IAAI,SAAA,CAAU,IAAA,EAAM,SAAA,EAAW,OAAO,UAAU,IAAA,CAAK,SAAA;AACrD,EAAA,OAAO,IAAA;AACT","file":"chunk-2NTHNWEH.cjs","sourcesContent":["import type { EntityDef } from '../types/crud'\nimport type { EntityArchetype } from '../types/entities'\n\nexport interface RegisteredEntity {\n entityKey: string\n label: string\n labelPlural: string\n icon?: string\n fields: EntityDef['fields']\n source: 'app' | 'plugin'\n archetype?: EntityArchetype\n pluginId?: string\n pluginName?: string\n pluginIcon?: string\n}\n\nconst entityRegistry = new Map<string, RegisteredEntity>()\n\nexport function registerEntity(entry: RegisteredEntity): void {\n entityRegistry.set(entry.entityKey, entry)\n}\n\nexport function getEntityByKey(key: string): RegisteredEntity | undefined {\n return entityRegistry.get(key)\n}\n\nexport function getAllEntities(): RegisteredEntity[] {\n return Array.from(entityRegistry.values())\n}\n\nexport function clearEntityRegistry(): void {\n entityRegistry.clear()\n}\n\nexport function deriveEntityKey(entityDef: EntityDef): string {\n const name = entityDef.name.toLowerCase().replace(/\\s+/g, '-')\n if (entityDef.data?.archetype && entityDef.data?.archetypeKind) {\n return `${entityDef.data.archetype}:${entityDef.data.archetypeKind}`\n }\n if (entityDef.data?.archetype) return entityDef.data.archetype\n return name\n}\n"]}