@elevasis/core 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/index.d.ts +82 -1
  2. package/dist/index.js +353 -171
  3. package/dist/knowledge/index.d.ts +44 -1
  4. package/dist/organization-model/index.d.ts +82 -1
  5. package/dist/organization-model/index.js +353 -171
  6. package/dist/test-utils/index.d.ts +41 -12
  7. package/dist/test-utils/index.js +352 -171
  8. package/package.json +4 -3
  9. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +89 -69
  10. package/src/auth/multi-tenancy/organizations/__tests__/api-schemas.test.ts +194 -0
  11. package/src/auth/multi-tenancy/organizations/api-schemas.ts +136 -128
  12. package/src/business/acquisition/api-schemas.test.ts +199 -15
  13. package/src/business/acquisition/api-schemas.ts +116 -51
  14. package/src/business/acquisition/build-templates.test.ts +212 -0
  15. package/src/business/acquisition/derive-actions.test.ts +1 -1
  16. package/src/business/acquisition/types.ts +21 -38
  17. package/src/business/deals/api-schemas.ts +2 -2
  18. package/src/execution/engine/index.ts +436 -434
  19. package/src/execution/engine/tools/integration/server/adapters/google-calendar/google-calendar-adapter.ts +428 -0
  20. package/src/execution/engine/tools/integration/server/adapters/google-calendar/index.ts +2 -0
  21. package/src/execution/engine/tools/lead-service-types.ts +51 -9
  22. package/src/execution/engine/tools/platform/acquisition/company-tools.ts +7 -6
  23. package/src/execution/engine/tools/platform/acquisition/contact-tools.ts +6 -5
  24. package/src/execution/engine/tools/platform/acquisition/types.ts +20 -9
  25. package/src/execution/engine/tools/registry.ts +700 -698
  26. package/src/execution/engine/tools/tool-maps.ts +10 -0
  27. package/src/execution/external/__tests__/api-schemas.test.ts +127 -0
  28. package/src/integrations/oauth/__tests__/provider-registry.test.ts +7 -6
  29. package/src/integrations/oauth/provider-registry.ts +74 -61
  30. package/src/integrations/oauth/server/credentials.ts +43 -39
  31. package/src/knowledge/__tests__/queries.test.ts +89 -0
  32. package/src/organization-model/__tests__/graph.test.ts +108 -2
  33. package/src/organization-model/__tests__/icons.test.ts +61 -0
  34. package/src/organization-model/__tests__/knowledge.test.ts +118 -1
  35. package/src/organization-model/__tests__/prospecting-ssot.test.ts +91 -0
  36. package/src/organization-model/__tests__/schema.test.ts +122 -0
  37. package/src/organization-model/__tests__/surface-projection.test.ts +174 -0
  38. package/src/organization-model/defaults.ts +8 -0
  39. package/src/organization-model/domains/knowledge.ts +9 -0
  40. package/src/organization-model/domains/prospecting.ts +347 -226
  41. package/src/organization-model/domains/sales.ts +40 -30
  42. package/src/organization-model/graph/build.ts +74 -0
  43. package/src/organization-model/graph/schema.ts +1 -0
  44. package/src/organization-model/graph/types.ts +1 -0
  45. package/src/organization-model/icons.ts +3 -0
  46. package/src/organization-model/schema.ts +63 -0
  47. package/src/organization-model/surface-projection.ts +218 -0
  48. package/src/organization-model/types.ts +9 -1
  49. package/src/platform/constants/versions.ts +1 -1
  50. package/src/platform/utils/__tests__/validation.test.ts +1084 -1083
  51. package/src/platform/utils/validation.ts +425 -425
  52. package/src/reference/_generated/contracts.md +89 -69
  53. package/src/server.ts +6 -0
  54. package/src/supabase/database.types.ts +6 -12
@@ -24,4 +24,65 @@ describe('OrganizationModelIconTokenSchema', () => {
24
24
  expect(OrganizationModelIconTokenSchema.safeParse(token).success).toBe(false)
25
25
  }
26
26
  })
27
+
28
+ // max-length — CustomIconTokenSchema allows at most 80 characters
29
+ it('rejects custom token exceeding 80 characters', () => {
30
+ // 'custom.' is 7 chars; pad with 'a' so total = 81
31
+ const token = 'custom.' + 'a'.repeat(74)
32
+ expect(token.length).toBe(81)
33
+ expect(OrganizationModelIconTokenSchema.safeParse(token).success).toBe(false)
34
+ })
35
+
36
+ it('accepts custom token at exactly 80 characters', () => {
37
+ // 'custom.' = 7 chars, fill rest with 'a' to reach exactly 80
38
+ const token = 'custom.' + 'a'.repeat(73)
39
+ expect(token.length).toBe(80)
40
+ expect(OrganizationModelIconTokenSchema.safeParse(token).success).toBe(true)
41
+ })
42
+
43
+ // invalid charset — custom token regex only allows [a-z0-9] with [-._] separators
44
+ it('rejects custom token with uppercase letters in the suffix', () => {
45
+ expect(OrganizationModelIconTokenSchema.safeParse('custom.MyIcon').success).toBe(false)
46
+ })
47
+
48
+ it('rejects custom token with spaces', () => {
49
+ expect(OrganizationModelIconTokenSchema.safeParse('custom.my icon').success).toBe(false)
50
+ })
51
+
52
+ it('rejects custom token with a leading separator (double-dot)', () => {
53
+ expect(OrganizationModelIconTokenSchema.safeParse('custom..icon').success).toBe(false)
54
+ })
55
+
56
+ it('rejects custom token with a trailing separator', () => {
57
+ expect(OrganizationModelIconTokenSchema.safeParse('custom.icon-').success).toBe(false)
58
+ })
59
+
60
+ // missing prefix — tokens without the 'custom.' prefix that are not built-ins
61
+ it('rejects token that looks like a custom slug but lacks the custom. prefix', () => {
62
+ expect(OrganizationModelIconTokenSchema.safeParse('my-icon').success).toBe(false)
63
+ expect(OrganizationModelIconTokenSchema.safeParse('org.my-icon').success).toBe(false)
64
+ })
65
+ })
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // OrganizationModelBuiltinIconTokenSchema — only accepts built-ins
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe('OrganizationModelBuiltinIconTokenSchema', () => {
72
+ it('accepts every entry in ORGANIZATION_MODEL_ICON_TOKENS', () => {
73
+ for (const token of ORGANIZATION_MODEL_ICON_TOKENS) {
74
+ expect(OrganizationModelBuiltinIconTokenSchema.safeParse(token).success).toBe(true)
75
+ }
76
+ })
77
+
78
+ it('rejects custom.* tokens (only built-ins allowed)', () => {
79
+ expect(OrganizationModelBuiltinIconTokenSchema.safeParse('custom.my-icon').success).toBe(false)
80
+ expect(OrganizationModelBuiltinIconTokenSchema.safeParse('custom.partner-portal').success).toBe(false)
81
+ })
82
+
83
+ it('rejects arbitrary strings not in the enum', () => {
84
+ expect(OrganizationModelBuiltinIconTokenSchema.safeParse('book').success).toBe(false)
85
+ expect(OrganizationModelBuiltinIconTokenSchema.safeParse('').success).toBe(false)
86
+ expect(OrganizationModelBuiltinIconTokenSchema.safeParse('nav.unknown-item').success).toBe(false)
87
+ })
27
88
  })
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { OrgKnowledgeNodeSchema, OrgKnowledgeKindSchema, KnowledgeDomainSchema } from '../domains/knowledge'
2
+ import {
3
+ OrgKnowledgeNodeSchema,
4
+ OrgKnowledgeKindSchema,
5
+ KnowledgeDomainSchema,
6
+ KnowledgeLinkSchema
7
+ } from '../domains/knowledge'
3
8
  import { OrganizationGraphEdgeKindSchema } from '../graph/schema'
4
9
  import { buildOrganizationGraph } from '../graph/build'
5
10
  import { DEFAULT_ORGANIZATION_MODEL, DEFAULT_ORGANIZATION_MODEL_KNOWLEDGE } from '../defaults'
@@ -212,3 +217,115 @@ describe('buildOrganizationGraph with knowledge', () => {
212
217
  expect(defaultKnowledgeNodes).toHaveLength(0)
213
218
  })
214
219
  })
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // KnowledgeLinkSchema — nodeId format validation
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe('KnowledgeLinkSchema', () => {
226
+ it('accepts a valid kind:dotted-path nodeId', () => {
227
+ expect(() => KnowledgeLinkSchema.parse({ nodeId: 'feature:sales.crm' })).not.toThrow()
228
+ expect(() => KnowledgeLinkSchema.parse({ nodeId: 'feature:sales.lead-gen' })).not.toThrow()
229
+ })
230
+
231
+ it('accepts resource: and knowledge: prefixed node IDs', () => {
232
+ expect(() => KnowledgeLinkSchema.parse({ nodeId: 'resource:my-resource' })).not.toThrow()
233
+ expect(() => KnowledgeLinkSchema.parse({ nodeId: 'knowledge:knowledge.test-doc' })).not.toThrow()
234
+ })
235
+
236
+ it('rejects a bare dotted ID with no kind: prefix', () => {
237
+ // NodeIdStringSchema requires kind:dotted-path format
238
+ expect(KnowledgeLinkSchema.safeParse({ nodeId: 'sales.crm' }).success).toBe(false)
239
+ })
240
+
241
+ it('rejects an empty nodeId', () => {
242
+ expect(KnowledgeLinkSchema.safeParse({ nodeId: '' }).success).toBe(false)
243
+ })
244
+
245
+ it('rejects a nodeId with uppercase characters', () => {
246
+ // NodeIdStringSchema regex requires lowercase
247
+ expect(KnowledgeLinkSchema.safeParse({ nodeId: 'Feature:Sales.CRM' }).success).toBe(false)
248
+ })
249
+
250
+ it('rejects a link missing the nodeId field', () => {
251
+ expect(KnowledgeLinkSchema.safeParse({}).success).toBe(false)
252
+ })
253
+
254
+ it('rejects a plain number as nodeId', () => {
255
+ expect(KnowledgeLinkSchema.safeParse({ nodeId: 123 }).success).toBe(false)
256
+ })
257
+ })
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // OrgKnowledgeNodeSchema — field length constraints
261
+ // ---------------------------------------------------------------------------
262
+
263
+ describe('OrgKnowledgeNodeSchema length constraints', () => {
264
+ const BASE = {
265
+ id: 'knowledge.test-node',
266
+ kind: 'playbook',
267
+ title: 'Test Node',
268
+ summary: 'A valid summary.',
269
+ body: 'Body content.',
270
+ links: [],
271
+ ownerIds: [],
272
+ updatedAt: '2026-05-01'
273
+ }
274
+
275
+ it('rejects title exceeding 200 characters', () => {
276
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, title: 'x'.repeat(201) })
277
+ expect(result.success).toBe(false)
278
+ })
279
+
280
+ it('accepts title exactly at 200 characters', () => {
281
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, title: 'x'.repeat(200) })
282
+ expect(result.success).toBe(true)
283
+ })
284
+
285
+ it('rejects summary exceeding 1000 characters', () => {
286
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, summary: 'x'.repeat(1001) })
287
+ expect(result.success).toBe(false)
288
+ })
289
+
290
+ it('accepts summary exactly at 1000 characters', () => {
291
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, summary: 'x'.repeat(1000) })
292
+ expect(result.success).toBe(true)
293
+ })
294
+
295
+ it('rejects empty title', () => {
296
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, title: '' })
297
+ expect(result.success).toBe(false)
298
+ })
299
+
300
+ it('rejects empty summary', () => {
301
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, summary: '' })
302
+ expect(result.success).toBe(false)
303
+ })
304
+
305
+ it('rejects empty body', () => {
306
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, body: '' })
307
+ expect(result.success).toBe(false)
308
+ })
309
+
310
+ it('rejects id with uppercase characters', () => {
311
+ // ModelIdSchema requires lowercase kebab/dot/underscore separated IDs
312
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, id: 'Knowledge.Test' })
313
+ expect(result.success).toBe(false)
314
+ })
315
+
316
+ it('rejects id exceeding 100 characters', () => {
317
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, id: 'knowledge.' + 'a'.repeat(95) })
318
+ expect(result.success).toBe(false)
319
+ })
320
+
321
+ it('accepts a valid custom icon token on a node', () => {
322
+ const result = OrgKnowledgeNodeSchema.safeParse({ ...BASE, icon: 'custom.my-icon' })
323
+ expect(result.success).toBe(true)
324
+ })
325
+
326
+ it('trims whitespace from title and summary', () => {
327
+ const node = OrgKnowledgeNodeSchema.parse({ ...BASE, title: ' Trimmed ', summary: ' Summary. ' })
328
+ expect(node.title).toBe('Trimmed')
329
+ expect(node.summary).toBe('Summary.')
330
+ })
331
+ })
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { LEAD_GEN_STAGE_CATALOG } from '../domains/sales'
3
+ import { CAPABILITY_REGISTRY, DEFAULT_ORGANIZATION_MODEL_PROSPECTING, PROSPECTING_STEPS } from '../domains/prospecting'
4
+
5
+ const EXPECTED_CAPABILITY_RESOURCE_BY_ID: Record<string, string> = {
6
+ 'lead-gen.company.source': 'lgn-import-workflow',
7
+ 'lead-gen.company.apollo-import': 'lgn-01c-apollo-import-workflow',
8
+ 'lead-gen.contact.discover': 'lgn-04-email-discovery-workflow',
9
+ 'lead-gen.contact.verify-email': 'lgn-05-email-verification-workflow',
10
+ 'lead-gen.company.website-extract': 'lgn-02-website-extract-workflow',
11
+ 'lead-gen.company.qualify': 'lgn-03-company-qualification-workflow',
12
+ 'lead-gen.company.dtc-subscription-qualify': 'lgn-03b-dtc-subscription-score-workflow',
13
+ 'lead-gen.contact.apollo-decision-maker-enrich': 'lgn-04b-apollo-decision-maker-enrich-workflow',
14
+ 'lead-gen.contact.personalize': 'ist-personalization-workflow',
15
+ 'lead-gen.review.outreach-ready': 'ist-upload-contacts-workflow',
16
+ 'lead-gen.export.list': 'lgn-06-export-list-workflow',
17
+ 'lead-gen.company.cleanup': 'lgn-company-cleanup-workflow'
18
+ }
19
+
20
+ describe('prospecting organization-model SSOT', () => {
21
+ it('catalogs the DTC decision-maker enrichment stage', () => {
22
+ expect(LEAD_GEN_STAGE_CATALOG['decision-makers-enriched']).toEqual({
23
+ key: 'decision-makers-enriched',
24
+ label: 'Decision-makers found',
25
+ description: 'Decision-maker contacts discovered and attached to a qualified company.',
26
+ order: 6,
27
+ entity: 'company'
28
+ })
29
+ })
30
+
31
+ it('scopes prospecting steps per build template', () => {
32
+ expect(PROSPECTING_STEPS.localServices.findContacts).toMatchObject({
33
+ id: 'find-contacts',
34
+ primaryEntity: 'contact',
35
+ stageKey: 'discovered'
36
+ })
37
+ expect(PROSPECTING_STEPS.dtcApolloClickup.enrichDecisionMakers).toMatchObject({
38
+ id: 'enrich-decision-makers',
39
+ primaryEntity: 'company',
40
+ stageKey: 'decision-makers-enriched'
41
+ })
42
+ })
43
+
44
+ it('uses catalog stage keys for every prospecting step', () => {
45
+ const catalogKeys = new Set(Object.keys(LEAD_GEN_STAGE_CATALOG))
46
+
47
+ for (const templateSteps of Object.values(PROSPECTING_STEPS)) {
48
+ for (const step of Object.values(templateSteps)) {
49
+ expect(catalogKeys.has(step.stageKey), step.id).toBe(true)
50
+ }
51
+ }
52
+ })
53
+
54
+ it('uses registered capabilities for every prospecting step', () => {
55
+ const capabilityKeys = new Set(CAPABILITY_REGISTRY.map((c) => c.id))
56
+
57
+ for (const templateSteps of Object.values(PROSPECTING_STEPS)) {
58
+ for (const step of Object.values(templateSteps)) {
59
+ expect(capabilityKeys.has(step.capabilityKey), step.id).toBe(true)
60
+ }
61
+ }
62
+ })
63
+
64
+ it('derives prospecting lifecycle stages from the stage catalog by entity', () => {
65
+ const expectedCompanyStages = Object.values(LEAD_GEN_STAGE_CATALOG)
66
+ .filter((stage) => stage.entity === 'company')
67
+ .sort((a, b) => a.order - b.order)
68
+ .map(({ key, label, order }) => ({ id: key, label, order }))
69
+
70
+ const expectedContactStages = Object.values(LEAD_GEN_STAGE_CATALOG)
71
+ .filter((stage) => stage.entity === 'contact')
72
+ .sort((a, b) => a.order - b.order)
73
+ .map(({ key, label, order }) => ({ id: key, label, order }))
74
+
75
+ expect(DEFAULT_ORGANIZATION_MODEL_PROSPECTING.companyStages).toEqual(expectedCompanyStages)
76
+ expect(DEFAULT_ORGANIZATION_MODEL_PROSPECTING.contactStages).toEqual(expectedContactStages)
77
+ })
78
+
79
+ it('build templates reference PROSPECTING_STEPS entries', () => {
80
+ const [localServices, dtcApolloClickup] = DEFAULT_ORGANIZATION_MODEL_PROSPECTING.buildTemplates
81
+
82
+ expect(localServices?.steps).toEqual(Object.values(PROSPECTING_STEPS.localServices))
83
+ expect(dtcApolloClickup?.steps).toEqual(Object.values(PROSPECTING_STEPS.dtcApolloClickup))
84
+ })
85
+
86
+ it('covers all known lead-gen capability registry entries', () => {
87
+ expect(CAPABILITY_REGISTRY).toHaveLength(12)
88
+ const actualResourceById = Object.fromEntries(CAPABILITY_REGISTRY.map((c) => [c.id, c.resourceId]))
89
+ expect(actualResourceById).toEqual(EXPECTED_CAPABILITY_RESOURCE_BY_ID)
90
+ })
91
+ })
@@ -14,6 +14,20 @@ function makeFeature(id: string, path = `/${id.replaceAll('.', '/')}`) {
14
14
  }
15
15
  }
16
16
 
17
+ function makeSurface(id: string, featureId?: string, featureIds: string[] = []) {
18
+ return {
19
+ id,
20
+ label: id,
21
+ path: `/${id.replaceAll('.', '/')}`,
22
+ surfaceType: 'page' as const,
23
+ featureId,
24
+ featureIds,
25
+ entityIds: [],
26
+ resourceIds: [],
27
+ capabilityIds: []
28
+ }
29
+ }
30
+
17
31
  function makeMinimalModel(features: unknown[] = []) {
18
32
  return {
19
33
  version: 1 as const,
@@ -48,6 +62,22 @@ describe('flat feature tree validation', () => {
48
62
  expect(messages.some((message) => message.includes('Feature id "sales" must be unique'))).toBe(true)
49
63
  })
50
64
 
65
+ it('rejects duplicate effective feature paths when explicit and default paths collide', () => {
66
+ const messages = getIssueMessages(
67
+ makeMinimalModel([
68
+ { id: 'sales', label: 'Sales', enabled: true },
69
+ makeFeature('sales.crm', '/sales/lead-gen'),
70
+ { id: 'sales.lead-gen', label: 'Lead Gen', enabled: true }
71
+ ])
72
+ )
73
+
74
+ expect(
75
+ messages.some((message) =>
76
+ message.includes('Feature "sales.lead-gen" effective path "/sales/lead-gen" duplicates feature "sales.crm"')
77
+ )
78
+ ).toBe(true)
79
+ })
80
+
51
81
  it('rejects a dotted child when its immediate parent is missing', () => {
52
82
  const messages = getIssueMessages(makeMinimalModel([makeFeature('sales.crm.pipeline')]))
53
83
 
@@ -80,4 +110,96 @@ describe('flat feature tree validation', () => {
80
110
  expect(model.navigation.groups).toEqual([])
81
111
  expect('resourceMappings' in model).toBe(false)
82
112
  })
113
+
114
+ it('rejects an unknown navigation default surface when surfaces are explicitly empty', () => {
115
+ const messages = getIssueMessages({
116
+ ...makeMinimalModel([makeFeature('dashboard', '/')]),
117
+ navigation: {
118
+ defaultSurfaceId: 'missing.surface',
119
+ surfaces: [],
120
+ groups: []
121
+ }
122
+ })
123
+
124
+ expect(
125
+ messages.some((message) =>
126
+ message.includes('Navigation defaultSurfaceId references unknown surface "missing.surface"')
127
+ )
128
+ ).toBe(true)
129
+ })
130
+
131
+ it('rejects navigation group surfaceIds that reference missing surfaces', () => {
132
+ const messages = getIssueMessages({
133
+ ...makeMinimalModel([makeFeature('dashboard', '/')]),
134
+ navigation: {
135
+ surfaces: [makeSurface('dashboard.home', 'dashboard')],
136
+ groups: [
137
+ {
138
+ id: 'primary',
139
+ label: 'Primary',
140
+ placement: 'primary',
141
+ surfaceIds: ['missing.surface']
142
+ }
143
+ ]
144
+ }
145
+ })
146
+
147
+ expect(
148
+ messages.some((message) =>
149
+ message.includes('Navigation group "primary" references unknown surface "missing.surface"')
150
+ )
151
+ ).toBe(true)
152
+ })
153
+
154
+ it('rejects navigation surface featureId references to missing features', () => {
155
+ const messages = getIssueMessages({
156
+ ...makeMinimalModel([makeFeature('dashboard', '/')]),
157
+ navigation: {
158
+ surfaces: [makeSurface('dashboard.home', 'missing.feature')],
159
+ groups: []
160
+ }
161
+ })
162
+
163
+ expect(
164
+ messages.some((message) =>
165
+ message.includes('Navigation surface "dashboard.home" references unknown feature "missing.feature"')
166
+ )
167
+ ).toBe(true)
168
+ })
169
+
170
+ it('rejects navigation surface featureIds references to missing features', () => {
171
+ const messages = getIssueMessages({
172
+ ...makeMinimalModel([makeFeature('dashboard', '/')]),
173
+ navigation: {
174
+ surfaces: [makeSurface('dashboard.home', 'dashboard', ['dashboard', 'missing.feature'])],
175
+ groups: []
176
+ }
177
+ })
178
+
179
+ expect(
180
+ messages.some((message) =>
181
+ message.includes('Navigation surface "dashboard.home" references unknown feature "missing.feature"')
182
+ )
183
+ ).toBe(true)
184
+ })
185
+
186
+ it('allows legacy navigation feature aliases when canonical features exist', () => {
187
+ const result = OrganizationModelSchema.safeParse({
188
+ ...makeMinimalModel([
189
+ { id: 'sales', label: 'Sales', enabled: true },
190
+ makeFeature('sales.crm', '/sales/crm/pipeline'),
191
+ makeFeature('sales.lead-gen', '/lead-gen/lists')
192
+ ]),
193
+ navigation: {
194
+ defaultSurfaceId: 'crm.pipeline',
195
+ surfaces: [
196
+ makeSurface('crm.pipeline', 'crm', ['crm']),
197
+ makeSurface('lead-gen.lists', 'lead-gen', ['lead-gen'])
198
+ ],
199
+ groups: []
200
+ }
201
+ })
202
+
203
+ expect(result.success).toBe(true)
204
+ })
83
205
  })
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { DEFAULT_ORGANIZATION_MODEL } from '../defaults'
3
+ import {
4
+ projectOrganizationSurfaces,
5
+ validateOrganizationSurfaceProjection
6
+ } from '../surface-projection'
7
+ import type { OrganizationModel, OrganizationModelFeature, OrganizationModelSurface } from '../types'
8
+
9
+ function makeFeature(
10
+ id: string,
11
+ overrides: Partial<OrganizationModelFeature> = {}
12
+ ): OrganizationModelFeature {
13
+ return {
14
+ id,
15
+ label: id,
16
+ enabled: true,
17
+ ...overrides
18
+ }
19
+ }
20
+
21
+ function makeSurface(
22
+ id: string,
23
+ overrides: Partial<OrganizationModelSurface> = {}
24
+ ): OrganizationModelSurface {
25
+ return {
26
+ id,
27
+ label: id,
28
+ path: `/${id.replaceAll('.', '/')}`,
29
+ surfaceType: 'page',
30
+ enabled: true,
31
+ featureIds: [],
32
+ entityIds: [],
33
+ resourceIds: [],
34
+ capabilityIds: [],
35
+ ...overrides
36
+ }
37
+ }
38
+
39
+ function makeModel(
40
+ features: OrganizationModelFeature[],
41
+ surfaces: OrganizationModelSurface[],
42
+ navigation: Partial<OrganizationModel['navigation']> = {}
43
+ ): OrganizationModel {
44
+ return {
45
+ ...DEFAULT_ORGANIZATION_MODEL,
46
+ features,
47
+ navigation: {
48
+ surfaces,
49
+ groups: [],
50
+ ...navigation
51
+ }
52
+ }
53
+ }
54
+
55
+ describe('projectOrganizationSurfaces', () => {
56
+ it('projects navigation surfaces into data-only DTOs with inherited flags', () => {
57
+ const model = makeModel(
58
+ [
59
+ makeFeature('sales', { requiresAdmin: true }),
60
+ makeFeature('sales.crm', { devOnly: true })
61
+ ],
62
+ [
63
+ makeSurface('crm.pipeline', {
64
+ label: 'Pipeline',
65
+ path: '/crm/pipeline',
66
+ surfaceType: 'graph',
67
+ featureId: 'sales.crm',
68
+ featureIds: ['sales.crm'],
69
+ entityIds: ['crm.deal'],
70
+ resourceIds: ['workflow.crm-sync'],
71
+ capabilityIds: ['crm.pipeline.manage']
72
+ })
73
+ ]
74
+ )
75
+
76
+ expect(projectOrganizationSurfaces(model)).toEqual([
77
+ {
78
+ id: 'crm.pipeline',
79
+ label: 'Pipeline',
80
+ path: '/crm/pipeline',
81
+ surfaceType: 'graph',
82
+ featureId: 'sales.crm',
83
+ featureIds: ['sales.crm'],
84
+ entityIds: ['crm.deal'],
85
+ resourceIds: ['workflow.crm-sync'],
86
+ capabilityIds: ['crm.pipeline.manage'],
87
+ enabled: true,
88
+ devOnly: true,
89
+ requiresAdmin: true
90
+ }
91
+ ])
92
+ })
93
+
94
+ it('canonicalizes legacy feature aliases', () => {
95
+ const model = makeModel(
96
+ [
97
+ makeFeature('sales'),
98
+ makeFeature('sales.crm'),
99
+ makeFeature('sales.lead-gen'),
100
+ makeFeature('monitoring'),
101
+ makeFeature('monitoring.submitted-requests')
102
+ ],
103
+ [
104
+ makeSurface('legacy.sales', {
105
+ featureId: 'crm',
106
+ featureIds: ['crm', 'lead-gen', 'submitted-requests']
107
+ })
108
+ ]
109
+ )
110
+
111
+ expect(projectOrganizationSurfaces(model)[0]).toMatchObject({
112
+ featureId: 'sales.crm',
113
+ featureIds: ['sales.crm', 'sales.lead-gen', 'monitoring.submitted-requests']
114
+ })
115
+ })
116
+
117
+ it('disables projected surfaces when feature lineage is disabled', () => {
118
+ const model = makeModel(
119
+ [
120
+ makeFeature('sales', { enabled: false }),
121
+ makeFeature('sales.crm')
122
+ ],
123
+ [
124
+ makeSurface('crm.pipeline', {
125
+ enabled: true,
126
+ featureId: 'sales.crm',
127
+ featureIds: ['sales.crm']
128
+ })
129
+ ]
130
+ )
131
+
132
+ expect(projectOrganizationSurfaces(model)[0].enabled).toBe(false)
133
+ })
134
+ })
135
+
136
+ describe('validateOrganizationSurfaceProjection', () => {
137
+ it('returns issue codes for duplicate and unknown surface references', () => {
138
+ const model = makeModel(
139
+ [makeFeature('sales'), makeFeature('sales.crm')],
140
+ [
141
+ makeSurface('crm.pipeline', {
142
+ path: '/crm/pipeline',
143
+ featureId: 'sales.crm',
144
+ featureIds: ['sales.crm']
145
+ }),
146
+ makeSurface('crm.pipeline', {
147
+ path: '/crm/pipeline/',
148
+ featureId: 'missing.primary',
149
+ featureIds: ['missing.related']
150
+ })
151
+ ],
152
+ {
153
+ defaultSurfaceId: 'missing.default',
154
+ groups: [
155
+ {
156
+ id: 'primary',
157
+ label: 'Primary',
158
+ placement: 'primary',
159
+ surfaceIds: ['missing.group']
160
+ }
161
+ ]
162
+ }
163
+ )
164
+
165
+ expect(validateOrganizationSurfaceProjection(model).map((issue) => issue.code)).toEqual([
166
+ 'duplicate-surface-id',
167
+ 'duplicate-surface-path',
168
+ 'unknown-surface-feature',
169
+ 'unknown-surface-feature-reference',
170
+ 'unknown-default-surface',
171
+ 'unknown-group-surface'
172
+ ])
173
+ })
174
+ })
@@ -150,6 +150,14 @@ export const DEFAULT_ORGANIZATION_MODEL: OrganizationModel = {
150
150
  enabled: true,
151
151
  uiPosition: 'sidebar-primary'
152
152
  },
153
+ {
154
+ id: 'monitoring.calendar',
155
+ label: 'Calendar',
156
+ description: 'Google Calendar events and agenda views',
157
+ enabled: true,
158
+ path: '/monitoring/calendar',
159
+ icon: 'feature.calendar'
160
+ },
153
161
  {
154
162
  id: 'monitoring.activity-log',
155
163
  label: 'Activity Log',
@@ -12,6 +12,9 @@ export const KnowledgeLinkSchema = z.object({
12
12
  nodeId: NodeIdStringSchema
13
13
  })
14
14
 
15
+ export const KnowledgeSkillBindingSchema = z.string().trim().min(1).max(120)
16
+ export const KnowledgeDomainBindingSchema = z.string().trim().min(1).max(80)
17
+
15
18
  // ---------------------------------------------------------------------------
16
19
  // OrgKnowledgeNode — single schema, `kind` discriminator drives presentation.
17
20
  // Phase 1: body is raw MDX string (lossless migration from operations/ docs).
@@ -33,6 +36,10 @@ export const OrgKnowledgeNodeSchema = z.object({
33
36
  * Each link emits a `governs` edge: knowledge-node -> target node.
34
37
  */
35
38
  links: z.array(KnowledgeLinkSchema).default([]),
39
+ /** Operator skill or command bindings relevant to this node. */
40
+ skills: z.array(KnowledgeSkillBindingSchema).optional(),
41
+ /** Domain key used to derive fast graph->skill registries. */
42
+ domain: KnowledgeDomainBindingSchema.optional(),
36
43
  /** Identifiers of the roles or members who own this knowledge node. */
37
44
  ownerIds: z.array(ModelIdSchema).default([]),
38
45
  /** ISO date string (YYYY-MM-DD or full ISO 8601) of last meaningful update. */
@@ -50,4 +57,6 @@ export const KnowledgeDomainSchema = z.object({
50
57
  export type OrgKnowledgeNode = z.infer<typeof OrgKnowledgeNodeSchema>
51
58
  export type OrgKnowledgeKind = z.infer<typeof OrgKnowledgeKindSchema>
52
59
  export type KnowledgeLink = z.infer<typeof KnowledgeLinkSchema>
60
+ export type KnowledgeSkillBinding = z.infer<typeof KnowledgeSkillBindingSchema>
61
+ export type KnowledgeDomainBinding = z.infer<typeof KnowledgeDomainBindingSchema>
53
62
  export type KnowledgeDomain = z.infer<typeof KnowledgeDomainSchema>