@eduardbar/drift 1.2.0 → 1.3.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 (61) hide show
  1. package/.github/workflows/publish-vscode.yml +3 -3
  2. package/.github/workflows/publish.yml +3 -3
  3. package/.github/workflows/review-pr.yml +98 -6
  4. package/AGENTS.md +6 -0
  5. package/README.md +160 -10
  6. package/ROADMAP.md +6 -5
  7. package/dist/analyzer.d.ts +2 -2
  8. package/dist/analyzer.js +420 -159
  9. package/dist/benchmark.d.ts +2 -0
  10. package/dist/benchmark.js +185 -0
  11. package/dist/cli.js +453 -62
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +3 -1
  16. package/dist/plugins.d.ts +2 -1
  17. package/dist/plugins.js +177 -28
  18. package/dist/printer.js +4 -0
  19. package/dist/review.js +2 -2
  20. package/dist/rules/comments.js +2 -2
  21. package/dist/rules/complexity.js +2 -7
  22. package/dist/rules/nesting.js +3 -13
  23. package/dist/rules/phase0-basic.js +10 -10
  24. package/dist/rules/shared.d.ts +2 -0
  25. package/dist/rules/shared.js +27 -3
  26. package/dist/saas.d.ts +143 -7
  27. package/dist/saas.js +478 -37
  28. package/dist/trust-kpi.d.ts +9 -0
  29. package/dist/trust-kpi.js +445 -0
  30. package/dist/trust.d.ts +65 -0
  31. package/dist/trust.js +571 -0
  32. package/dist/types.d.ts +154 -0
  33. package/docs/PRD.md +187 -109
  34. package/docs/plugin-contract.md +61 -0
  35. package/docs/trust-core-release-checklist.md +55 -0
  36. package/package.json +5 -3
  37. package/src/analyzer.ts +484 -155
  38. package/src/benchmark.ts +244 -0
  39. package/src/cli.ts +562 -79
  40. package/src/diff.ts +75 -10
  41. package/src/git.ts +16 -0
  42. package/src/index.ts +48 -0
  43. package/src/plugins.ts +354 -26
  44. package/src/printer.ts +4 -0
  45. package/src/review.ts +2 -2
  46. package/src/rules/comments.ts +2 -2
  47. package/src/rules/complexity.ts +2 -7
  48. package/src/rules/nesting.ts +3 -13
  49. package/src/rules/phase0-basic.ts +11 -12
  50. package/src/rules/shared.ts +31 -3
  51. package/src/saas.ts +641 -43
  52. package/src/trust-kpi.ts +518 -0
  53. package/src/trust.ts +774 -0
  54. package/src/types.ts +171 -0
  55. package/tests/diff.test.ts +124 -0
  56. package/tests/new-features.test.ts +71 -0
  57. package/tests/plugins.test.ts +219 -0
  58. package/tests/rules.test.ts +23 -1
  59. package/tests/saas-foundation.test.ts +358 -1
  60. package/tests/trust-kpi.test.ts +120 -0
  61. package/tests/trust.test.ts +584 -0
package/src/saas.ts CHANGED
@@ -7,16 +7,30 @@ export interface SaasPolicy {
7
7
  maxRunsPerWorkspacePerMonth: number
8
8
  maxReposPerWorkspace: number
9
9
  retentionDays: number
10
+ strictActorEnforcement: boolean
11
+ maxWorkspacesPerOrganizationByPlan: Record<SaasPlan, number>
10
12
  }
11
13
 
14
+ export type SaasRole = 'owner' | 'member' | 'viewer'
15
+ export type SaasPlan = 'free' | 'sponsor' | 'team' | 'business'
16
+
12
17
  export interface SaasUser {
13
18
  id: string
14
19
  createdAt: string
15
20
  lastSeenAt: string
16
21
  }
17
22
 
23
+ export interface SaasOrganization {
24
+ id: string
25
+ plan: SaasPlan
26
+ createdAt: string
27
+ lastSeenAt: string
28
+ workspaceIds: string[]
29
+ }
30
+
18
31
  export interface SaasWorkspace {
19
32
  id: string
33
+ organizationId: string
20
34
  createdAt: string
21
35
  lastSeenAt: string
22
36
  userIds: string[]
@@ -25,18 +39,42 @@ export interface SaasWorkspace {
25
39
 
26
40
  export interface SaasRepo {
27
41
  id: string
42
+ organizationId: string
28
43
  workspaceId: string
29
44
  name: string
30
45
  createdAt: string
31
46
  lastSeenAt: string
32
47
  }
33
48
 
49
+ export interface SaasMembership {
50
+ id: string
51
+ organizationId: string
52
+ workspaceId: string
53
+ userId: string
54
+ role: SaasRole
55
+ createdAt: string
56
+ lastSeenAt: string
57
+ }
58
+
59
+ export interface SaasPlanChange {
60
+ id: string
61
+ organizationId: string
62
+ fromPlan: SaasPlan
63
+ toPlan: SaasPlan
64
+ changedAt: string
65
+ changedByUserId: string
66
+ reason?: string
67
+ }
68
+
34
69
  export interface SaasSnapshot {
35
70
  id: string
36
71
  createdAt: string
37
72
  scannedAt: string
73
+ organizationId: string
38
74
  workspaceId: string
39
75
  userId: string
76
+ role: SaasRole
77
+ plan: SaasPlan
40
78
  repoId: string
41
79
  repoName: string
42
80
  targetPath: string
@@ -54,9 +92,68 @@ export interface SaasStore {
54
92
  version: number
55
93
  policy: SaasPolicy
56
94
  users: Record<string, SaasUser>
95
+ organizations: Record<string, SaasOrganization>
57
96
  workspaces: Record<string, SaasWorkspace>
97
+ memberships: Record<string, SaasMembership>
58
98
  repos: Record<string, SaasRepo>
59
99
  snapshots: SaasSnapshot[]
100
+ planChanges: SaasPlanChange[]
101
+ }
102
+
103
+ export type SaasOperation = 'snapshot:write' | 'snapshot:read' | 'summary:read' | 'billing:write' | 'billing:read'
104
+
105
+ export interface SaasPermissionContext {
106
+ operation: SaasOperation
107
+ organizationId: string
108
+ workspaceId?: string
109
+ actorUserId?: string
110
+ }
111
+
112
+ export interface SaasPermissionResult {
113
+ actorRole?: SaasRole
114
+ requiredRole: SaasRole
115
+ }
116
+
117
+ export interface SaasEffectiveLimits {
118
+ plan: SaasPlan
119
+ maxWorkspaces: number
120
+ maxReposPerWorkspace: number
121
+ maxRunsPerWorkspacePerMonth: number
122
+ retentionDays: number
123
+ }
124
+
125
+ export interface SaasOrganizationUsageSnapshot {
126
+ organizationId: string
127
+ plan: SaasPlan
128
+ capturedAt: string
129
+ workspaceCount: number
130
+ repoCount: number
131
+ runCount: number
132
+ runCountThisMonth: number
133
+ }
134
+
135
+ export interface ChangeOrganizationPlanOptions {
136
+ organizationId: string
137
+ actorUserId: string
138
+ newPlan: SaasPlan
139
+ reason?: string
140
+ storeFile?: string
141
+ policy?: SaasPolicyOverrides
142
+ }
143
+
144
+ export interface SaasUsageQueryOptions {
145
+ organizationId: string
146
+ month?: string
147
+ storeFile?: string
148
+ policy?: SaasPolicyOverrides
149
+ actorUserId?: string
150
+ }
151
+
152
+ export interface SaasPlanChangeQueryOptions {
153
+ organizationId: string
154
+ storeFile?: string
155
+ policy?: SaasPolicyOverrides
156
+ actorUserId?: string
60
157
  }
61
158
 
62
159
  export interface SaasSummary {
@@ -71,28 +168,118 @@ export interface SaasSummary {
71
168
  freeUsersRemaining: number
72
169
  }
73
170
 
171
+ export interface SaasPolicyOverrides {
172
+ freeUserThreshold?: number
173
+ maxRunsPerWorkspacePerMonth?: number
174
+ maxReposPerWorkspace?: number
175
+ retentionDays?: number
176
+ strictActorEnforcement?: boolean
177
+ maxWorkspacesPerOrganizationByPlan?: Partial<Record<SaasPlan, number>>
178
+ }
179
+
180
+ export interface SaasQueryOptions {
181
+ storeFile?: string
182
+ policy?: SaasPolicyOverrides
183
+ organizationId?: string
184
+ workspaceId?: string
185
+ actorUserId?: string
186
+ }
187
+
74
188
  export interface IngestOptions {
189
+ organizationId?: string
75
190
  workspaceId: string
76
191
  userId: string
192
+ role?: SaasRole
193
+ plan?: SaasPlan
77
194
  repoName?: string
195
+ actorUserId?: string
78
196
  storeFile?: string
79
- policy?: Partial<SaasPolicy>
197
+ policy?: SaasPolicyOverrides
80
198
  }
81
199
 
82
- const STORE_VERSION = 1
200
+ const STORE_VERSION = 3
83
201
  const ACTIVE_WINDOW_DAYS = 30
202
+ const DEFAULT_ORGANIZATION_ID = 'default-org'
203
+ const VALID_ROLES: SaasRole[] = ['owner', 'member', 'viewer']
204
+ const VALID_PLANS: SaasPlan[] = ['free', 'sponsor', 'team', 'business']
205
+ const ROLE_PRIORITY: Record<SaasRole, number> = { viewer: 1, member: 2, owner: 3 }
206
+ const REQUIRED_ROLE_BY_OPERATION: Record<SaasOperation, SaasRole> = {
207
+ 'snapshot:write': 'member',
208
+ 'snapshot:read': 'viewer',
209
+ 'summary:read': 'viewer',
210
+ 'billing:write': 'owner',
211
+ 'billing:read': 'viewer',
212
+ }
213
+
214
+ export class SaasPermissionError extends Error {
215
+ readonly code = 'SAAS_PERMISSION_DENIED'
216
+ readonly operation: SaasOperation
217
+ readonly organizationId: string
218
+ readonly workspaceId?: string
219
+ readonly actorUserId?: string
220
+ readonly requiredRole: SaasRole
221
+ readonly actorRole?: SaasRole
222
+
223
+ constructor(context: SaasPermissionContext, requiredRole: SaasRole, actorRole?: SaasRole) {
224
+ const actor = context.actorUserId ?? 'unknown-actor'
225
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : ''
226
+ const actualRole = actorRole ?? 'none'
227
+ super(
228
+ `Permission denied for operation '${context.operation}'. actor='${actor}' organization='${context.organizationId}'${workspaceSuffix} requiredRole='${requiredRole}' actualRole='${actualRole}'.`,
229
+ )
230
+ this.name = 'SaasPermissionError'
231
+ this.operation = context.operation
232
+ this.organizationId = context.organizationId
233
+ this.workspaceId = context.workspaceId
234
+ this.actorUserId = context.actorUserId
235
+ this.requiredRole = requiredRole
236
+ this.actorRole = actorRole
237
+ }
238
+ }
239
+
240
+ export class SaasActorRequiredError extends Error {
241
+ readonly code = 'SAAS_ACTOR_REQUIRED'
242
+ readonly operation: SaasOperation
243
+ readonly organizationId: string
244
+ readonly workspaceId?: string
245
+
246
+ constructor(context: SaasPermissionContext) {
247
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : ''
248
+ super(
249
+ `Actor is required for operation '${context.operation}'. organization='${context.organizationId}'${workspaceSuffix}.`,
250
+ )
251
+ this.name = 'SaasActorRequiredError'
252
+ this.operation = context.operation
253
+ this.organizationId = context.organizationId
254
+ this.workspaceId = context.workspaceId
255
+ }
256
+ }
84
257
 
85
258
  export const DEFAULT_SAAS_POLICY: SaasPolicy = {
86
259
  freeUserThreshold: 7500,
87
260
  maxRunsPerWorkspacePerMonth: 500,
88
261
  maxReposPerWorkspace: 20,
89
262
  retentionDays: 90,
263
+ strictActorEnforcement: false,
264
+ maxWorkspacesPerOrganizationByPlan: {
265
+ free: 20,
266
+ sponsor: 50,
267
+ team: 200,
268
+ business: 1000,
269
+ },
90
270
  }
91
271
 
92
- export function resolveSaasPolicy(policy?: Partial<SaasPolicy> | DriftConfig['saas']): SaasPolicy {
272
+ export function resolveSaasPolicy(policy?: SaasPolicyOverrides | DriftConfig['saas']): SaasPolicy {
273
+ const customPlanLimits = (policy && 'maxWorkspacesPerOrganizationByPlan' in policy)
274
+ ? (policy.maxWorkspacesPerOrganizationByPlan ?? {})
275
+ : {}
93
276
  return {
94
277
  ...DEFAULT_SAAS_POLICY,
95
278
  ...(policy ?? {}),
279
+ maxWorkspacesPerOrganizationByPlan: {
280
+ ...DEFAULT_SAAS_POLICY.maxWorkspacesPerOrganizationByPlan,
281
+ ...customPlanLimits,
282
+ },
96
283
  }
97
284
  }
98
285
 
@@ -100,7 +287,7 @@ export function defaultSaasStorePath(root = '.'): string {
100
287
  return resolve(root, '.drift-cloud', 'store.json')
101
288
  }
102
289
 
103
- function ensureStoreFile(storeFile: string, policy?: Partial<SaasPolicy>): void {
290
+ function ensureStoreFile(storeFile: string, policy?: SaasPolicyOverrides): void {
104
291
  const dir = dirname(storeFile)
105
292
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
106
293
  if (!existsSync(storeFile)) {
@@ -109,17 +296,110 @@ function ensureStoreFile(storeFile: string, policy?: Partial<SaasPolicy>): void
109
296
  }
110
297
  }
111
298
 
112
- function createEmptyStore(policy?: Partial<SaasPolicy>): SaasStore {
299
+ function createEmptyStore(policy?: SaasPolicyOverrides): SaasStore {
113
300
  return {
114
301
  version: STORE_VERSION,
115
302
  policy: resolveSaasPolicy(policy),
116
303
  users: {},
304
+ organizations: {},
117
305
  workspaces: {},
306
+ memberships: {},
118
307
  repos: {},
119
308
  snapshots: [],
309
+ planChanges: [],
310
+ }
311
+ }
312
+
313
+ function normalizePlan(plan?: string): SaasPlan {
314
+ if (!plan) return 'free'
315
+ return VALID_PLANS.includes(plan as SaasPlan) ? (plan as SaasPlan) : 'free'
316
+ }
317
+
318
+ function normalizeRole(role?: string): SaasRole {
319
+ if (!role) return 'member'
320
+ return VALID_ROLES.includes(role as SaasRole) ? (role as SaasRole) : 'member'
321
+ }
322
+
323
+ function hasRoleAtLeast(role: SaasRole | undefined, requiredRole: SaasRole): boolean {
324
+ if (!role) return false
325
+ return ROLE_PRIORITY[role] >= ROLE_PRIORITY[requiredRole]
326
+ }
327
+
328
+ function resolveActorRole(store: SaasStore, organizationId: string, actorUserId: string, workspaceId?: string): SaasRole | undefined {
329
+ if (workspaceId) {
330
+ const scopedMembershipId = membershipKey(organizationId, workspaceId, actorUserId)
331
+ return store.memberships[scopedMembershipId]?.role
332
+ }
333
+
334
+ let highestRole: SaasRole | undefined
335
+ for (const membership of Object.values(store.memberships)) {
336
+ if (membership.organizationId !== organizationId) continue
337
+ if (membership.userId !== actorUserId) continue
338
+ if (!highestRole || ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highestRole]) {
339
+ highestRole = membership.role
340
+ if (highestRole === 'owner') break
341
+ }
342
+ }
343
+ return highestRole
344
+ }
345
+
346
+ function assertPermissionInStore(store: SaasStore, context: SaasPermissionContext): SaasPermissionResult {
347
+ const requiredRole = REQUIRED_ROLE_BY_OPERATION[context.operation]
348
+ if (!context.actorUserId) {
349
+ if (store.policy.strictActorEnforcement) {
350
+ throw new SaasActorRequiredError(context)
351
+ }
352
+ return { requiredRole }
353
+ }
354
+
355
+ const actorRole = resolveActorRole(store, context.organizationId, context.actorUserId, context.workspaceId)
356
+ if (!hasRoleAtLeast(actorRole, requiredRole)) {
357
+ throw new SaasPermissionError(context, requiredRole, actorRole)
358
+ }
359
+
360
+ return { requiredRole, actorRole }
361
+ }
362
+
363
+ export function getRequiredRoleForOperation(operation: SaasOperation): SaasRole {
364
+ return REQUIRED_ROLE_BY_OPERATION[operation]
365
+ }
366
+
367
+ export function assertSaasPermission(context: SaasPermissionContext & { storeFile?: string; policy?: SaasPolicyOverrides }): SaasPermissionResult {
368
+ const storeFile = resolve(context.storeFile ?? defaultSaasStorePath())
369
+ const store = loadStoreInternal(storeFile, context.policy)
370
+ return assertPermissionInStore(store, context)
371
+ }
372
+
373
+ export function getSaasEffectiveLimits(input: { plan: SaasPlan; policy?: SaasPolicyOverrides }): SaasEffectiveLimits {
374
+ const policy = resolveSaasPolicy(input.policy)
375
+ return {
376
+ plan: input.plan,
377
+ maxWorkspaces: policy.maxWorkspacesPerOrganizationByPlan[input.plan],
378
+ maxReposPerWorkspace: policy.maxReposPerWorkspace,
379
+ maxRunsPerWorkspacePerMonth: policy.maxRunsPerWorkspacePerMonth,
380
+ retentionDays: policy.retentionDays,
120
381
  }
121
382
  }
122
383
 
384
+ export function getOrganizationEffectiveLimits(options: { organizationId: string; storeFile?: string; policy?: SaasPolicyOverrides }): SaasEffectiveLimits {
385
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
386
+ const store = loadStoreInternal(storeFile, options.policy)
387
+ const plan = normalizePlan(store.organizations[options.organizationId]?.plan)
388
+ return getSaasEffectiveLimits({ plan, policy: store.policy })
389
+ }
390
+
391
+ function workspaceKey(organizationId: string, workspaceId: string): string {
392
+ return `${organizationId}:${workspaceId}`
393
+ }
394
+
395
+ function repoKey(organizationId: string, workspaceId: string, repoName: string): string {
396
+ return `${workspaceKey(organizationId, workspaceId)}:${repoName}`
397
+ }
398
+
399
+ function membershipKey(organizationId: string, workspaceId: string, userId: string): string {
400
+ return `${workspaceKey(organizationId, workspaceId)}:${userId}`
401
+ }
402
+
123
403
  function monthKey(isoDate: string): string {
124
404
  const date = new Date(isoDate)
125
405
  const month = String(date.getUTCMonth() + 1).padStart(2, '0')
@@ -142,7 +422,7 @@ function saveStore(storeFile: string, store: SaasStore): void {
142
422
  writeFileSync(storeFile, JSON.stringify(store, null, 2), 'utf8')
143
423
  }
144
424
 
145
- function loadStoreInternal(storeFile: string, policy?: Partial<SaasPolicy>): SaasStore {
425
+ function loadStoreInternal(storeFile: string, policy?: SaasPolicyOverrides): SaasStore {
146
426
  ensureStoreFile(storeFile, policy)
147
427
  const raw = readFileSync(storeFile, 'utf8')
148
428
  const parsed = JSON.parse(raw) as Partial<SaasStore>
@@ -150,10 +430,43 @@ function loadStoreInternal(storeFile: string, policy?: Partial<SaasPolicy>): Saa
150
430
  const merged = createEmptyStore(parsed.policy)
151
431
  merged.version = parsed.version ?? STORE_VERSION
152
432
  merged.users = parsed.users ?? {}
433
+ merged.organizations = parsed.organizations ?? {}
153
434
  merged.workspaces = parsed.workspaces ?? {}
435
+ merged.memberships = parsed.memberships ?? {}
154
436
  merged.repos = parsed.repos ?? {}
155
437
  merged.snapshots = parsed.snapshots ?? []
438
+ merged.planChanges = parsed.planChanges ?? []
156
439
  merged.policy = resolveSaasPolicy({ ...merged.policy, ...policy })
440
+
441
+ for (const workspace of Object.values(merged.workspaces)) {
442
+ if (!workspace.organizationId) workspace.organizationId = DEFAULT_ORGANIZATION_ID
443
+ }
444
+ for (const repo of Object.values(merged.repos)) {
445
+ if (!repo.organizationId) repo.organizationId = DEFAULT_ORGANIZATION_ID
446
+ }
447
+ for (const snapshot of merged.snapshots) {
448
+ if (!snapshot.organizationId) snapshot.organizationId = DEFAULT_ORGANIZATION_ID
449
+ if (!snapshot.plan) snapshot.plan = 'free'
450
+ if (!snapshot.role) snapshot.role = 'member'
451
+ }
452
+
453
+ for (const workspace of Object.values(merged.workspaces)) {
454
+ const orgId = workspace.organizationId
455
+ const existingOrg = merged.organizations[orgId]
456
+ if (!existingOrg) {
457
+ merged.organizations[orgId] = {
458
+ id: orgId,
459
+ plan: 'free',
460
+ createdAt: workspace.createdAt,
461
+ lastSeenAt: workspace.lastSeenAt,
462
+ workspaceIds: [workspace.id],
463
+ }
464
+ continue
465
+ }
466
+ if (!existingOrg.workspaceIds.includes(workspace.id)) existingOrg.workspaceIds.push(workspace.id)
467
+ if (workspace.lastSeenAt > existingOrg.lastSeenAt) existingOrg.lastSeenAt = workspace.lastSeenAt
468
+ }
469
+
157
470
  applyRetention(merged)
158
471
 
159
472
  return merged
@@ -167,7 +480,36 @@ function isRepoActive(repo: SaasRepo): boolean {
167
480
  return new Date(repo.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS)
168
481
  }
169
482
 
483
+ function resolveScopedIdentity(options: IngestOptions): {
484
+ organizationId: string
485
+ workspaceId: string
486
+ workspaceKey: string
487
+ repoName: string
488
+ repoId: string
489
+ } {
490
+ const organizationId = options.organizationId ?? DEFAULT_ORGANIZATION_ID
491
+ const workspaceId = options.workspaceId
492
+ const repoName = options.repoName ?? 'default'
493
+ return {
494
+ organizationId,
495
+ workspaceId,
496
+ workspaceKey: workspaceKey(organizationId, workspaceId),
497
+ repoName,
498
+ repoId: repoKey(organizationId, workspaceId, repoName),
499
+ }
500
+ }
501
+
170
502
  function assertGuardrails(store: SaasStore, options: IngestOptions, nowIso: string): void {
503
+ const scoped = resolveScopedIdentity(options)
504
+ const organization = store.organizations[scoped.organizationId]
505
+ const effectivePlan = normalizePlan(options.plan ?? organization?.plan)
506
+ const workspaceLimit = store.policy.maxWorkspacesPerOrganizationByPlan[effectivePlan]
507
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey])
508
+ const workspaceCount = organization?.workspaceIds.length ?? 0
509
+ if (!workspaceExists && workspaceCount >= workspaceLimit) {
510
+ throw new Error(`Organization '${scoped.organizationId}' on plan '${effectivePlan}' reached max workspaces (${workspaceLimit}).`)
511
+ }
512
+
171
513
  const usersRegistered = Object.keys(store.users).length
172
514
  const isFreePhase = usersRegistered < store.policy.freeUserThreshold
173
515
  if (!isFreePhase) return
@@ -176,23 +518,134 @@ function assertGuardrails(store: SaasStore, options: IngestOptions, nowIso: stri
176
518
  throw new Error(`Free threshold reached (${store.policy.freeUserThreshold} users).`)
177
519
  }
178
520
 
179
- const workspace = store.workspaces[options.workspaceId]
180
- const repoName = options.repoName ?? 'default'
181
- const repoId = `${options.workspaceId}:${repoName}`
182
- const repoExists = Boolean(store.repos[repoId])
521
+ const workspace = store.workspaces[scoped.workspaceKey]
522
+ const repoExists = Boolean(store.repos[scoped.repoId])
183
523
  const repoCount = workspace?.repoIds.length ?? 0
184
524
 
185
525
  if (!repoExists && repoCount >= store.policy.maxReposPerWorkspace) {
186
- throw new Error(`Workspace '${options.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`)
526
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`)
187
527
  }
188
528
 
189
529
  const currentMonth = monthKey(nowIso)
190
530
  const runsThisMonth = store.snapshots.filter((snapshot) => {
191
- return snapshot.workspaceId === options.workspaceId && monthKey(snapshot.createdAt) === currentMonth
531
+ return (
532
+ snapshot.organizationId === scoped.organizationId
533
+ && snapshot.workspaceId === scoped.workspaceId
534
+ && monthKey(snapshot.createdAt) === currentMonth
535
+ )
192
536
  }).length
193
537
 
194
538
  if (runsThisMonth >= store.policy.maxRunsPerWorkspacePerMonth) {
195
- throw new Error(`Workspace '${options.workspaceId}' reached max monthly runs (${store.policy.maxRunsPerWorkspacePerMonth}).`)
539
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max monthly runs (${store.policy.maxRunsPerWorkspacePerMonth}).`)
540
+ }
541
+ }
542
+
543
+ function appendPlanChange(
544
+ store: SaasStore,
545
+ input: { organizationId: string; fromPlan: SaasPlan; toPlan: SaasPlan; changedByUserId: string; reason?: string; changedAt: string },
546
+ ): SaasPlanChange {
547
+ const change: SaasPlanChange = {
548
+ id: `${input.changedAt}-${Math.random().toString(16).slice(2, 10)}`,
549
+ organizationId: input.organizationId,
550
+ fromPlan: input.fromPlan,
551
+ toPlan: input.toPlan,
552
+ changedAt: input.changedAt,
553
+ changedByUserId: input.changedByUserId,
554
+ reason: input.reason,
555
+ }
556
+ store.planChanges.push(change)
557
+ return change
558
+ }
559
+
560
+ export function changeOrganizationPlan(options: ChangeOrganizationPlanOptions): SaasPlanChange {
561
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
562
+ const store = loadStoreInternal(storeFile, options.policy)
563
+ const nowIso = new Date().toISOString()
564
+
565
+ const organization = store.organizations[options.organizationId]
566
+ if (!organization) {
567
+ throw new Error(`Organization '${options.organizationId}' does not exist.`)
568
+ }
569
+
570
+ assertPermissionInStore(store, {
571
+ operation: 'billing:write',
572
+ organizationId: options.organizationId,
573
+ actorUserId: options.actorUserId,
574
+ })
575
+
576
+ const nextPlan = normalizePlan(options.newPlan)
577
+ if (organization.plan === nextPlan) {
578
+ const unchanged = appendPlanChange(store, {
579
+ organizationId: organization.id,
580
+ fromPlan: organization.plan,
581
+ toPlan: nextPlan,
582
+ changedAt: nowIso,
583
+ changedByUserId: options.actorUserId,
584
+ reason: options.reason,
585
+ })
586
+ saveStore(storeFile, store)
587
+ return unchanged
588
+ }
589
+
590
+ const previousPlan = organization.plan
591
+ organization.plan = nextPlan
592
+ organization.lastSeenAt = nowIso
593
+ const change = appendPlanChange(store, {
594
+ organizationId: organization.id,
595
+ fromPlan: previousPlan,
596
+ toPlan: nextPlan,
597
+ changedAt: nowIso,
598
+ changedByUserId: options.actorUserId,
599
+ reason: options.reason,
600
+ })
601
+ saveStore(storeFile, store)
602
+ return change
603
+ }
604
+
605
+ export function listOrganizationPlanChanges(options: SaasPlanChangeQueryOptions): SaasPlanChange[] {
606
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
607
+ const store = loadStoreInternal(storeFile, options.policy)
608
+
609
+ assertPermissionInStore(store, {
610
+ operation: 'billing:read',
611
+ organizationId: options.organizationId,
612
+ actorUserId: options.actorUserId,
613
+ })
614
+
615
+ return store.planChanges
616
+ .filter((change) => change.organizationId === options.organizationId)
617
+ .sort((a, b) => b.changedAt.localeCompare(a.changedAt))
618
+ }
619
+
620
+ export function getOrganizationUsageSnapshot(options: SaasUsageQueryOptions): SaasOrganizationUsageSnapshot {
621
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
622
+ const store = loadStoreInternal(storeFile, options.policy)
623
+
624
+ assertPermissionInStore(store, {
625
+ operation: 'billing:read',
626
+ organizationId: options.organizationId,
627
+ actorUserId: options.actorUserId,
628
+ })
629
+
630
+ const organization = store.organizations[options.organizationId]
631
+ if (!organization) {
632
+ throw new Error(`Organization '${options.organizationId}' does not exist.`)
633
+ }
634
+
635
+ const month = options.month ?? monthKey(new Date().toISOString())
636
+ const organizationRunSnapshots = store.snapshots.filter((snapshot) => snapshot.organizationId === options.organizationId)
637
+
638
+ return {
639
+ organizationId: options.organizationId,
640
+ plan: organization.plan,
641
+ capturedAt: new Date().toISOString(),
642
+ workspaceCount: organization.workspaceIds.length,
643
+ repoCount: organization.workspaceIds
644
+ .map((workspaceId) => store.workspaces[workspaceKey(options.organizationId, workspaceId)])
645
+ .filter((workspace): workspace is SaasWorkspace => Boolean(workspace))
646
+ .reduce((count, workspace) => count + workspace.repoIds.length, 0),
647
+ runCount: organizationRunSnapshots.length,
648
+ runCountThisMonth: organizationRunSnapshots.filter((snapshot) => monthKey(snapshot.createdAt) === month).length,
196
649
  }
197
650
  }
198
651
 
@@ -200,6 +653,35 @@ export function ingestSnapshotFromReport(report: DriftReport, options: IngestOpt
200
653
  const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
201
654
  const store = loadStoreInternal(storeFile, options.policy)
202
655
  const nowIso = new Date().toISOString()
656
+ const scoped = resolveScopedIdentity(options)
657
+ const requestedPlan = normalizePlan(options.plan)
658
+
659
+ if (store.policy.strictActorEnforcement && !options.actorUserId) {
660
+ throw new SaasActorRequiredError({
661
+ operation: 'snapshot:write',
662
+ organizationId: scoped.organizationId,
663
+ workspaceId: scoped.workspaceId,
664
+ })
665
+ }
666
+
667
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey])
668
+ const organizationExists = Boolean(store.organizations[scoped.organizationId])
669
+ if (options.actorUserId) {
670
+ if (workspaceExists) {
671
+ assertPermissionInStore(store, {
672
+ operation: 'snapshot:write',
673
+ organizationId: scoped.organizationId,
674
+ workspaceId: scoped.workspaceId,
675
+ actorUserId: options.actorUserId,
676
+ })
677
+ } else if (organizationExists) {
678
+ assertPermissionInStore(store, {
679
+ operation: 'billing:write',
680
+ organizationId: scoped.organizationId,
681
+ actorUserId: options.actorUserId,
682
+ })
683
+ }
684
+ }
203
685
 
204
686
  assertGuardrails(store, options, nowIso)
205
687
 
@@ -214,45 +696,104 @@ export function ingestSnapshotFromReport(report: DriftReport, options: IngestOpt
214
696
  }
215
697
  }
216
698
 
217
- const workspace = store.workspaces[options.workspaceId]
699
+ const existingOrg = store.organizations[scoped.organizationId]
700
+ const plan = normalizePlan(existingOrg?.plan ?? requestedPlan)
701
+
702
+ if (existingOrg) {
703
+ existingOrg.lastSeenAt = nowIso
704
+ if (options.plan && existingOrg.plan !== requestedPlan) {
705
+ if (options.actorUserId) {
706
+ assertPermissionInStore(store, {
707
+ operation: 'billing:write',
708
+ organizationId: scoped.organizationId,
709
+ actorUserId: options.actorUserId,
710
+ })
711
+ }
712
+ const previousPlan = existingOrg.plan
713
+ existingOrg.plan = requestedPlan
714
+ appendPlanChange(store, {
715
+ organizationId: scoped.organizationId,
716
+ fromPlan: previousPlan,
717
+ toPlan: requestedPlan,
718
+ changedAt: nowIso,
719
+ changedByUserId: options.actorUserId ?? options.userId,
720
+ reason: 'ingest-option-plan-change',
721
+ })
722
+ }
723
+ } else {
724
+ store.organizations[scoped.organizationId] = {
725
+ id: scoped.organizationId,
726
+ plan,
727
+ createdAt: nowIso,
728
+ lastSeenAt: nowIso,
729
+ workspaceIds: [],
730
+ }
731
+ }
732
+
733
+ const workspace = store.workspaces[scoped.workspaceKey]
218
734
  if (workspace) {
219
735
  workspace.lastSeenAt = nowIso
220
736
  if (!workspace.userIds.includes(options.userId)) workspace.userIds.push(options.userId)
221
737
  } else {
222
- store.workspaces[options.workspaceId] = {
223
- id: options.workspaceId,
738
+ store.workspaces[scoped.workspaceKey] = {
739
+ id: scoped.workspaceId,
740
+ organizationId: scoped.organizationId,
224
741
  createdAt: nowIso,
225
742
  lastSeenAt: nowIso,
226
743
  userIds: [options.userId],
227
744
  repoIds: [],
228
745
  }
746
+ const org = store.organizations[scoped.organizationId]
747
+ if (!org.workspaceIds.includes(scoped.workspaceId)) org.workspaceIds.push(scoped.workspaceId)
229
748
  }
230
749
 
231
- const repoName = options.repoName ?? 'default'
232
- const repoId = `${options.workspaceId}:${repoName}`
233
- const repo = store.repos[repoId]
750
+ const membershipId = membershipKey(scoped.organizationId, scoped.workspaceId, options.userId)
751
+ const membership = store.memberships[membershipId]
752
+ let role = normalizeRole(options.role)
753
+ if (!membership && !workspace) role = 'owner'
754
+ if (membership) {
755
+ membership.lastSeenAt = nowIso
756
+ if (options.role) membership.role = normalizeRole(options.role)
757
+ role = membership.role
758
+ } else {
759
+ store.memberships[membershipId] = {
760
+ id: membershipId,
761
+ organizationId: scoped.organizationId,
762
+ workspaceId: scoped.workspaceId,
763
+ userId: options.userId,
764
+ role,
765
+ createdAt: nowIso,
766
+ lastSeenAt: nowIso,
767
+ }
768
+ }
769
+
770
+ const repo = store.repos[scoped.repoId]
234
771
  if (repo) {
235
772
  repo.lastSeenAt = nowIso
236
773
  } else {
237
- store.repos[repoId] = {
238
- id: repoId,
239
- workspaceId: options.workspaceId,
240
- name: repoName,
774
+ store.repos[scoped.repoId] = {
775
+ id: scoped.repoId,
776
+ organizationId: scoped.organizationId,
777
+ workspaceId: scoped.workspaceId,
778
+ name: scoped.repoName,
241
779
  createdAt: nowIso,
242
780
  lastSeenAt: nowIso,
243
781
  }
244
- const ws = store.workspaces[options.workspaceId]
245
- if (!ws.repoIds.includes(repoId)) ws.repoIds.push(repoId)
782
+ const ws = store.workspaces[scoped.workspaceKey]
783
+ if (!ws.repoIds.includes(scoped.repoId)) ws.repoIds.push(scoped.repoId)
246
784
  }
247
785
 
248
786
  const snapshot: SaasSnapshot = {
249
787
  id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
250
788
  createdAt: nowIso,
251
789
  scannedAt: report.scannedAt,
252
- workspaceId: options.workspaceId,
790
+ organizationId: scoped.organizationId,
791
+ workspaceId: scoped.workspaceId,
253
792
  userId: options.userId,
254
- repoId,
255
- repoName,
793
+ role,
794
+ plan: normalizePlan(store.organizations[scoped.organizationId]?.plan ?? requestedPlan),
795
+ repoId: scoped.repoId,
796
+ repoName: scoped.repoName,
256
797
  targetPath: report.targetPath,
257
798
  totalScore: report.totalScore,
258
799
  totalIssues: report.totalIssues,
@@ -271,17 +812,71 @@ export function ingestSnapshotFromReport(report: DriftReport, options: IngestOpt
271
812
  return snapshot
272
813
  }
273
814
 
274
- export function getSaasSummary(options?: { storeFile?: string; policy?: Partial<SaasPolicy> }): SaasSummary {
815
+ function matchesTenantScope(snapshot: SaasSnapshot, options?: SaasQueryOptions): boolean {
816
+ if (!options?.organizationId && !options?.workspaceId) return true
817
+ if (options.organizationId && snapshot.organizationId !== options.organizationId) return false
818
+ if (options.workspaceId && snapshot.workspaceId !== options.workspaceId) return false
819
+ return true
820
+ }
821
+
822
+ export function listSaasSnapshots(options?: SaasQueryOptions): SaasSnapshot[] {
275
823
  const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
276
824
  const store = loadStoreInternal(storeFile, options?.policy)
825
+
826
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId)
827
+ if (options?.actorUserId || shouldEnforceActorForScope) {
828
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID
829
+ assertPermissionInStore(store, {
830
+ operation: 'snapshot:read',
831
+ organizationId,
832
+ workspaceId: options?.workspaceId,
833
+ actorUserId: options?.actorUserId,
834
+ })
835
+ }
836
+
277
837
  saveStore(storeFile, store)
838
+ return store.snapshots
839
+ .filter((snapshot) => matchesTenantScope(snapshot, options))
840
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
841
+ }
278
842
 
279
- const usersRegistered = Object.keys(store.users).length
280
- const workspacesActive = Object.values(store.workspaces).filter(isWorkspaceActive).length
281
- const reposActive = Object.values(store.repos).filter(isRepoActive).length
843
+ export function getSaasSummary(options?: SaasQueryOptions): SaasSummary {
844
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
845
+ const store = loadStoreInternal(storeFile, options?.policy)
846
+
847
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId)
848
+ if (options?.actorUserId || shouldEnforceActorForScope) {
849
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID
850
+ assertPermissionInStore(store, {
851
+ operation: 'summary:read',
852
+ organizationId,
853
+ workspaceId: options?.workspaceId,
854
+ actorUserId: options?.actorUserId,
855
+ })
856
+ }
857
+
858
+ saveStore(storeFile, store)
859
+
860
+ const scopedSnapshots = store.snapshots.filter((snapshot) => matchesTenantScope(snapshot, options))
861
+ const scopedWorkspaces = Object.values(store.workspaces).filter((workspace) => {
862
+ if (options?.organizationId && workspace.organizationId !== options.organizationId) return false
863
+ if (options?.workspaceId && workspace.id !== options.workspaceId) return false
864
+ return true
865
+ })
866
+ const scopedRepos = Object.values(store.repos).filter((repo) => {
867
+ if (options?.organizationId && repo.organizationId !== options.organizationId) return false
868
+ if (options?.workspaceId && repo.workspaceId !== options.workspaceId) return false
869
+ return true
870
+ })
871
+
872
+ const usersRegistered = options?.organizationId || options?.workspaceId
873
+ ? new Set(scopedSnapshots.map((snapshot) => snapshot.userId)).size
874
+ : Object.keys(store.users).length
875
+ const workspacesActive = scopedWorkspaces.filter(isWorkspaceActive).length
876
+ const reposActive = scopedRepos.filter(isRepoActive).length
282
877
 
283
878
  const runsPerMonth: Record<string, number> = {}
284
- for (const snapshot of store.snapshots) {
879
+ for (const snapshot of scopedSnapshots) {
285
880
  const key = monthKey(snapshot.createdAt)
286
881
  runsPerMonth[key] = (runsPerMonth[key] ?? 0) + 1
287
882
  }
@@ -294,7 +889,7 @@ export function getSaasSummary(options?: { storeFile?: string; policy?: Partial<
294
889
  workspacesActive,
295
890
  reposActive,
296
891
  runsPerMonth,
297
- totalSnapshots: store.snapshots.length,
892
+ totalSnapshots: scopedSnapshots.length,
298
893
  phase: thresholdReached ? 'paid' : 'free',
299
894
  thresholdReached,
300
895
  freeUsersRemaining: Math.max(0, store.policy.freeUserThreshold - usersRegistered),
@@ -310,20 +905,23 @@ function escapeHtml(value: string): string {
310
905
  .replaceAll("'", '&#39;')
311
906
  }
312
907
 
313
- export function generateSaasDashboardHtml(options?: { storeFile?: string; policy?: Partial<SaasPolicy> }): string {
908
+ export function generateSaasDashboardHtml(options?: { storeFile?: string; policy?: SaasPolicyOverrides }): string {
314
909
  const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
315
910
  const store = loadStoreInternal(storeFile, options?.policy)
316
911
  const summary = getSaasSummary(options)
317
912
 
318
913
  const workspaceStats = Object.values(store.workspaces)
319
914
  .map((workspace) => {
320
- const snapshots = store.snapshots.filter((snapshot) => snapshot.workspaceId === workspace.id)
915
+ const snapshots = store.snapshots.filter((snapshot) => {
916
+ return snapshot.organizationId === workspace.organizationId && snapshot.workspaceId === workspace.id
917
+ })
321
918
  const runs = snapshots.length
322
919
  const avgScore = runs === 0
323
920
  ? 0
324
921
  : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
325
922
  const lastRun = snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.createdAt ?? 'n/a'
326
923
  return {
924
+ organizationId: workspace.organizationId,
327
925
  id: workspace.id,
328
926
  runs,
329
927
  avgScore,
@@ -358,7 +956,7 @@ export function generateSaasDashboardHtml(options?: { storeFile?: string; policy
358
956
  .join('')
359
957
 
360
958
  const workspaceRows = workspaceStats
361
- .map((workspace) => `<tr><td>${escapeHtml(workspace.id)}</td><td>${workspace.runs}</td><td>${workspace.avgScore}</td><td>${escapeHtml(workspace.lastRun)}</td></tr>`)
959
+ .map((workspace) => `<tr><td>${escapeHtml(workspace.organizationId)}</td><td>${escapeHtml(workspace.id)}</td><td>${workspace.runs}</td><td>${workspace.avgScore}</td><td>${escapeHtml(workspace.lastRun)}</td></tr>`)
362
960
  .join('')
363
961
 
364
962
  const repoRows = repoStats
@@ -413,12 +1011,12 @@ export function generateSaasDashboardHtml(options?: { storeFile?: string; policy
413
1011
  </section>
414
1012
 
415
1013
  <section class="section">
416
- <h2>Workspace Hotspots</h2>
417
- <table>
418
- <thead><tr><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead>
419
- <tbody>${workspaceRows || '<tr><td colspan="4">No workspace data</td></tr>'}</tbody>
420
- </table>
421
- </section>
1014
+ <h2>Workspace Hotspots</h2>
1015
+ <table>
1016
+ <thead><tr><th>Organization</th><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead>
1017
+ <tbody>${workspaceRows || '<tr><td colspan="5">No workspace data</td></tr>'}</tbody>
1018
+ </table>
1019
+ </section>
422
1020
 
423
1021
  <section class="section">
424
1022
  <h2>Repo Hotspots</h2>