@fayz-ai/core 0.8.1 → 0.8.3

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 (123) 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-QR7U2URY.cjs → chunk-CATASHPK.cjs} +345 -4
  16. package/dist/chunk-CATASHPK.cjs.map +1 -0
  17. package/dist/{chunk-WKZVSKEU.js → chunk-CU64QI4A.js} +2 -2
  18. package/dist/chunk-CU64QI4A.js.map +1 -0
  19. package/dist/{chunk-KNIVT6XZ.js → chunk-DZALZ6Q6.js} +345 -4
  20. package/dist/chunk-DZALZ6Q6.js.map +1 -0
  21. package/dist/{chunk-54SRL7AJ.cjs → chunk-KSI2L3E4.cjs} +145 -86
  22. package/dist/chunk-KSI2L3E4.cjs.map +1 -0
  23. package/dist/{chunk-ZBIWYDAR.js → chunk-TYPVSWLG.js} +144 -87
  24. package/dist/chunk-TYPVSWLG.js.map +1 -0
  25. package/dist/{chunk-2NTHNWEH.cjs → chunk-UKU4RIWM.cjs} +2 -2
  26. package/dist/chunk-UKU4RIWM.cjs.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/archetype.d.ts.map +1 -1
  32. package/dist/data/count.d.ts +34 -0
  33. package/dist/data/count.d.ts.map +1 -0
  34. package/dist/data/index.cjs +18 -10
  35. package/dist/data/index.d.ts +2 -0
  36. package/dist/data/index.d.ts.map +1 -1
  37. package/dist/data/index.js +1 -1
  38. package/dist/data/refresh.d.ts +36 -0
  39. package/dist/data/refresh.d.ts.map +1 -0
  40. package/dist/data/resolve.d.ts.map +1 -1
  41. package/dist/data/supabase.d.ts.map +1 -1
  42. package/dist/data/types.d.ts +6 -0
  43. package/dist/data/types.d.ts.map +1 -1
  44. package/dist/entity/index.cjs +6 -6
  45. package/dist/entity/index.js +1 -1
  46. package/dist/entity/registry.d.ts +9 -0
  47. package/dist/entity/registry.d.ts.map +1 -1
  48. package/dist/i18n/index.cjs +10 -10
  49. package/dist/i18n/index.js +1 -1
  50. package/dist/i18n/shell-translations.d.ts.map +1 -1
  51. package/dist/index.cjs +1517 -180
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.ts +15 -5
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1412 -110
  56. package/dist/index.js.map +1 -1
  57. package/dist/manifest/index.d.ts +109 -3
  58. package/dist/manifest/index.d.ts.map +1 -1
  59. package/dist/manifest/serialize.d.ts +14 -0
  60. package/dist/manifest/serialize.d.ts.map +1 -0
  61. package/dist/plugin/index.cjs +7 -7
  62. package/dist/plugin/index.js +1 -1
  63. package/dist/plugin/runtime.d.ts.map +1 -1
  64. package/dist/search/engine.d.ts +12 -0
  65. package/dist/search/engine.d.ts.map +1 -0
  66. package/dist/search/index.d.ts +5 -0
  67. package/dist/search/index.d.ts.map +1 -0
  68. package/dist/search/text.d.ts +37 -0
  69. package/dist/search/text.d.ts.map +1 -0
  70. package/dist/search/types.d.ts +72 -0
  71. package/dist/search/types.d.ts.map +1 -0
  72. package/dist/types/billing.d.ts +20 -0
  73. package/dist/types/billing.d.ts.map +1 -1
  74. package/dist/types/crud.d.ts +47 -1
  75. package/dist/types/crud.d.ts.map +1 -1
  76. package/dist/types/entitlements.d.ts +45 -0
  77. package/dist/types/entitlements.d.ts.map +1 -0
  78. package/dist/types/index.d.ts +4 -3
  79. package/dist/types/index.d.ts.map +1 -1
  80. package/dist/types/org.d.ts +28 -0
  81. package/dist/types/org.d.ts.map +1 -1
  82. package/dist/types/plugins.d.ts +88 -0
  83. package/dist/types/plugins.d.ts.map +1 -1
  84. package/package.json +14 -4
  85. package/src/access/denial.ts +33 -0
  86. package/src/access/engine.test.ts +48 -0
  87. package/src/access/engine.ts +121 -0
  88. package/src/access/index.ts +13 -0
  89. package/src/access/limits.ts +18 -0
  90. package/src/address/index.ts +188 -0
  91. package/src/data/archetype.ts +4 -2
  92. package/src/data/count.test.ts +152 -0
  93. package/src/data/count.ts +131 -0
  94. package/src/data/index.ts +2 -0
  95. package/src/data/refresh.ts +105 -0
  96. package/src/data/resolve.ts +7 -1
  97. package/src/data/supabase.ts +3 -1
  98. package/src/data/types.ts +6 -0
  99. package/src/entity/registry.ts +7 -0
  100. package/src/i18n/shell-translations.ts +345 -2
  101. package/src/index.ts +37 -4
  102. package/src/manifest/app-manifest.schema.json +618 -45
  103. package/src/manifest/index.ts +127 -3
  104. package/src/manifest/serialize.ts +52 -0
  105. package/src/plugin/runtime.ts +5 -1
  106. package/src/search/engine.ts +551 -0
  107. package/src/search/index.ts +25 -0
  108. package/src/search/text.ts +206 -0
  109. package/src/search/types.ts +75 -0
  110. package/src/types/billing.ts +22 -0
  111. package/src/types/crud.ts +48 -1
  112. package/src/types/entitlements.ts +45 -0
  113. package/src/types/index.ts +4 -3
  114. package/src/types/org.ts +29 -0
  115. package/src/types/plugins.ts +83 -0
  116. package/dist/chunk-2NTHNWEH.cjs.map +0 -1
  117. package/dist/chunk-54SRL7AJ.cjs.map +0 -1
  118. package/dist/chunk-5X2VX3RQ.cjs.map +0 -1
  119. package/dist/chunk-CHGX6UYR.js.map +0 -1
  120. package/dist/chunk-KNIVT6XZ.js.map +0 -1
  121. package/dist/chunk-QR7U2URY.cjs.map +0 -1
  122. package/dist/chunk-WKZVSKEU.js.map +0 -1
  123. package/dist/chunk-ZBIWYDAR.js.map +0 -1
@@ -0,0 +1,206 @@
1
+ // Text folding + ranking for global search. The same folding must exist in SQL
2
+ // as public.fayz_norm() (@fayz-ai/db 018_global_search.sql), or server and
3
+ // client disagree on what matched.
4
+
5
+ const COMBINING_MARKS = /[\u0300-\u036f]/g
6
+
7
+ /** Strip diacritics, lowercase, collapse non-alphanumerics to single spaces. */
8
+ export function foldText(value: unknown): string {
9
+ if (typeof value !== 'string' || value === '') return ''
10
+ return value
11
+ .normalize('NFD')
12
+ .replace(COMBINING_MARKS, '')
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, ' ')
15
+ .trim()
16
+ }
17
+
18
+ /** Fold, keeping each folded char's index in the source — used for highlighting. */
19
+ export function foldWithMap(value: string): { folded: string; map: number[] } {
20
+ const out: string[] = []
21
+ const map: number[] = []
22
+ let pendingSpace = false
23
+ for (let i = 0; i < value.length; i++) {
24
+ const ch = value[i]!
25
+ const folded = ch.normalize('NFD').replace(COMBINING_MARKS, '').toLowerCase()
26
+ for (const f of folded) {
27
+ const alnum = (f >= 'a' && f <= 'z') || (f >= '0' && f <= '9')
28
+ if (!alnum) { pendingSpace = out.length > 0; continue }
29
+ if (pendingSpace) { out.push(' '); map.push(i); pendingSpace = false }
30
+ out.push(f)
31
+ map.push(i)
32
+ }
33
+ }
34
+ return { folded: out.join(''), map }
35
+ }
36
+
37
+ /** Digits only — phone numbers, CPF/CNPJ, SKUs typed with separators. */
38
+ export function digitsOf(value: unknown): string {
39
+ return typeof value === 'string' ? value.replace(/\D+/g, '') : ''
40
+ }
41
+
42
+ export interface NormalizedQuery {
43
+ /** What the user typed, untouched. */
44
+ raw: string
45
+ /** Folded form of the whole query — the "phrase". */
46
+ folded: string
47
+ /** Folded query split on whitespace. */
48
+ tokens: string[]
49
+ /** Digits the user typed, when they typed enough of them to mean a number. */
50
+ digits: string
51
+ /** The longest token — the most selective thing to send to a `%LIKE%` backend. */
52
+ anchor: string
53
+ }
54
+
55
+ /** Below this a phone/document lookup is indistinguishable from noise. */
56
+ const MIN_DIGIT_QUERY = 4
57
+
58
+ export function normalizeQuery(raw: string): NormalizedQuery {
59
+ const folded = foldText(raw)
60
+ const tokens = folded ? folded.split(' ').filter(Boolean) : []
61
+ const digits = raw.replace(/\D+/g, '')
62
+ let anchor = ''
63
+ for (const token of tokens) if (token.length > anchor.length) anchor = token
64
+ return {
65
+ raw,
66
+ folded,
67
+ tokens,
68
+ digits: digits.length >= MIN_DIGIT_QUERY ? digits : '',
69
+ anchor,
70
+ }
71
+ }
72
+
73
+ // Fuzzy similarity — Sørensen–Dice over bigrams. Same family as pg_trgm's
74
+ // similarity(), so both sides rank near-misses the same way.
75
+
76
+ function bigrams(value: string): string[] {
77
+ if (value.length < 2) return value ? [value] : []
78
+ const out: string[] = []
79
+ for (let i = 0; i < value.length - 1; i++) out.push(value.slice(i, i + 2))
80
+ return out
81
+ }
82
+
83
+ /** 0 (nothing in common) … 1 (identical). Both inputs must already be folded. */
84
+ export function similarity(a: string, b: string): number {
85
+ if (!a || !b) return 0
86
+ if (a === b) return 1
87
+ const left = bigrams(a)
88
+ const right = bigrams(b)
89
+ if (left.length === 0 || right.length === 0) return 0
90
+ const pool = new Map<string, number>()
91
+ for (const g of left) pool.set(g, (pool.get(g) ?? 0) + 1)
92
+ let shared = 0
93
+ for (const g of right) {
94
+ const count = pool.get(g)
95
+ if (count) { shared++; pool.set(g, count - 1) }
96
+ }
97
+ return (2 * shared) / (left.length + right.length)
98
+ }
99
+
100
+ /** Best similarity between the query token and any single word of the text. */
101
+ function wordSimilarity(text: string, token: string): number {
102
+ let best = similarity(text, token)
103
+ for (const word of text.split(' ')) {
104
+ if (!word) continue
105
+ const s = similarity(word, token)
106
+ if (s > best) best = s
107
+ }
108
+ return best
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Ranking
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export interface RankCandidate {
116
+ /** Folded display name — what the user is most likely aiming at. */
117
+ title: string
118
+ /** Folded concatenation of every searchable value on the record. */
119
+ haystack: string
120
+ /** Digits of every phone/document-ish value on the record. */
121
+ digits?: string
122
+ }
123
+
124
+ /** A fuzzy word has to be at least this close before it counts as a match. */
125
+ const FUZZY_FLOOR = 0.62
126
+
127
+ /** Score a candidate 0…1. Zero means "not a match" — the score is also the filter. */
128
+ export function scoreCandidate(query: NormalizedQuery, candidate: RankCandidate): number {
129
+ const { folded, tokens } = query
130
+ if (!folded || tokens.length === 0) return 0
131
+ const title = candidate.title
132
+ const haystack = candidate.haystack || title
133
+
134
+ // Phrase: the query as one string.
135
+ let phrase = 0
136
+ if (title === folded) phrase = 1
137
+ else if (title.startsWith(folded)) phrase = 0.94
138
+ else if (title.includes(' ' + folded)) phrase = 0.88
139
+ else if (title.includes(folded)) phrase = 0.76
140
+ else if (haystack.startsWith(folded) || haystack.includes(' ' + folded)) phrase = 0.7
141
+ else if (haystack.includes(folded)) phrase = 0.62
142
+
143
+ // Tokens: every word must land somewhere ("maria silva" → "Maria da Silva").
144
+ let tokenTotal = 0
145
+ let fuzzyUsed = false
146
+ for (const token of tokens) {
147
+ let best = 0
148
+ if (title.startsWith(token)) best = 0.95
149
+ else if (title.includes(' ' + token)) best = 0.85
150
+ else if (title.includes(token)) best = 0.7
151
+ else if (haystack.startsWith(token) || haystack.includes(' ' + token)) best = 0.6
152
+ else if (haystack.includes(token)) best = 0.5
153
+ else {
154
+ const sim = wordSimilarity(title, token)
155
+ if (sim >= FUZZY_FLOOR) { best = 0.45 * sim; fuzzyUsed = true }
156
+ }
157
+ if (best === 0) return phrase > 0 ? phrase : 0
158
+ tokenTotal += best
159
+ }
160
+ let tokenScore = tokenTotal / tokens.length
161
+ // A typo-only match must never outrank a literal one.
162
+ if (fuzzyUsed) tokenScore *= 0.8
163
+
164
+ let score = Math.max(phrase, tokenScore)
165
+
166
+ // Digits: a phone number has no words to match.
167
+ if (query.digits && candidate.digits && candidate.digits.includes(query.digits)) {
168
+ score = Math.max(score, 0.72 + Math.min(0.2, query.digits.length / 50))
169
+ }
170
+
171
+ if (score === 0) return 0
172
+
173
+ // Tie-break: shorter title wins ("Ana" over "Ana Beatriz" for "ana").
174
+ const brevity = 1 / (1 + Math.max(0, title.length - folded.length) / 40)
175
+ return score * 0.97 + brevity * 0.03
176
+ }
177
+
178
+ /** Ranges in the ORIGINAL text covered by the query, merged and sorted. */
179
+ export function highlightRanges(text: string, query: NormalizedQuery): Array<[number, number]> {
180
+ if (!text || query.tokens.length === 0) return []
181
+ const { folded, map } = foldWithMap(text)
182
+ if (!folded) return []
183
+ const spans: Array<[number, number]> = []
184
+ const needles = query.tokens.length > 1 ? [query.folded, ...query.tokens] : [query.folded]
185
+ for (const needle of needles) {
186
+ if (!needle) continue
187
+ let from = 0
188
+ for (;;) {
189
+ const at = folded.indexOf(needle, from)
190
+ if (at === -1) break
191
+ const start = map[at]
192
+ const endChar = map[at + needle.length - 1]
193
+ if (start !== undefined && endChar !== undefined) spans.push([start, endChar + 1])
194
+ from = at + needle.length
195
+ }
196
+ }
197
+ if (spans.length === 0) return []
198
+ spans.sort((a, b) => a[0] - b[0])
199
+ const merged: Array<[number, number]> = [spans[0]!]
200
+ for (const span of spans.slice(1)) {
201
+ const last = merged[merged.length - 1]!
202
+ if (span[0] <= last[1]) last[1] = Math.max(last[1], span[1])
203
+ else merged.push(span)
204
+ }
205
+ return merged
206
+ }
@@ -0,0 +1,75 @@
1
+ import type { EntityDef } from '../types/crud'
2
+
3
+ /** One searchable thing — declaring an entity is all it takes to be findable. */
4
+ export interface SearchTarget {
5
+ /** Stable entity key (`deriveEntityKey`): 'person:client', 'product', … */
6
+ key: string
7
+ /** Plural human label; becomes the result group heading. */
8
+ label: string
9
+ icon?: string
10
+ entity: EntityDef
11
+ mockData?: Array<{ id: string }>
12
+ /** Multiplies the final score. Default 1. */
13
+ boost?: number
14
+ }
15
+
16
+ export interface SearchHit {
17
+ /** `${target.key}:${record id}` — stable across both search paths. */
18
+ uid: string
19
+ id: string
20
+ /** Target key this record came from. */
21
+ key: string
22
+ /** Group heading (the target's plural label). */
23
+ group: string
24
+ icon?: string
25
+ title: string
26
+ subtitle?: string
27
+ /** 0 … 1 (times the target boost). Higher is better. */
28
+ score: number
29
+ archetype?: string
30
+ archetypeKind?: string
31
+ table?: string
32
+ /** The row, when the path that found it had one. The index path returns
33
+ * titles only. */
34
+ record?: Record<string, unknown>
35
+ }
36
+
37
+ export interface SearchGroup {
38
+ key: string
39
+ label: string
40
+ icon?: string
41
+ hits: SearchHit[]
42
+ /** True when this group hit its per-target cap — more matches exist. */
43
+ hasMore: boolean
44
+ }
45
+
46
+ export type SearchPath = 'index' | 'scan' | 'cache'
47
+
48
+ export interface GlobalSearchResult {
49
+ query: string
50
+ hits: SearchHit[]
51
+ groups: SearchGroup[]
52
+ /** Which path produced this result — surfaced for diagnostics, not for UI. */
53
+ via: SearchPath
54
+ /** Targets whose query failed. Never fatal: a broken table cannot blind the box. */
55
+ failed: string[]
56
+ /** Wall-clock milliseconds. */
57
+ elapsedMs: number
58
+ /** True while a better (network) answer is still on its way. */
59
+ partial: boolean
60
+ }
61
+
62
+ export interface SearchOptions {
63
+ /** Permission filtering is the caller's job — the engine searches what it's handed. */
64
+ targets: SearchTarget[]
65
+ /** Max hits returned overall. Default 30. */
66
+ limit?: number
67
+ /** Max hits per target. Default 5. */
68
+ perTarget?: number
69
+ /** Fired with early/partial answers before the authoritative one resolves. */
70
+ onPartial?: (result: GlobalSearchResult) => void
71
+ /** Discard the result if this aborts. */
72
+ signal?: AbortSignal
73
+ /** Skip the server index and force the per-entity scan (tests, diagnostics). */
74
+ forceScan?: boolean
75
+ }
@@ -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
@@ -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'
package/src/types/org.ts CHANGED
@@ -36,6 +36,28 @@ export interface OrgMembership {
36
36
  profileName: string
37
37
  }
38
38
 
39
+ /**
40
+ * A team member in the Person-first model: a person (of a configured team kind)
41
+ * with an OPTIONAL access overlay. `membership` is present only when the person
42
+ * has login + RBAC role (a `tenant_members` row linked by `person_id`).
43
+ */
44
+ export interface TeamPerson {
45
+ personId: string
46
+ name: string
47
+ kind: string
48
+ email?: string
49
+ avatarUrl?: string
50
+ isActive: boolean
51
+ membership?: {
52
+ memberId: string
53
+ userId: string
54
+ /** RBAC role key (tenant_members.role). */
55
+ profileId: string
56
+ profileName?: string
57
+ joinedAt?: string
58
+ }
59
+ }
60
+
39
61
  export interface Location {
40
62
  id: string
41
63
  tenantId: string
@@ -78,6 +100,13 @@ export interface OrgAdapter {
78
100
  createOrg(name: string, userId: string, options?: CreateOrgOptions): Promise<Organization>
79
101
  updateOrg(orgId: string, data: Partial<Organization>): Promise<Organization>
80
102
  listMembers(orgId: string): Promise<OrgMember[]>
103
+ /**
104
+ * Person-first team list: people whose `kind` is in `personKinds`, each with an
105
+ * optional membership overlay (login + role) linked by `tenant_members.person_id`.
106
+ * Optional so adapters can adopt it incrementally; the Team screen falls back to
107
+ * `listMembers` when absent or when no `personKinds` are configured.
108
+ */
109
+ listTeam?(orgId: string, personKinds: string[]): Promise<TeamPerson[]>
81
110
  updateMemberProfile(orgId: string, memberId: string, profileId: string): Promise<void>
82
111
  removeMember(orgId: string, memberId: string): Promise<void>
83
112
  listProfiles(orgId: string): Promise<PermissionProfile[]>
@@ -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 {
@@ -54,6 +63,10 @@ export interface PluginRouteDefinition {
54
63
  permission?: PluginPermissionRequirement
55
64
  /** Render edge-to-edge with no page padding/animation wrapper (chat, kanban, canvas). */
56
65
  fullBleed?: boolean
66
+ /** Archetypes this page displays (`archetype` or `archetype:kind`, e.g.
67
+ * `'schedule:appointment'`). A CRUD page claims its own; a hand-built page
68
+ * must declare it or records it owns resolve to no route. */
69
+ entityRoutes?: string[]
57
70
  }
58
71
 
59
72
  export type PluginWidgetZone =
@@ -225,6 +238,27 @@ export interface AIToolSuggestion {
225
238
  verticalId?: VerticalId
226
239
  }
227
240
 
241
+ export type AIToolExecution =
242
+ | { plane: 'server'; kind: 'rpc'; rpc: string }
243
+ | { plane: 'server'; kind: 'entity_read'; entity: string }
244
+ | { plane: 'client' }
245
+
246
+ /**
247
+ * A tenant-scoped pool RPC (`agent_<domain>_<verb>`) this plugin ships for the
248
+ * server-plane agent executor. Surfaces in the manifest as
249
+ * `agent.rpcs` (RpcContract) — the broker only ever calls declared functions.
250
+ */
251
+ export interface AgentRpcDeclaration {
252
+ /** Function name in the pool, e.g. 'agent_agenda_create_appointment'. */
253
+ name: string
254
+ kind: 'read' | 'write'
255
+ description: string
256
+ /** JSON Schema of the p_payload argument. */
257
+ payloadSchema?: AIToolParameters
258
+ /** Whether the function writes audit_logs itself. */
259
+ audits?: boolean
260
+ }
261
+
228
262
  export interface PluginAITool {
229
263
  id: string
230
264
  name: string
@@ -233,6 +267,31 @@ export interface PluginAITool {
233
267
  mode: AIToolMode
234
268
  parameters?: AIToolParameters
235
269
  permission?: PluginPermissionRequirement
270
+ /**
271
+ * Quantity-limit key this tool's writes consume (e.g. 'bookings_month' for a
272
+ * booking-creating tool). Persist-mode tools that create countable rows MUST
273
+ * declare it so the agent executor can run the SAME plan-limit guard the UI
274
+ * runs (guard before execute, invalidate after) — an AI agent never clicks a
275
+ * gated button, so the declarative contract is its only gate.
276
+ */
277
+ limitKey?: string
278
+ /**
279
+ * Where the tool runs. Server-plane is what makes browserless channels
280
+ * (WhatsApp, MCP) able to run the tool at all:
281
+ * - `{ plane:'server', kind:'rpc', rpc }` — the Fayz broker calls the named
282
+ * `agent_*` RPC in the tenant pool (writes / guarded reads).
283
+ * - `{ plane:'server', kind:'entity_read', entity }` — the broker runs the
284
+ * generic tenant-scoped read against the entity's table (derived tools).
285
+ * - `{ plane:'client' }` (or absent) — a locally registered executor runs it
286
+ * in the surface (navigation, modals).
287
+ */
288
+ execution?: AIToolExecution
289
+ /**
290
+ * Require an explicit human confirmation before executing. Defaults to true
291
+ * for `mode: 'persist'` tools — the channel renders it natively (FAB modal,
292
+ * WhatsApp yes/no reply).
293
+ */
294
+ requiresConfirmation?: boolean
236
295
  suggestions?: AIToolSuggestion[]
237
296
  category?: string
238
297
  tags?: string[]
@@ -314,6 +373,28 @@ export interface PluginManifest {
314
373
  entities?: string[]
315
374
  permissions?: string[]
316
375
  declaredFeatures?: FeatureDeclaration[]
376
+ /**
377
+ * Quantity limits this plugin knows how to count. Aggregated by the plugin
378
+ * runtime into `PluginRuntime.pluginLimits` and consumed by the access engine
379
+ * (useLimit / useLimitGuard) so a plugin "is born compatible" with freemium
380
+ * caps without any per-app wiring. See {@link LimitDeclaration}.
381
+ */
382
+ declaredLimits?: LimitDeclaration[]
383
+ /** Pool RPCs this plugin ships for the server-plane agent executor. */
384
+ declaredRpcs?: AgentRpcDeclaration[]
385
+ /**
386
+ * Read-models this plugin exposes to the agent's generic data primitives
387
+ * (searchRecords/queryData) BEYOND its CRUD registries — typically its
388
+ * views/ledgers (v_appointments, plg_financial_movements). `key` is a stable
389
+ * ASCII id (never derived from a translated label); the entity's own
390
+ * `permission` gates each call.
391
+ */
392
+ queryEntities?: Array<{
393
+ key: string
394
+ entity: EntityDef
395
+ /** Base tables may opt into createRecord; views/ledgers stay read-only. */
396
+ writable?: boolean
397
+ }>
317
398
  registries?: PluginRegistryDef[]
318
399
  /**
319
400
  * Connectors this plugin contributes. An ADDON plugin declares its connector(s)
@@ -386,4 +467,6 @@ export interface PluginRuntime {
386
467
  /** Connectors contributed by active plugins, grouped by the host plugin they extend. */
387
468
  connectorsByHost: Map<string, ConnectorDefinition[]>
388
469
  pluginFeatures: FeatureDeclaration[]
470
+ /** Limit declarations aggregated from every active plugin's `declaredLimits`. */
471
+ pluginLimits: LimitDeclaration[]
389
472
  }
@@ -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"]}