@elevasis/core 0.31.0 → 0.33.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.
@@ -17,6 +17,12 @@ const organizationModel = {
17
17
  label: 'CRM',
18
18
  lifecycle: 'active',
19
19
  order: 10
20
+ },
21
+ 'lead-gen': {
22
+ id: 'lead-gen',
23
+ label: 'Lead Gen',
24
+ lifecycle: 'active',
25
+ order: 20
20
26
  }
21
27
  }
22
28
  },
@@ -179,6 +185,31 @@ describe('checkAccess', () => {
179
185
  })
180
186
  })
181
187
 
188
+ it('allows Lead Gen manage only with the explicit OM System permission', () => {
189
+ expect(
190
+ checkAccess(
191
+ AccessKeys.leadGenManage,
192
+ context({
193
+ membership: {
194
+ id: 'membership-1',
195
+ organizationId: 'org-1',
196
+ effectivePermissions: ['sales.lead-gen.manage']
197
+ }
198
+ })
199
+ )
200
+ ).toEqual({
201
+ allowed: true,
202
+ restrictedBy: null,
203
+ reason: 'allowed'
204
+ })
205
+
206
+ expect(checkAccess(AccessKeys.leadGenManage, context())).toEqual({
207
+ allowed: false,
208
+ restrictedBy: 'role-permission',
209
+ reason: 'role-permission-denied'
210
+ })
211
+ })
212
+
182
213
  it('requires explicit role permissions for permission-only access keys', () => {
183
214
  expect(
184
215
  checkAccess(
@@ -54,6 +54,7 @@ export const AccessKeys = {
54
54
  secretsManage: { systemPath: 'permission.secrets', action: 'manage' },
55
55
  operationsRead: { systemPath: 'permission.operations', action: DEFAULT_ACCESS_ACTION },
56
56
  operationsManage: { systemPath: 'permission.operations', action: 'manage' },
57
+ leadGenManage: { systemPath: 'sales.lead-gen', action: 'manage' },
57
58
  acquisitionManage: { systemPath: 'permission.acquisition', action: 'manage' },
58
59
  projectsManage: { systemPath: 'permission.projects', action: 'manage' },
59
60
  clientsManage: { systemPath: 'permission.clients', action: 'manage' },
@@ -32,12 +32,13 @@ export const PERMISSIONS = {
32
32
  MEMBERS_MANAGE: 'members.manage',
33
33
  ROLES_MANAGE: 'roles.manage',
34
34
  SECRETS_MANAGE: 'secrets.manage',
35
- OPERATIONS_READ: 'operations.read',
36
- OPERATIONS_MANAGE: 'operations.manage',
37
- ACQUISITION_MANAGE: 'acquisition.manage',
38
- PROJECTS_MANAGE: 'projects.manage',
39
- CLIENTS_MANAGE: 'clients.manage'
40
- } as const
35
+ OPERATIONS_READ: 'operations.read',
36
+ OPERATIONS_MANAGE: 'operations.manage',
37
+ SALES_LEAD_GEN_MANAGE: 'sales.lead-gen.manage',
38
+ ACQUISITION_MANAGE: 'acquisition.manage',
39
+ PROJECTS_MANAGE: 'projects.manage',
40
+ CLIENTS_MANAGE: 'clients.manage'
41
+ } as const
41
42
 
42
43
  export type PermissionKey = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]
43
44
 
@@ -89,13 +90,18 @@ export const PERMISSION_CATALOG: readonly PermissionDescriptor[] = [
89
90
  description: 'View executions, sessions, schedules, and command queue',
90
91
  isOrgGrantable: true
91
92
  },
92
- {
93
- key: 'operations.manage',
94
- description: 'Run and modify executions, sessions, schedules, queue',
95
- isOrgGrantable: true
96
- },
97
- {
98
- key: 'acquisition.manage',
93
+ {
94
+ key: 'operations.manage',
95
+ description: 'Run and modify executions, sessions, schedules, queue',
96
+ isOrgGrantable: true
97
+ },
98
+ {
99
+ key: 'sales.lead-gen.manage',
100
+ description: 'Operate Lead Gen lists and list-builder workflows',
101
+ isOrgGrantable: true
102
+ },
103
+ {
104
+ key: 'acquisition.manage',
99
105
  description:
100
106
  'Create, update, and delete acquisition records (acq_companies, acq_contacts, acq_deals, acq_lists*, acq_content*, acquisition storage files)',
101
107
  isOrgGrantable: false
@@ -216,45 +216,42 @@ describe.each([
216
216
  idField: 'id' as const,
217
217
  invalidOverride: { kind: 'invalid-kind' }
218
218
  }
219
- ])(
220
- 'define$domain$domain',
221
- ({ domain: _domain, defineSingle, defineMultiple, valid, schema, idField, invalidOverride }) => {
222
- it('defineX — validates and returns a parsed entry', () => {
223
- const result = defineSingle(valid as never)
224
- expect(result).toHaveProperty(idField)
225
- expect((result as Record<string, unknown>)[idField]).toBe((valid as Record<string, unknown>)[idField])
226
- })
219
+ ])('define$domain — $domain', ({ domain: _domain, defineSingle, defineMultiple, valid, idField, invalidOverride }) => {
220
+ it('defineXvalidates and returns a parsed entry', () => {
221
+ const result = defineSingle(valid as never)
222
+ expect(result).toHaveProperty(idField)
223
+ expect((result as Record<string, unknown>)[idField]).toBe((valid as Record<string, unknown>)[idField])
224
+ })
227
225
 
228
- it('defineX — throws ZodError on invalid input', () => {
229
- const bad = { ...valid, ...invalidOverride } as never
230
- expect(() => defineSingle(bad)).toThrow(z.ZodError)
231
- })
226
+ it('defineX — throws ZodError on invalid input', () => {
227
+ const bad = { ...valid, ...invalidOverride } as never
228
+ expect(() => defineSingle(bad)).toThrow(z.ZodError)
229
+ })
232
230
 
233
- it('defineXs — produces an id-keyed map', () => {
234
- const result = defineMultiple([valid] as never[])
235
- const id = (valid as Record<string, unknown>)[idField] as string
236
- expect(result).toHaveProperty(id)
237
- expect((result[id] as Record<string, unknown>)[idField]).toBe(id)
238
- })
231
+ it('defineXs — produces an id-keyed map', () => {
232
+ const result = defineMultiple([valid] as never[])
233
+ const id = (valid as Record<string, unknown>)[idField] as string
234
+ expect(result).toHaveProperty(id)
235
+ expect((result[id] as Record<string, unknown>)[idField]).toBe(id)
236
+ })
239
237
 
240
- it('defineXs — throws ZodError on invalid input', () => {
241
- const bad = { ...valid, ...invalidOverride } as never
242
- expect(() => defineMultiple([bad] as never[])).toThrow(z.ZodError)
243
- })
238
+ it('defineXs — throws ZodError on invalid input', () => {
239
+ const bad = { ...valid, ...invalidOverride } as never
240
+ expect(() => defineMultiple([bad] as never[])).toThrow(z.ZodError)
241
+ })
244
242
 
245
- it('defineXs — returns empty map for empty array', () => {
246
- const result = defineMultiple([] as never[])
247
- expect(result).toEqual({})
248
- })
243
+ it('defineXs — returns empty map for empty array', () => {
244
+ const result = defineMultiple([] as never[])
245
+ expect(result).toEqual({})
246
+ })
249
247
 
250
- it('defineXs — validates through schema (no bypass)', () => {
251
- // Confirm that defineMultiple uses schema.parse, not a pass-through.
252
- // Missing required fields should throw even if the object looks similar.
253
- const missingRequired = { [idField]: (valid as Record<string, unknown>)[idField] } as never
254
- expect(() => defineMultiple([missingRequired] as never[])).toThrow(z.ZodError)
255
- })
256
- }
257
- )
248
+ it('defineXs — validates through schema (no bypass)', () => {
249
+ // Confirm that defineMultiple uses schema.parse, not a pass-through.
250
+ // Missing required fields should throw even if the object looks similar.
251
+ const missingRequired = { [idField]: (valid as Record<string, unknown>)[idField] } as never
252
+ expect(() => defineMultiple([missingRequired] as never[])).toThrow(z.ZodError)
253
+ })
254
+ })
258
255
 
259
256
  // ---------------------------------------------------------------------------
260
257
  // Topology — existing helpers (defineTopology, defineTopologyRelationship)
@@ -0,0 +1,199 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { IdentityDomainSchema } from '../../domains/identity'
3
+ import { resolveOrganizationModel } from '../../resolve'
4
+
5
+ /**
6
+ * Profile-singleton domains (`branding`, `identity`) use `.passthrough()` so that
7
+ * tenant-authored direct properties survive `resolveOrganizationModel` instead of
8
+ * being silently stripped by Zod's default `.strip()`. These tests lock that
9
+ * behavior in and verify the broadened typed branding fields round-trip.
10
+ */
11
+ describe('profile-singleton passthrough extensibility', () => {
12
+ it('keeps an unknown direct property on branding through resolve', () => {
13
+ const model = resolveOrganizationModel({
14
+ branding: {
15
+ organizationName: 'Acme',
16
+ productName: 'AcmeOS',
17
+ shortName: 'Acme',
18
+ // Unknown tenant-authored direct property — must survive passthrough.
19
+ brandVibe: 'warm'
20
+ } as never
21
+ })
22
+
23
+ expect((model.branding as Record<string, unknown>).brandVibe).toBe('warm')
24
+ // Known fields still resolve normally.
25
+ expect(model.branding.organizationName).toBe('Acme')
26
+ })
27
+
28
+ it('keeps an unknown direct property on identity through resolve', () => {
29
+ const model = resolveOrganizationModel({
30
+ identity: {
31
+ // Unknown tenant-authored direct property — must survive passthrough.
32
+ founderNote: 'bootstrapped'
33
+ } as never
34
+ })
35
+
36
+ expect((model.identity as Record<string, unknown>).founderNote).toBe('bootstrapped')
37
+ // Known identity fields still resolve from defaults.
38
+ expect(model.identity.timeZone).toBe('UTC')
39
+ })
40
+
41
+ it('parses and round-trips the broadened typed branding fields', () => {
42
+ const model = resolveOrganizationModel({
43
+ branding: {
44
+ organizationName: 'Acme',
45
+ productName: 'AcmeOS',
46
+ shortName: 'Acme',
47
+ voice: 'Direct and human — no jargon',
48
+ tagline: 'Automation that works while you sleep',
49
+ values: ['Transparency', 'Craftsmanship', 'Velocity'],
50
+ themePresetId: 'midnight'
51
+ }
52
+ })
53
+
54
+ expect(model.branding.voice).toBe('Direct and human — no jargon')
55
+ expect(model.branding.tagline).toBe('Automation that works while you sleep')
56
+ expect(model.branding.values).toEqual(['Transparency', 'Craftsmanship', 'Velocity'])
57
+ expect(model.branding.themePresetId).toBe('midnight')
58
+ })
59
+
60
+ it('leaves the broadened branding fields undefined when not authored', () => {
61
+ const model = resolveOrganizationModel()
62
+
63
+ expect(model.branding.voice).toBeUndefined()
64
+ expect(model.branding.tagline).toBeUndefined()
65
+ expect(model.branding.values).toBeUndefined()
66
+ expect(model.branding.themePresetId).toBeUndefined()
67
+ })
68
+ })
69
+
70
+ /**
71
+ * Names → identity: the four display-name fields (organizationName, productName,
72
+ * shortName, description) are now the recommended placement on `identity`. They are
73
+ * optional so that legacy tenants can continue to rely on `branding.*` fallbacks
74
+ * without breaking. These tests lock the contract: fields round-trip, are truly
75
+ * optional (no default), and branding fields still validate unchanged.
76
+ */
77
+ describe('identity display-name fields — recommended placement (Step 5)', () => {
78
+ it('accepts all four name fields on identity and round-trips them through resolveOrganizationModel', () => {
79
+ const model = resolveOrganizationModel({
80
+ identity: {
81
+ organizationName: 'Acme Corp',
82
+ productName: 'AcmeOS',
83
+ shortName: 'Acme',
84
+ description: 'AI orchestration for modern teams.'
85
+ }
86
+ })
87
+
88
+ expect(model.identity.organizationName).toBe('Acme Corp')
89
+ expect(model.identity.productName).toBe('AcmeOS')
90
+ expect(model.identity.shortName).toBe('Acme')
91
+ expect(model.identity.description).toBe('AI orchestration for modern teams.')
92
+ })
93
+
94
+ it('identity name fields are optional — a model with none set still resolves, and resolved values are undefined', () => {
95
+ const model = resolveOrganizationModel()
96
+
97
+ expect(model.identity.organizationName).toBeUndefined()
98
+ expect(model.identity.productName).toBeUndefined()
99
+ expect(model.identity.shortName).toBeUndefined()
100
+ expect(model.identity.description).toBeUndefined()
101
+ })
102
+
103
+ it('identity name fields parse as undefined through IdentityDomainSchema directly (no defaults injected)', () => {
104
+ const result = IdentityDomainSchema.safeParse({})
105
+ expect(result.success).toBe(true)
106
+ if (result.success) {
107
+ expect(result.data.organizationName).toBeUndefined()
108
+ expect(result.data.productName).toBeUndefined()
109
+ expect(result.data.shortName).toBeUndefined()
110
+ expect(result.data.description).toBeUndefined()
111
+ }
112
+ })
113
+
114
+ it('identity.shortName rejects values exceeding 40 characters', () => {
115
+ expect(() =>
116
+ resolveOrganizationModel({
117
+ identity: { shortName: 'a'.repeat(41) } as never
118
+ })
119
+ ).toThrow()
120
+ })
121
+
122
+ it('identity.organizationName rejects empty string (LabelSchema min 1)', () => {
123
+ expect(() =>
124
+ resolveOrganizationModel({
125
+ identity: { organizationName: '' } as never
126
+ })
127
+ ).toThrow()
128
+ })
129
+
130
+ it('identity.description rejects empty string (DescriptionSchema min 1)', () => {
131
+ expect(() =>
132
+ resolveOrganizationModel({
133
+ identity: { description: '' } as never
134
+ })
135
+ ).toThrow()
136
+ })
137
+
138
+ it('identity name fields trim whitespace', () => {
139
+ const model = resolveOrganizationModel({
140
+ identity: {
141
+ organizationName: ' Acme Corp ',
142
+ shortName: ' Acme '
143
+ } as never
144
+ })
145
+
146
+ expect(model.identity.organizationName).toBe('Acme Corp')
147
+ expect(model.identity.shortName).toBe('Acme')
148
+ })
149
+
150
+ it('branding still validates with its name fields — @deprecated tags change nothing at runtime', () => {
151
+ const model = resolveOrganizationModel({
152
+ branding: {
153
+ organizationName: 'Legacy Org',
154
+ productName: 'LegacyOS',
155
+ shortName: 'LGO',
156
+ description: 'Legacy description for back-compat.'
157
+ }
158
+ })
159
+
160
+ expect(model.branding.organizationName).toBe('Legacy Org')
161
+ expect(model.branding.productName).toBe('LegacyOS')
162
+ expect(model.branding.shortName).toBe('LGO')
163
+ expect(model.branding.description).toBe('Legacy description for back-compat.')
164
+ })
165
+ })
166
+
167
+ /**
168
+ * The broadened branding fields are *typed*, not passthrough — their constraints
169
+ * are part of the published contract. These tests lock those bounds so a future
170
+ * edit can't silently widen/drop them, and confirm `.passthrough()` does NOT
171
+ * bypass validation for known fields (only unknown ones are let through).
172
+ */
173
+ describe('broadened branding typed-field validation', () => {
174
+ it('rejects a voice longer than 280 characters', () => {
175
+ expect(() => resolveOrganizationModel({ branding: { voice: 'a'.repeat(281) } as never })).toThrow()
176
+ })
177
+
178
+ it('rejects a tagline longer than 200 characters', () => {
179
+ expect(() => resolveOrganizationModel({ branding: { tagline: 'a'.repeat(201) } as never })).toThrow()
180
+ })
181
+
182
+ it('rejects an empty-string entry in values (each entry must be non-empty)', () => {
183
+ expect(() => resolveOrganizationModel({ branding: { values: ['Valid', ''] } as never })).toThrow()
184
+ })
185
+
186
+ it('rejects a themePresetId outside the 1–64 character bounds', () => {
187
+ expect(() => resolveOrganizationModel({ branding: { themePresetId: '' } as never })).toThrow()
188
+ expect(() => resolveOrganizationModel({ branding: { themePresetId: 'a'.repeat(65) } as never })).toThrow()
189
+ })
190
+
191
+ it('trims whitespace on string fields', () => {
192
+ const model = resolveOrganizationModel({
193
+ branding: { voice: ' trimmed voice ', tagline: ' trimmed tagline ' } as never
194
+ })
195
+
196
+ expect(model.branding.voice).toBe('trimmed voice')
197
+ expect(model.branding.tagline).toBe('trimmed tagline')
198
+ })
199
+ })
@@ -1,19 +1,61 @@
1
- import { z } from 'zod'
2
- import { DescriptionSchema, LabelSchema } from './shared'
3
-
4
- export const OrganizationModelBrandingSchema = z.object({
5
- organizationName: LabelSchema,
6
- productName: LabelSchema,
7
- shortName: z.string().trim().min(1).max(40),
8
- description: DescriptionSchema.optional(),
9
- logos: z
10
- .object({
11
- light: z.string().trim().min(1).max(2048).optional(),
12
- dark: z.string().trim().min(1).max(2048).optional()
13
- })
14
- .default({})
15
- })
16
-
1
+ import { z } from 'zod'
2
+ import { DescriptionSchema, LabelSchema } from './shared'
3
+
4
+ export const OrganizationModelBrandingSchema = z
5
+ .object({
6
+ /**
7
+ * @deprecated Prefer `identity.organizationName`; branding retains it for back-compat.
8
+ * Legacy tenants that have not set `identity.organizationName` fall back to this field.
9
+ */
10
+ organizationName: LabelSchema,
11
+ /**
12
+ * @deprecated Prefer `identity.productName`; branding retains it for back-compat.
13
+ * Legacy tenants that have not set `identity.productName` fall back to this field.
14
+ */
15
+ productName: LabelSchema,
16
+ /**
17
+ * @deprecated Prefer `identity.shortName`; branding retains it for back-compat.
18
+ * Legacy tenants that have not set `identity.shortName` fall back to this field.
19
+ */
20
+ shortName: z.string().trim().min(1).max(40),
21
+ /**
22
+ * @deprecated Prefer `identity.description`; branding retains it for back-compat.
23
+ * Legacy tenants that have not set `identity.description` fall back to this field.
24
+ */
25
+ description: DescriptionSchema.optional(),
26
+ logos: z
27
+ .object({
28
+ light: z.string().trim().min(1).max(2048).optional(),
29
+ dark: z.string().trim().min(1).max(2048).optional()
30
+ })
31
+ .default({}),
32
+ /**
33
+ * Brand voice — how the organization communicates. Plain-language description
34
+ * of tone, register, and personality (e.g. "Direct and human — no jargon").
35
+ * Max 280 characters.
36
+ */
37
+ voice: z.string().trim().max(280).optional(),
38
+ /**
39
+ * Brand tagline or positioning statement — the memorable one-liner that
40
+ * captures the brand's promise or differentiator. Max 200 characters.
41
+ */
42
+ tagline: z.string().trim().max(200).optional(),
43
+ /**
44
+ * Core brand values — an ordered list of principles the organization stands
45
+ * for (e.g. ["Transparency", "Craftsmanship", "Velocity"]). Each entry must
46
+ * be a non-empty trimmed string.
47
+ */
48
+ values: z.array(z.string().trim().min(1)).optional(),
49
+ /**
50
+ * ID of the active UI theme preset from the UI theme-presets registry.
51
+ * The UI layer resolves this to colors and typography via `usePresetsContext()`.
52
+ * Free-form string — validation and fallback state are handled at the UI layer.
53
+ * Min 1, max 64 characters.
54
+ */
55
+ themePresetId: z.string().trim().min(1).max(64).optional()
56
+ })
57
+ .passthrough()
58
+
17
59
  export const DEFAULT_ORGANIZATION_MODEL_BRANDING: z.infer<typeof OrganizationModelBrandingSchema> = {
18
60
  organizationName: 'Default Organization',
19
61
  productName: 'Organization OS',