@elevasis/core 0.35.1 → 0.37.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 (32) hide show
  1. package/dist/auth/index.d.ts +124 -2
  2. package/dist/auth/index.js +14 -1
  3. package/dist/index.d.ts +334 -3
  4. package/dist/index.js +21 -5
  5. package/dist/knowledge/index.d.ts +81 -0
  6. package/dist/organization-model/index.d.ts +334 -3
  7. package/dist/organization-model/index.js +21 -5
  8. package/dist/test-utils/index.d.ts +97 -0
  9. package/dist/test-utils/index.js +19 -4
  10. package/package.json +1 -1
  11. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +67 -0
  12. package/src/auth/multi-tenancy/invitations/api-schemas.ts +24 -9
  13. package/src/auth/multi-tenancy/invitations/index.ts +11 -9
  14. package/src/auth/multi-tenancy/organizations/__tests__/api-schemas.test.ts +39 -3
  15. package/src/auth/multi-tenancy/organizations/api-schemas.ts +36 -8
  16. package/src/auth/multi-tenancy/organizations/index.ts +13 -11
  17. package/src/business/acquisition/build-templates.test.ts +34 -0
  18. package/src/business/acquisition/build-templates.ts +8 -1
  19. package/src/organization-model/__tests__/domains/navigation-topbar.test.ts +282 -0
  20. package/src/organization-model/__tests__/migration-helpers.test.ts +11 -11
  21. package/src/organization-model/defaults.ts +2 -1
  22. package/src/organization-model/domains/navigation.ts +176 -139
  23. package/src/organization-model/icons.ts +1 -0
  24. package/src/organization-model/migration-helpers.ts +8 -1
  25. package/src/organization-model/published.ts +6 -6
  26. package/src/organization-model/types.ts +5 -1
  27. package/src/platform/constants/versions.ts +1 -1
  28. package/src/platform/registry/__tests__/resource-registry.test.ts +2053 -2054
  29. package/src/reference/_generated/contracts.md +67 -0
  30. package/src/scaffold-registry/index.ts +4 -4
  31. package/src/supabase/database.types.ts +15 -0
  32. package/src/test-utils/rls/RLSTestContext.ts +3 -3
@@ -0,0 +1,282 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ OrganizationModelNavigationSchema,
4
+ TopbarActionNodeSchema,
5
+ TopbarSectionSchema
6
+ } from '../../domains/navigation'
7
+ import { OrganizationModelSchema } from '../../schema'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function makeMinimalSystems() {
14
+ return {
15
+ dashboard: {
16
+ id: 'dashboard',
17
+ order: 10,
18
+ label: 'Dashboard',
19
+ enabled: true,
20
+ lifecycle: 'active' as const,
21
+ path: '/'
22
+ }
23
+ }
24
+ }
25
+
26
+ function makeMinimalModel(overrides: Record<string, unknown> = {}) {
27
+ return {
28
+ version: 1,
29
+ branding: { organizationName: 'Test', productName: 'Test OS', shortName: 'Test' },
30
+ systems: makeMinimalSystems(),
31
+ entities: {},
32
+ actions: {},
33
+ ...overrides
34
+ }
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // TopbarActionNodeSchema
39
+ // ---------------------------------------------------------------------------
40
+
41
+ describe('TopbarActionNodeSchema — leaf node shape', () => {
42
+ it('accepts a minimal node (id + label + enabled default)', () => {
43
+ const result = TopbarActionNodeSchema.safeParse({
44
+ id: 'request',
45
+ label: 'Request a feature or report an issue'
46
+ })
47
+
48
+ expect(result.success).toBe(true)
49
+ if (result.success) {
50
+ expect(result.data.enabled).toBe(true)
51
+ }
52
+ })
53
+
54
+ it('accepts a fully populated node', () => {
55
+ const result = TopbarActionNodeSchema.safeParse({
56
+ id: 'request',
57
+ label: 'Request a feature or report an issue',
58
+ tooltip: 'Request a feature or report an issue',
59
+ icon: 'message-plus',
60
+ order: 10,
61
+ enabled: true,
62
+ devOnly: false,
63
+ requiresAdmin: false,
64
+ targets: { systems: ['monitoring'], actions: [] }
65
+ })
66
+
67
+ expect(result.success).toBe(true)
68
+ })
69
+
70
+ it('defaults enabled to true when omitted', () => {
71
+ const result = TopbarActionNodeSchema.parse({ id: 'docs', label: 'Docs' })
72
+
73
+ expect(result.enabled).toBe(true)
74
+ })
75
+
76
+ it('accepts enabled: false to mark an item as hidden', () => {
77
+ const result = TopbarActionNodeSchema.parse({ id: 'docs', label: 'Docs', enabled: false })
78
+
79
+ expect(result.enabled).toBe(false)
80
+ })
81
+
82
+ it('accepts optional devOnly flag', () => {
83
+ const result = TopbarActionNodeSchema.parse({ id: 'devtools', label: 'Dev Tools', devOnly: true })
84
+
85
+ expect(result.devOnly).toBe(true)
86
+ })
87
+
88
+ it('accepts optional requiresAdmin flag', () => {
89
+ const result = TopbarActionNodeSchema.parse({ id: 'admin-panel', label: 'Admin Panel', requiresAdmin: true })
90
+
91
+ expect(result.requiresAdmin).toBe(true)
92
+ })
93
+
94
+ it('rejects a node missing id', () => {
95
+ const result = TopbarActionNodeSchema.safeParse({ label: 'No Id' })
96
+
97
+ expect(result.success).toBe(false)
98
+ })
99
+
100
+ it('rejects a node missing label', () => {
101
+ const result = TopbarActionNodeSchema.safeParse({ id: 'no-label' })
102
+
103
+ expect(result.success).toBe(false)
104
+ })
105
+
106
+ it('does NOT have a path field (leaf, not a route)', () => {
107
+ const result = TopbarActionNodeSchema.parse({ id: 'request', label: 'Request' })
108
+
109
+ expect('path' in result).toBe(false)
110
+ })
111
+
112
+ it('does NOT have a surfaceType field (closed enum untouched)', () => {
113
+ const result = TopbarActionNodeSchema.parse({ id: 'request', label: 'Request' })
114
+
115
+ expect('surfaceType' in result).toBe(false)
116
+ })
117
+ })
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // TopbarSectionSchema
121
+ // ---------------------------------------------------------------------------
122
+
123
+ describe('TopbarSectionSchema — flat map of topbar action nodes', () => {
124
+ it('defaults to an empty record', () => {
125
+ const result = TopbarSectionSchema.parse(undefined)
126
+
127
+ expect(result).toEqual({})
128
+ })
129
+
130
+ it('accepts a populated map', () => {
131
+ const result = TopbarSectionSchema.safeParse({
132
+ request: { id: 'request', label: 'Request' },
133
+ docs: { id: 'docs', label: 'Docs', icon: 'knowledge' }
134
+ })
135
+
136
+ expect(result.success).toBe(true)
137
+ if (result.success) {
138
+ expect(Object.keys(result.data)).toEqual(['request', 'docs'])
139
+ }
140
+ })
141
+ })
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // OrganizationModelNavigationSchema — topbar field added
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('OrganizationModelNavigationSchema — topbar region', () => {
148
+ it('defaults topbar to an empty record', () => {
149
+ const result = OrganizationModelNavigationSchema.parse({})
150
+
151
+ expect(result.topbar).toEqual({})
152
+ })
153
+
154
+ it('retains existing sidebar defaults (regression: append only, sidebar untouched)', () => {
155
+ const result = OrganizationModelNavigationSchema.parse({})
156
+
157
+ expect(result.sidebar.primary).toEqual({})
158
+ expect(result.sidebar.bottom).toEqual({})
159
+ })
160
+
161
+ it('accepts topbar nodes alongside sidebar', () => {
162
+ const result = OrganizationModelNavigationSchema.safeParse({
163
+ sidebar: { primary: {}, bottom: {} },
164
+ topbar: {
165
+ request: {
166
+ id: 'request',
167
+ label: 'Request a feature or report an issue',
168
+ tooltip: 'Request a feature or report an issue',
169
+ icon: 'message-plus',
170
+ order: 10,
171
+ enabled: true
172
+ }
173
+ }
174
+ })
175
+
176
+ expect(result.success).toBe(true)
177
+ if (result.success) {
178
+ expect(result.data.topbar['request']?.id).toBe('request')
179
+ expect(result.data.topbar['request']?.icon).toBe('message-plus')
180
+ }
181
+ })
182
+ })
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // OrganizationModelSchema — navigation.topbar propagates through full schema
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe('OrganizationModelSchema — navigation.topbar contract', () => {
189
+ it('accepts a model with topbar declared', () => {
190
+ const result = OrganizationModelSchema.safeParse(
191
+ makeMinimalModel({
192
+ navigation: {
193
+ topbar: {
194
+ request: {
195
+ id: 'request',
196
+ label: 'Request a feature or report an issue',
197
+ tooltip: 'Request a feature or report an issue',
198
+ icon: 'message-plus',
199
+ order: 10,
200
+ enabled: true
201
+ }
202
+ }
203
+ }
204
+ })
205
+ )
206
+
207
+ expect(result.success).toBe(true)
208
+ if (result.success) {
209
+ expect(result.data.navigation.topbar['request']?.id).toBe('request')
210
+ }
211
+ })
212
+
213
+ it('defaults navigation.topbar to empty when navigation is omitted', () => {
214
+ const result = OrganizationModelSchema.safeParse(makeMinimalModel())
215
+
216
+ expect(result.success).toBe(true)
217
+ if (result.success) {
218
+ expect(result.data.navigation.topbar).toEqual({})
219
+ }
220
+ })
221
+
222
+ it('defaults navigation.topbar to empty when only sidebar is provided', () => {
223
+ const result = OrganizationModelSchema.safeParse(
224
+ makeMinimalModel({
225
+ navigation: {
226
+ sidebar: {
227
+ primary: {
228
+ dashboard: {
229
+ type: 'surface',
230
+ label: 'Dashboard',
231
+ path: '/',
232
+ surfaceType: 'dashboard',
233
+ order: 10
234
+ }
235
+ },
236
+ bottom: {}
237
+ }
238
+ }
239
+ })
240
+ )
241
+
242
+ expect(result.success).toBe(true)
243
+ if (result.success) {
244
+ expect(result.data.navigation.topbar).toEqual({})
245
+ }
246
+ })
247
+
248
+ it('coexists with sidebar — both sections independently populated', () => {
249
+ const result = OrganizationModelSchema.safeParse(
250
+ makeMinimalModel({
251
+ navigation: {
252
+ sidebar: {
253
+ primary: {
254
+ dashboard: {
255
+ type: 'surface',
256
+ label: 'Dashboard',
257
+ path: '/',
258
+ surfaceType: 'dashboard',
259
+ order: 10
260
+ }
261
+ },
262
+ bottom: {}
263
+ },
264
+ topbar: {
265
+ request: {
266
+ id: 'request',
267
+ label: 'Request',
268
+ order: 10,
269
+ enabled: true
270
+ }
271
+ }
272
+ }
273
+ })
274
+ )
275
+
276
+ expect(result.success).toBe(true)
277
+ if (result.success) {
278
+ expect(result.data.navigation.sidebar.primary['dashboard']?.label).toBe('Dashboard')
279
+ expect(result.data.navigation.topbar['request']?.label).toBe('Request')
280
+ }
281
+ })
282
+ })
@@ -114,17 +114,6 @@ function modelWithBuildTemplate(): OrganizationModel {
114
114
  kind: 'template-step',
115
115
  appliesTo: 'sales.lead-gen:object/list',
116
116
  entries: {
117
- 'source-companies': {
118
- label: 'Source Companies',
119
- order: 10,
120
- primaryEntity: 'company',
121
- outputs: ['company'],
122
- stageKey: 'populated',
123
- dependencyMode: 'per-record-eligibility',
124
- actionKey: 'lead-gen.company.source',
125
- defaultBatchSize: 100,
126
- maxBatchSize: 500
127
- },
128
117
  'qualify-companies': {
129
118
  label: 'Qualify Companies',
130
119
  order: 20,
@@ -136,6 +125,17 @@ function modelWithBuildTemplate(): OrganizationModel {
136
125
  actionKey: 'lead-gen.company.qualify',
137
126
  defaultBatchSize: 100,
138
127
  maxBatchSize: 500
128
+ },
129
+ 'source-companies': {
130
+ label: 'Source Companies',
131
+ order: 10,
132
+ primaryEntity: 'company',
133
+ outputs: ['company'],
134
+ stageKey: 'populated',
135
+ dependencyMode: 'per-record-eligibility',
136
+ actionKey: 'lead-gen.company.source',
137
+ defaultBatchSize: 100,
138
+ maxBatchSize: 500
139
139
  }
140
140
  }
141
141
  }
@@ -37,7 +37,8 @@ const DEFAULT_ORGANIZATION_MODEL_NAVIGATION: OrganizationModelNavigation = {
37
37
  sidebar: {
38
38
  primary: {},
39
39
  bottom: {}
40
- }
40
+ },
41
+ topbar: {}
41
42
  }
42
43
 
43
44
  export const DEFAULT_ORGANIZATION_MODEL: OrganizationModel = {
@@ -1,139 +1,176 @@
1
- import { z, type ZodType } from 'zod'
2
- import { DescriptionSchema, IconNameSchema, LabelSchema, ModelIdSchema, PathSchema } from './shared'
3
-
4
- export const SurfaceTypeSchema = z
5
- .enum(['page', 'dashboard', 'graph', 'detail', 'list', 'settings'])
6
- .meta({ label: 'Surface type', color: 'blue' })
7
-
8
- export const SurfaceDefinitionSchema = z.object({
9
- id: ModelIdSchema,
10
- label: LabelSchema,
11
- path: PathSchema,
12
- surfaceType: SurfaceTypeSchema,
13
- description: DescriptionSchema.optional(),
14
- enabled: z.boolean().default(true),
15
- devOnly: z.boolean().optional(),
16
- icon: IconNameSchema.optional(),
17
- systemIds: z.array(ModelIdSchema.meta({ ref: 'system' })).default([]),
18
- entityIds: z.array(ModelIdSchema.meta({ ref: 'entity' })).default([]),
19
- resourceIds: z.array(ModelIdSchema.meta({ ref: 'resource' })).default([]),
20
- actionIds: z.array(ModelIdSchema.meta({ ref: 'action' })).default([]),
21
- parentId: ModelIdSchema.meta({ ref: 'surface' }).optional()
22
- })
23
-
24
- export const SidebarSurfaceTargetsSchema = z
25
- .object({
26
- systems: z.array(ModelIdSchema.meta({ ref: 'system' })).default([]).optional(),
27
- entities: z.array(ModelIdSchema.meta({ ref: 'entity' })).default([]).optional(),
28
- resources: z.array(ModelIdSchema.meta({ ref: 'resource' })).default([]).optional(),
29
- actions: z.array(ModelIdSchema.meta({ ref: 'action' })).default([]).optional()
30
- })
31
- .default({})
32
-
33
- export interface SidebarSurfaceNode {
34
- type: 'surface'
35
- label: string
36
- path: string
37
- surfaceType: z.infer<typeof SurfaceTypeSchema>
38
- description?: string
39
- icon?: string
40
- order?: number
41
- targets?: {
42
- systems?: string[]
43
- entities?: string[]
44
- resources?: string[]
45
- actions?: string[]
46
- }
47
- devOnly?: boolean
48
- requiresAdmin?: boolean
49
- }
50
-
51
- export interface SidebarGroupNode {
52
- type: 'group'
53
- label: string
54
- description?: string
55
- icon?: string
56
- order?: number
57
- children: Record<string, SidebarNode>
58
- }
59
-
60
- export type SidebarNode = SidebarSurfaceNode | SidebarGroupNode
61
-
62
- export const SidebarNodeSchema: ZodType<SidebarNode> = z.lazy(() =>
63
- z.discriminatedUnion('type', [
64
- z.object({
65
- type: z.literal('group'),
66
- label: LabelSchema,
67
- description: DescriptionSchema.optional(),
68
- icon: IconNameSchema.optional(),
69
- order: z.number().int().optional(),
70
- children: z.record(z.string(), SidebarNodeSchema).default({})
71
- }),
72
- z.object({
73
- type: z.literal('surface'),
74
- label: LabelSchema,
75
- path: PathSchema,
76
- surfaceType: SurfaceTypeSchema,
77
- description: DescriptionSchema.optional(),
78
- icon: IconNameSchema.optional(),
79
- order: z.number().int().optional(),
80
- targets: SidebarSurfaceTargetsSchema.optional(),
81
- devOnly: z.boolean().optional(),
82
- requiresAdmin: z.boolean().optional()
83
- })
84
- ])
85
- )
86
-
87
- export const SidebarSectionSchema = z.record(z.string(), SidebarNodeSchema).default({})
88
-
89
- export const SidebarNavigationSchema = z
90
- .object({
91
- primary: SidebarSectionSchema,
92
- bottom: SidebarSectionSchema
93
- })
94
- .default({ primary: {}, bottom: {} })
95
-
96
- export const OrganizationModelNavigationSchema = z
97
- .object({
98
- sidebar: SidebarNavigationSchema
99
- })
100
- .default({ sidebar: { primary: {}, bottom: {} } })
101
-
102
- export const DEFAULT_ORGANIZATION_MODEL_NAVIGATION: z.infer<typeof OrganizationModelNavigationSchema> = {
103
- sidebar: {
104
- primary: {},
105
- bottom: {}
106
- }
107
- }
108
-
109
- export function getSortedSidebarEntries<TNode extends SidebarNode>(
110
- nodes: Record<string, TNode>
111
- ): Array<[string, TNode]> {
112
- return Object.entries(nodes).sort(([leftId, left], [rightId, right]) => {
113
- const orderDelta = (left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER)
114
- return orderDelta === 0 ? leftId.localeCompare(rightId) : orderDelta
115
- })
116
- }
117
-
118
- /**
119
- * Core placement values: 'primary' | 'secondary' | 'bottom'.
120
- *
121
- * `placement` is intentionally open (`z.string()`) so that per-project adapters can
122
- * introduce additional placement IDs (e.g. 'pinned', 'spotlight', 'contextual') without
123
- * forking core. Extension pattern: add the new string literal to the project's
124
- * `foundations/config/organization-model.ts` adapter — no changes to this file required.
125
- *
126
- * `surfaceType` and `resourceType` remain closed enums (UI/runtime invariants).
127
- */
128
- export const CORE_PLACEMENT_VALUES = ['primary', 'secondary', 'bottom'] as const
129
- export type CorePlacement = (typeof CORE_PLACEMENT_VALUES)[number]
130
-
131
- export const NavigationGroupSchema = z.object({
132
- id: ModelIdSchema,
133
- label: LabelSchema,
134
- placement: z.string().trim().min(1).max(50),
135
- surfaceIds: z.array(ModelIdSchema.meta({ ref: 'surface' })).default([])
136
- })
137
-
138
- // SurfaceDefinitionSchema and NavigationGroupSchema remain for derived DTOs and
139
- // transitional call sites. They are not authored top-level OrganizationModel fields.
1
+ import { z, type ZodType } from 'zod'
2
+ import { DescriptionSchema, IconNameSchema, LabelSchema, ModelIdSchema, PathSchema } from './shared'
3
+
4
+ export const SurfaceTypeSchema = z
5
+ .enum(['page', 'dashboard', 'graph', 'detail', 'list', 'settings'])
6
+ .meta({ label: 'Surface type', color: 'blue' })
7
+
8
+ export const SurfaceDefinitionSchema = z.object({
9
+ id: ModelIdSchema,
10
+ label: LabelSchema,
11
+ path: PathSchema,
12
+ surfaceType: SurfaceTypeSchema,
13
+ description: DescriptionSchema.optional(),
14
+ enabled: z.boolean().default(true),
15
+ devOnly: z.boolean().optional(),
16
+ icon: IconNameSchema.optional(),
17
+ systemIds: z.array(ModelIdSchema.meta({ ref: 'system' })).default([]),
18
+ entityIds: z.array(ModelIdSchema.meta({ ref: 'entity' })).default([]),
19
+ resourceIds: z.array(ModelIdSchema.meta({ ref: 'resource' })).default([]),
20
+ actionIds: z.array(ModelIdSchema.meta({ ref: 'action' })).default([]),
21
+ parentId: ModelIdSchema.meta({ ref: 'surface' }).optional()
22
+ })
23
+
24
+ export const SidebarSurfaceTargetsSchema = z
25
+ .object({
26
+ systems: z
27
+ .array(ModelIdSchema.meta({ ref: 'system' }))
28
+ .default([])
29
+ .optional(),
30
+ entities: z
31
+ .array(ModelIdSchema.meta({ ref: 'entity' }))
32
+ .default([])
33
+ .optional(),
34
+ resources: z
35
+ .array(ModelIdSchema.meta({ ref: 'resource' }))
36
+ .default([])
37
+ .optional(),
38
+ actions: z
39
+ .array(ModelIdSchema.meta({ ref: 'action' }))
40
+ .default([])
41
+ .optional()
42
+ })
43
+ .default({})
44
+
45
+ export interface SidebarSurfaceNode {
46
+ type: 'surface'
47
+ label: string
48
+ path: string
49
+ surfaceType: z.infer<typeof SurfaceTypeSchema>
50
+ description?: string
51
+ icon?: string
52
+ order?: number
53
+ targets?: {
54
+ systems?: string[]
55
+ entities?: string[]
56
+ resources?: string[]
57
+ actions?: string[]
58
+ }
59
+ devOnly?: boolean
60
+ requiresAdmin?: boolean
61
+ }
62
+
63
+ export interface SidebarGroupNode {
64
+ type: 'group'
65
+ label: string
66
+ description?: string
67
+ icon?: string
68
+ order?: number
69
+ children: Record<string, SidebarNode>
70
+ }
71
+
72
+ export type SidebarNode = SidebarSurfaceNode | SidebarGroupNode
73
+
74
+ export const SidebarNodeSchema: ZodType<SidebarNode> = z.lazy(() =>
75
+ z.discriminatedUnion('type', [
76
+ z.object({
77
+ type: z.literal('group'),
78
+ label: LabelSchema,
79
+ description: DescriptionSchema.optional(),
80
+ icon: IconNameSchema.optional(),
81
+ order: z.number().int().optional(),
82
+ children: z.record(z.string(), SidebarNodeSchema).default({})
83
+ }),
84
+ z.object({
85
+ type: z.literal('surface'),
86
+ label: LabelSchema,
87
+ path: PathSchema,
88
+ surfaceType: SurfaceTypeSchema,
89
+ description: DescriptionSchema.optional(),
90
+ icon: IconNameSchema.optional(),
91
+ order: z.number().int().optional(),
92
+ targets: SidebarSurfaceTargetsSchema.optional(),
93
+ devOnly: z.boolean().optional(),
94
+ requiresAdmin: z.boolean().optional()
95
+ })
96
+ ])
97
+ )
98
+
99
+ export const SidebarSectionSchema = z.record(z.string(), SidebarNodeSchema).default({})
100
+
101
+ export const SidebarNavigationSchema = z
102
+ .object({
103
+ primary: SidebarSectionSchema,
104
+ bottom: SidebarSectionSchema
105
+ })
106
+ .default({ primary: {}, bottom: {} })
107
+
108
+ /**
109
+ * A leaf node in `navigation.topbar`. Has no `path`, `surfaceType`, or nesting —
110
+ * topbar actions trigger behavior (open a modal, open docs, etc.) rather than navigate to a route.
111
+ * Behavior is bound by a UI registry (`TopbarActionModule`) that matches on `id`.
112
+ */
113
+ export const TopbarActionNodeSchema = z.object({
114
+ id: ModelIdSchema,
115
+ label: LabelSchema,
116
+ tooltip: DescriptionSchema.optional(),
117
+ icon: IconNameSchema.optional(),
118
+ order: z.number().int().optional(),
119
+ enabled: z.boolean().default(true),
120
+ devOnly: z.boolean().optional(),
121
+ requiresAdmin: z.boolean().optional(),
122
+ targets: SidebarSurfaceTargetsSchema.optional()
123
+ })
124
+
125
+ /**
126
+ * A flat map of topbar action nodes, keyed by action id.
127
+ * Defaults to an empty record (no topbar actions declared).
128
+ */
129
+ export const TopbarSectionSchema = z.record(z.string(), TopbarActionNodeSchema).default({})
130
+
131
+ export const OrganizationModelNavigationSchema = z
132
+ .object({
133
+ sidebar: SidebarNavigationSchema,
134
+ topbar: TopbarSectionSchema
135
+ })
136
+ .default({ sidebar: { primary: {}, bottom: {} }, topbar: {} })
137
+
138
+ export const DEFAULT_ORGANIZATION_MODEL_NAVIGATION: z.infer<typeof OrganizationModelNavigationSchema> = {
139
+ sidebar: {
140
+ primary: {},
141
+ bottom: {}
142
+ },
143
+ topbar: {}
144
+ }
145
+
146
+ export function getSortedSidebarEntries<TNode extends SidebarNode>(
147
+ nodes: Record<string, TNode>
148
+ ): Array<[string, TNode]> {
149
+ return Object.entries(nodes).sort(([leftId, left], [rightId, right]) => {
150
+ const orderDelta = (left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER)
151
+ return orderDelta === 0 ? leftId.localeCompare(rightId) : orderDelta
152
+ })
153
+ }
154
+
155
+ /**
156
+ * Core placement values: 'primary' | 'secondary' | 'bottom'.
157
+ *
158
+ * `placement` is intentionally open (`z.string()`) so that per-project adapters can
159
+ * introduce additional placement IDs (e.g. 'pinned', 'spotlight', 'contextual') without
160
+ * forking core. Extension pattern: add the new string literal to the project's
161
+ * `foundations/config/organization-model.ts` adapter — no changes to this file required.
162
+ *
163
+ * `surfaceType` and `resourceType` remain closed enums (UI/runtime invariants).
164
+ */
165
+ export const CORE_PLACEMENT_VALUES = ['primary', 'secondary', 'bottom'] as const
166
+ export type CorePlacement = (typeof CORE_PLACEMENT_VALUES)[number]
167
+
168
+ export const NavigationGroupSchema = z.object({
169
+ id: ModelIdSchema,
170
+ label: LabelSchema,
171
+ placement: z.string().trim().min(1).max(50),
172
+ surfaceIds: z.array(ModelIdSchema.meta({ ref: 'surface' })).default([])
173
+ })
174
+
175
+ // SurfaceDefinitionSchema and NavigationGroupSchema remain for derived DTOs and
176
+ // transitional call sites. They are not authored top-level OrganizationModel fields.
@@ -54,6 +54,7 @@ export const ORGANIZATION_MODEL_ICON_TOKENS = [
54
54
  'view',
55
55
  'launch',
56
56
  'message',
57
+ 'message-plus',
57
58
  'escalate',
58
59
  'promote',
59
60
  'submit',
@@ -124,7 +124,14 @@ export function getAllBuildTemplates(model: OrganizationModel): BuildTemplate[]
124
124
  entriesOf(catalog).map(([templateId, templateEntry]) => {
125
125
  const stepCatalogId = stringValue(templateEntry.stepCatalog) as OntologyId | undefined
126
126
  const stepCatalog = stepCatalogId !== undefined ? stepCatalogs.get(stepCatalogId) : undefined
127
- const steps = stepCatalog === undefined ? [] : entriesOf(stepCatalog)
127
+ const steps =
128
+ stepCatalog === undefined
129
+ ? []
130
+ : entriesOf(stepCatalog).sort(
131
+ ([leftId, left], [rightId, right]) =>
132
+ numberValue(left.order, Number.MAX_SAFE_INTEGER) -
133
+ numberValue(right.order, Number.MAX_SAFE_INTEGER) || leftId.localeCompare(rightId)
134
+ )
128
135
 
129
136
  return {
130
137
  order: numberValue(templateEntry.order, Number.MAX_SAFE_INTEGER),