@elevasis/core 0.28.0 → 0.30.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 (36) hide show
  1. package/dist/auth/index.d.ts +5289 -0
  2. package/dist/auth/index.js +595 -0
  3. package/dist/index.d.ts +11 -11
  4. package/dist/knowledge/index.d.ts +1 -1
  5. package/dist/organization-model/index.d.ts +11 -11
  6. package/dist/test-utils/index.d.ts +24 -1
  7. package/package.json +7 -3
  8. package/src/__tests__/publish.test.ts +8 -7
  9. package/src/auth/__tests__/access-key-coverage.test.ts +42 -0
  10. package/src/auth/__tests__/access-key-scan.ts +117 -0
  11. package/src/auth/__tests__/access-keys.test.ts +81 -0
  12. package/src/auth/__tests__/access-model.test.ts +257 -0
  13. package/src/auth/__tests__/access-test-fixtures.ts +50 -0
  14. package/src/auth/__tests__/key-catalog-drift.test.ts +33 -0
  15. package/src/auth/__tests__/platform-admin-bypass-parity.test.ts +67 -0
  16. package/src/auth/access-keys.ts +173 -0
  17. package/src/auth/access-model.ts +185 -0
  18. package/src/auth/index.ts +6 -2
  19. package/src/auth/multi-tenancy/memberships/membership.ts +2 -4
  20. package/src/auth/multi-tenancy/permissions.ts +1 -1
  21. package/src/auth/multi-tenancy/types.ts +3 -12
  22. package/src/business/acquisition/api-schemas.test.ts +59 -8
  23. package/src/business/acquisition/api-schemas.ts +10 -5
  24. package/src/business/acquisition/build-templates.test.ts +187 -240
  25. package/src/business/acquisition/build-templates.ts +87 -98
  26. package/src/business/acquisition/types.ts +390 -389
  27. package/src/execution/engine/index.ts +6 -4
  28. package/src/execution/engine/tools/lead-service-types.ts +63 -34
  29. package/src/execution/engine/tools/platform/acquisition/types.ts +7 -8
  30. package/src/execution/engine/tools/registry.ts +6 -4
  31. package/src/execution/engine/tools/tool-maps.ts +23 -1
  32. package/src/organization-model/domains/prospecting.ts +2 -327
  33. package/src/organization-model/migration-helpers.ts +16 -12
  34. package/src/reference/_generated/contracts.md +352 -328
  35. package/src/reference/glossary.md +8 -6
  36. package/src/supabase/database.types.ts +13 -0
@@ -0,0 +1,185 @@
1
+ import {
2
+ DEFAULT_ACCESS_ACTION,
3
+ PLATFORM_ADMIN_ACCESS_KEY,
4
+ deriveAccessKeyCatalog,
5
+ findAccessCatalogEntry,
6
+ normalizeAccessKey,
7
+ type AccessKeyCatalog,
8
+ type AccessKeyInput,
9
+ type NormalizedAccessKey
10
+ } from './access-keys'
11
+ import { getSystem } from '../organization-model/helpers'
12
+ import type { OrganizationModel, OrganizationModelSystemLifecycle } from '../organization-model/types'
13
+
14
+ export type AccessRestrictedBy =
15
+ | 'catalog'
16
+ | 'membership'
17
+ | 'system-lifecycle'
18
+ | 'role-permission'
19
+ | 'diagnostic-allowlist'
20
+ | null
21
+
22
+ export type AccessReason =
23
+ | 'allowed'
24
+ | 'platform-admin-bypass'
25
+ | 'invalid-access-key'
26
+ | 'unknown-access-key'
27
+ | 'organization-mismatch'
28
+ | 'missing-membership'
29
+ | 'system-not-active'
30
+ | 'role-permission-denied'
31
+ | 'diagnostic-key-not-allowed'
32
+
33
+ export interface AccessAnswer {
34
+ allowed: boolean
35
+ restrictedBy: AccessRestrictedBy
36
+ reason: AccessReason
37
+ }
38
+
39
+ export interface AccessContextMembership {
40
+ id?: string
41
+ organizationId: string
42
+ role?: string | null
43
+ effectivePermissions?: readonly string[] | null
44
+ }
45
+
46
+ export interface AccessContextProfile {
47
+ id?: string
48
+ isPlatformAdmin?: boolean | null
49
+ is_platform_admin?: boolean | null
50
+ }
51
+
52
+ export interface AccessContext {
53
+ organizationId: string
54
+ organizationModel: OrganizationModel
55
+ membership?: AccessContextMembership | null
56
+ profile?: AccessContextProfile | null
57
+ permissions?: readonly string[] | null
58
+ diagnosticAllowlist?: ReadonlySet<string> | readonly string[]
59
+ accessCatalog?: AccessKeyCatalog
60
+ betaAccessEnabled?: boolean
61
+ isDevelopment?: boolean
62
+ }
63
+
64
+ export interface AccessModel {
65
+ catalog: AccessKeyCatalog
66
+ checkAccess: (key: AccessKeyInput, ctx: Omit<AccessContext, 'accessCatalog'>) => AccessAnswer
67
+ }
68
+
69
+ const ALLOWED: AccessAnswer = { allowed: true, restrictedBy: null, reason: 'allowed' }
70
+ const PLATFORM_ADMIN_BYPASS: AccessAnswer = {
71
+ allowed: true,
72
+ restrictedBy: null,
73
+ reason: 'platform-admin-bypass'
74
+ }
75
+
76
+ function deny(restrictedBy: Exclude<AccessRestrictedBy, null>, reason: Exclude<AccessReason, 'allowed'>): AccessAnswer {
77
+ return { allowed: false, restrictedBy, reason }
78
+ }
79
+
80
+ function isPlatformAdmin(profile: AccessContextProfile | null | undefined): boolean {
81
+ return profile?.isPlatformAdmin === true || profile?.is_platform_admin === true
82
+ }
83
+
84
+ function diagnosticAllowlistHas(
85
+ allowlist: AccessContext['diagnosticAllowlist'],
86
+ systemPath: string
87
+ ): boolean {
88
+ if (allowlist === undefined) return false
89
+ return 'has' in allowlist ? allowlist.has(systemPath) : allowlist.includes(systemPath)
90
+ }
91
+
92
+ function lifecycleAllowsAccess(
93
+ lifecycle: OrganizationModelSystemLifecycle | undefined,
94
+ ctx: Pick<AccessContext, 'betaAccessEnabled' | 'isDevelopment'>
95
+ ): boolean {
96
+ if (lifecycle === 'active') return true
97
+ if (lifecycle === 'beta') return ctx.betaAccessEnabled === true || ctx.isDevelopment === true
98
+ return false
99
+ }
100
+
101
+ function getPermissionSource(ctx: AccessContext): readonly string[] | null | undefined {
102
+ return ctx.permissions ?? ctx.membership?.effectivePermissions
103
+ }
104
+
105
+ function hasRequiredPermission(key: NormalizedAccessKey, rolePermission: string | undefined, ctx: AccessContext): boolean {
106
+ const permissionSource = getPermissionSource(ctx)
107
+ if (permissionSource === undefined || permissionSource === null) return true
108
+
109
+ const requiredPermission = rolePermission ?? `${key.systemPath}.${key.action}`
110
+ return permissionSource.includes(requiredPermission)
111
+ }
112
+
113
+ function hasExplicitRequiredPermission(rolePermission: string | undefined, ctx: AccessContext): boolean {
114
+ const permissionSource = getPermissionSource(ctx)
115
+ return rolePermission !== undefined && permissionSource !== undefined && permissionSource !== null
116
+ ? permissionSource.includes(rolePermission)
117
+ : false
118
+ }
119
+
120
+ export function checkAccess(input: AccessKeyInput, ctx: AccessContext): AccessAnswer {
121
+ if (isPlatformAdmin(ctx.profile)) return PLATFORM_ADMIN_BYPASS
122
+
123
+ const parsed = (() => {
124
+ try {
125
+ return normalizeAccessKey(input)
126
+ } catch {
127
+ return null
128
+ }
129
+ })()
130
+
131
+ if (parsed === null) return deny('catalog', 'invalid-access-key')
132
+
133
+ const catalog = ctx.accessCatalog ?? deriveAccessKeyCatalog(ctx.organizationModel)
134
+ const catalogEntry = findAccessCatalogEntry(catalog, parsed)
135
+
136
+ if (catalogEntry === undefined) return deny('catalog', 'unknown-access-key')
137
+
138
+ const membership = ctx.membership
139
+ if (membership === undefined || membership === null) return deny('membership', 'missing-membership')
140
+ if (membership.organizationId !== ctx.organizationId) return deny('membership', 'organization-mismatch')
141
+
142
+ if (parsed.systemPath === PLATFORM_ADMIN_ACCESS_KEY) {
143
+ return deny('role-permission', 'role-permission-denied')
144
+ }
145
+
146
+ if (catalogEntry.source === 'diagnostic') {
147
+ if (!diagnosticAllowlistHas(ctx.diagnosticAllowlist, parsed.systemPath)) {
148
+ return deny('diagnostic-allowlist', 'diagnostic-key-not-allowed')
149
+ }
150
+
151
+ if (parsed.action !== DEFAULT_ACCESS_ACTION && !hasRequiredPermission(parsed, catalogEntry.rolePermission, ctx)) {
152
+ return deny('role-permission', 'role-permission-denied')
153
+ }
154
+
155
+ return ALLOWED
156
+ }
157
+
158
+ if (catalogEntry.source === 'permission') {
159
+ if (!hasExplicitRequiredPermission(catalogEntry.rolePermission, ctx)) {
160
+ return deny('role-permission', 'role-permission-denied')
161
+ }
162
+
163
+ return ALLOWED
164
+ }
165
+
166
+ const system = getSystem(ctx.organizationModel, parsed.systemPath)
167
+ if (system === undefined || !lifecycleAllowsAccess(system.lifecycle, ctx)) {
168
+ return deny('system-lifecycle', 'system-not-active')
169
+ }
170
+
171
+ if (parsed.action !== DEFAULT_ACCESS_ACTION && !hasRequiredPermission(parsed, catalogEntry.rolePermission, ctx)) {
172
+ return deny('role-permission', 'role-permission-denied')
173
+ }
174
+
175
+ return ALLOWED
176
+ }
177
+
178
+ export function createAccessModel(organizationModel: OrganizationModel): AccessModel {
179
+ const catalog = deriveAccessKeyCatalog(organizationModel)
180
+
181
+ return {
182
+ catalog,
183
+ checkAccess: (key, ctx) => checkAccess(key, { ...ctx, organizationModel, accessCatalog: catalog })
184
+ }
185
+ }
package/src/auth/index.ts CHANGED
@@ -4,5 +4,9 @@
4
4
  * Identity and multi-tenancy types
5
5
  */
6
6
 
7
- // Multi-tenancy types (browser-safe only)
8
- export * from './multi-tenancy/index'
7
+ // Multi-tenancy types (browser-safe only)
8
+ export * from './multi-tenancy/index'
9
+
10
+ // Unified access model primitives (browser-safe only)
11
+ export * from './access-keys'
12
+ export * from './access-model'
@@ -1,5 +1,4 @@
1
- import type { MembershipFeatureConfig } from '../types'
2
- import { type MembershipStatus } from './api-schemas.js'
1
+ import { type MembershipStatus } from './api-schemas.js'
3
2
 
4
3
  export type { MembershipStatus }
5
4
 
@@ -75,8 +74,7 @@ export interface MembershipWithDetails extends OrganizationMembership {
75
74
  metadata?: Record<string, unknown>
76
75
  config?: Record<string, unknown>
77
76
  }
78
- config?: MembershipFeatureConfig
79
- }
77
+ }
80
78
 
81
79
  /**
82
80
  * UI-specific membership table row data
@@ -4,7 +4,7 @@
4
4
  * Source of truth for the permission keys used by:
5
5
  * - RLS policies in Supabase (via has_org_permission(org_id, key))
6
6
  * - API middleware (via requireOrganizationPermission(key))
7
- * - UI hooks (via useOrganizationPermissions().hasPermission(key))
7
+ * - UI access checks (via useAccess() permission-backed AccessKeys)
8
8
  *
9
9
  * The DB table `org_rol_permissions` mirrors this constant. There is no
10
10
  * runtime reconciler; parity is enforced two ways:
@@ -3,23 +3,14 @@
3
3
  *
4
4
  * Config is stored in dedicated `config` columns (NOT nested in metadata):
5
5
  * - organizations.config: Org-level config (no feature toggles -- all features available by default)
6
- * - org_memberships.config: Per-user-per-org feature overrides
6
+ * - org_memberships.config: Reserved for non-access membership configuration
7
7
  * - users.config: User-global config
8
8
  */
9
9
 
10
10
  import type { ThemePresetName } from './theme-presets'
11
11
 
12
- /**
13
- * Per-user-per-org config (stored in org_memberships.config)
14
- * Controls which features a specific member can access within their org.
15
- * Keys are feature IDs from the organization model (e.g. crm, lead-gen, projects, seo).
16
- */
17
- export interface MembershipFeatureConfig {
18
- features?: Record<string, boolean>
19
- }
20
-
21
- /**
22
- * User-global config (stored in users.config)
12
+ /**
13
+ * User-global config (stored in users.config)
23
14
  * Theme and onboarding are user-specific, NOT org-specific
24
15
  */
25
16
  export interface UserConfig {
@@ -44,6 +44,7 @@ import {
44
44
  ListReadQuerySchema,
45
45
  ListRecordsQuerySchema,
46
46
  ListStatusSchema,
47
+ PipelineConfigSchema,
47
48
  PipelineStageSchema,
48
49
  ScrapingConfigSchema,
49
50
  TransitionDealStateRequestSchema,
@@ -54,7 +55,7 @@ import {
54
55
  UpdateListRequestSchema,
55
56
  UpdateListStatusRequestSchema
56
57
  } from './api-schemas'
57
- import { createBuildPlanSnapshotFromTemplateId } from './build-templates'
58
+ import { createBuildPlanSnapshotFromTemplate } from './build-templates'
58
59
  import {
59
60
  compileBusinessOntologyValidationIndex,
60
61
  CRM_PIPELINE_CATALOG_ONTOLOGY_ID,
@@ -1168,17 +1169,32 @@ describe('PipelineStageSchema', () => {
1168
1169
  // ---------------------------------------------------------------------------
1169
1170
 
1170
1171
  describe('BuildPlanSnapshotSchema', () => {
1171
- const validSnapshot = createBuildPlanSnapshotFromTemplateId('dtc-subscription-apollo-clickup')
1172
+ const validSnapshot = createBuildPlanSnapshotFromTemplate({
1173
+ id: 'tenant-template',
1174
+ label: 'Tenant Template',
1175
+ steps: [
1176
+ {
1177
+ id: 'source-companies',
1178
+ label: 'Companies found',
1179
+ primaryEntity: 'company',
1180
+ outputs: ['company'],
1181
+ stageKey: 'populated',
1182
+ dependencyMode: 'per-record-eligibility',
1183
+ actionKey: 'lead-gen.company.source',
1184
+ defaultBatchSize: 100,
1185
+ maxBatchSize: 250
1186
+ }
1187
+ ]
1188
+ })
1172
1189
 
1173
1190
  it('accepts a snapshot generated from a prospecting build template', () => {
1174
- expect(validSnapshot).not.toBeNull()
1175
1191
  expect(BuildPlanSnapshotSchema.safeParse(validSnapshot).success).toBe(true)
1176
1192
  })
1177
1193
 
1178
1194
  it('accepts custom non-empty step stage keys at the transport boundary', () => {
1179
1195
  const result = BuildPlanSnapshotSchema.safeParse({
1180
1196
  ...validSnapshot,
1181
- steps: [{ ...validSnapshot!.steps[0], stageKey: 'made-up-stage' }]
1197
+ steps: [{ ...validSnapshot.steps[0], stageKey: 'made-up-stage' }]
1182
1198
  })
1183
1199
 
1184
1200
  expect(result.success).toBe(true)
@@ -1187,7 +1203,7 @@ describe('BuildPlanSnapshotSchema', () => {
1187
1203
  it('accepts custom non-empty action keys at the transport boundary', () => {
1188
1204
  const result = BuildPlanSnapshotSchema.safeParse({
1189
1205
  ...validSnapshot,
1190
- steps: [{ ...validSnapshot!.steps[0], actionKey: 'lead-gen.missing.action' }]
1206
+ steps: [{ ...validSnapshot.steps[0], actionKey: 'lead-gen.missing.action' }]
1191
1207
  })
1192
1208
 
1193
1209
  expect(result.success).toBe(true)
@@ -1293,8 +1309,8 @@ describe('CreateListRequestSchema', () => {
1293
1309
  expect(result.success).toBe(true)
1294
1310
  })
1295
1311
 
1296
- it('rejects an unknown prospecting build template id', () => {
1297
- expect(CreateListRequestSchema.safeParse({ name: 'X', buildTemplateId: 'not-a-template' }).success).toBe(false)
1312
+ it('accepts tenant-owned build template ids for API-layer membership validation', () => {
1313
+ expect(CreateListRequestSchema.safeParse({ name: 'X', buildTemplateId: 'not-a-template' }).success).toBe(true)
1298
1314
  })
1299
1315
 
1300
1316
  it('rejects an invalid status', () => {
@@ -1311,10 +1327,20 @@ describe('CreateListRequestSchema', () => {
1311
1327
  name: 'SaaS List',
1312
1328
  scrapingConfig: { vertical: 'SaaS' },
1313
1329
  icp: { minReviewCount: 5 },
1314
- pipelineConfig: { stages: [] }
1330
+ pipelineConfig: { stages: [], dataMode: 'mock' }
1315
1331
  }).success
1316
1332
  ).toBe(true)
1317
1333
  })
1334
+
1335
+ it('accepts create-time list-wide pipeline dataMode', () => {
1336
+ const result = CreateListRequestSchema.safeParse({
1337
+ name: 'DTC Demo',
1338
+ pipelineConfig: { dataMode: 'mock' }
1339
+ })
1340
+
1341
+ expect(result.success).toBe(true)
1342
+ if (result.success) expect(result.data.pipelineConfig?.dataMode).toBe('mock')
1343
+ })
1318
1344
  })
1319
1345
 
1320
1346
  // ---------------------------------------------------------------------------
@@ -1404,11 +1430,36 @@ describe('UpdateListConfigRequestSchema', () => {
1404
1430
  expect(UpdateListConfigRequestSchema.safeParse({ pipelineConfig: { stages: [] } }).success).toBe(true)
1405
1431
  })
1406
1432
 
1433
+ it('accepts a data-mode-only pipelineConfig patch', () => {
1434
+ const result = UpdateListConfigRequestSchema.safeParse({ pipelineConfig: { dataMode: 'live' } })
1435
+
1436
+ expect(result.success).toBe(true)
1437
+ if (result.success) expect(result.data.pipelineConfig?.dataMode).toBe('live')
1438
+ })
1439
+
1440
+ it('rejects invalid pipelineConfig dataMode values', () => {
1441
+ expect(UpdateListConfigRequestSchema.safeParse({ pipelineConfig: { dataMode: 'demo' } }).success).toBe(false)
1442
+ })
1443
+
1407
1444
  it('rejects unknown fields (strict mode)', () => {
1408
1445
  expect(UpdateListConfigRequestSchema.safeParse({ scrapingConfig: {}, bogus: true }).success).toBe(false)
1409
1446
  })
1410
1447
  })
1411
1448
 
1449
+ // ---------------------------------------------------------------------------
1450
+ // PipelineConfigSchema
1451
+ // ---------------------------------------------------------------------------
1452
+
1453
+ describe('PipelineConfigSchema', () => {
1454
+ it.each(['mock', 'live'])('accepts list-wide dataMode "%s"', (dataMode) => {
1455
+ expect(PipelineConfigSchema.safeParse({ dataMode }).success).toBe(true)
1456
+ })
1457
+
1458
+ it('keeps dataMode optional for legacy stored configs', () => {
1459
+ expect(PipelineConfigSchema.safeParse({ stages: [{ key: 'populated' }] }).success).toBe(true)
1460
+ })
1461
+ })
1462
+
1412
1463
  // ---------------------------------------------------------------------------
1413
1464
  // AddCompaniesToListRequestSchema / AddContactsToListRequestSchema
1414
1465
  // ---------------------------------------------------------------------------
@@ -1,7 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { UuidSchema, NonEmptyStringSchema } from '../../platform/utils/validation'
3
3
  import { CredentialRequirementSchema, RecordColumnConfigSchema } from '../../organization-model/domains/prospecting'
4
- import { isProspectingBuildTemplateId } from './build-templates'
5
4
  export { CrmPriorityBucketKeySchema, CrmPriorityBucketOverrideSchema, CrmPriorityOverrideSchema } from './crm-priority'
6
5
  export type { CrmPriorityBucketOverride, CrmPriorityOverride, ResolvedCrmPriorityRuleConfig } from './crm-priority'
7
6
 
@@ -508,12 +507,15 @@ export const PipelineStageSchema = z.object({
508
507
  order: z.number().int().optional()
509
508
  })
510
509
 
510
+ export const DataModeSchema = z.enum(['mock', 'live'])
511
+
511
512
  /**
512
513
  * Pipeline presentation contract stored in `acq_lists.pipeline_config` jsonb.
513
514
  * `stages[].key` validates against the catalog; the rest is presentation only.
514
515
  */
515
516
  export const PipelineConfigSchema = z.object({
516
- stages: z.array(PipelineStageSchema).optional()
517
+ stages: z.array(PipelineStageSchema).optional(),
518
+ dataMode: DataModeSchema.optional()
517
519
  })
518
520
 
519
521
  export const BuildPlanSnapshotStepSchema = z
@@ -584,9 +586,10 @@ export const AcqListMetadataSchema = z
584
586
  })
585
587
  .catchall(z.unknown())
586
588
 
587
- export const ProspectingBuildTemplateIdSchema = z.string().trim().min(1).max(100).refine(isProspectingBuildTemplateId, {
588
- message: 'buildTemplateId must match a known prospecting build template'
589
- })
589
+ // Build-template IDs are tenant-owned OM catalog entries. Published core only
590
+ // validates the transport shape; API/services validate membership against the
591
+ // tenant's resolved Organization Model before creating or changing snapshots.
592
+ export const ProspectingBuildTemplateIdSchema = z.string().trim().min(1).max(100)
590
593
 
591
594
  // ---------------------------------------------------------------------------
592
595
  // List telemetry / progress schemas
@@ -1360,6 +1363,7 @@ export const AcqListSchemas = {
1360
1363
  ScrapingConfig: ScrapingConfigSchema,
1361
1364
  IcpRubric: IcpRubricSchema,
1362
1365
  PipelineConfig: PipelineConfigSchema,
1366
+ DataMode: DataModeSchema,
1363
1367
  PipelineStage: PipelineStageSchema,
1364
1368
  BuildPlanSnapshot: BuildPlanSnapshotSchema,
1365
1369
  BuildPlanSnapshotStep: BuildPlanSnapshotStepSchema,
@@ -1459,6 +1463,7 @@ export type ListStatus = z.infer<typeof ListStatusSchema>
1459
1463
  export type ScrapingConfig = z.infer<typeof ScrapingConfigSchema>
1460
1464
  export type IcpRubric = z.infer<typeof IcpRubricSchema>
1461
1465
  export type PipelineStage = z.infer<typeof PipelineStageSchema>
1466
+ export type DataMode = z.infer<typeof DataModeSchema>
1462
1467
  export type PipelineConfig = z.infer<typeof PipelineConfigSchema>
1463
1468
  export type BuildPlanSnapshotStep = z.infer<typeof BuildPlanSnapshotStepSchema>
1464
1469
  export type BuildPlanSnapshot = z.infer<typeof BuildPlanSnapshotSchema>