@eduardbar/drift 1.1.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 (66) 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 +153 -0
  4. package/AGENTS.md +6 -0
  5. package/README.md +192 -4
  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 +509 -23
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.js +3 -0
  16. package/dist/map.d.ts +3 -2
  17. package/dist/map.js +98 -10
  18. package/dist/plugins.d.ts +2 -1
  19. package/dist/plugins.js +177 -28
  20. package/dist/printer.js +4 -0
  21. package/dist/review.js +2 -2
  22. package/dist/rules/comments.js +2 -2
  23. package/dist/rules/complexity.js +2 -7
  24. package/dist/rules/nesting.js +3 -13
  25. package/dist/rules/phase0-basic.js +10 -10
  26. package/dist/rules/shared.d.ts +2 -0
  27. package/dist/rules/shared.js +27 -3
  28. package/dist/saas.d.ts +219 -0
  29. package/dist/saas.js +762 -0
  30. package/dist/trust-kpi.d.ts +9 -0
  31. package/dist/trust-kpi.js +445 -0
  32. package/dist/trust.d.ts +65 -0
  33. package/dist/trust.js +571 -0
  34. package/dist/types.d.ts +160 -0
  35. package/docs/PRD.md +199 -172
  36. package/docs/plugin-contract.md +61 -0
  37. package/docs/trust-core-release-checklist.md +55 -0
  38. package/package.json +5 -3
  39. package/packages/vscode-drift/src/code-actions.ts +53 -0
  40. package/packages/vscode-drift/src/extension.ts +11 -0
  41. package/src/analyzer.ts +484 -155
  42. package/src/benchmark.ts +244 -0
  43. package/src/cli.ts +628 -36
  44. package/src/diff.ts +75 -10
  45. package/src/git.ts +16 -0
  46. package/src/index.ts +63 -0
  47. package/src/map.ts +112 -10
  48. package/src/plugins.ts +354 -26
  49. package/src/printer.ts +4 -0
  50. package/src/review.ts +2 -2
  51. package/src/rules/comments.ts +2 -2
  52. package/src/rules/complexity.ts +2 -7
  53. package/src/rules/nesting.ts +3 -13
  54. package/src/rules/phase0-basic.ts +11 -12
  55. package/src/rules/shared.ts +31 -3
  56. package/src/saas.ts +1031 -0
  57. package/src/trust-kpi.ts +518 -0
  58. package/src/trust.ts +774 -0
  59. package/src/types.ts +177 -0
  60. package/tests/diff.test.ts +124 -0
  61. package/tests/new-features.test.ts +98 -0
  62. package/tests/plugins.test.ts +219 -0
  63. package/tests/rules.test.ts +23 -1
  64. package/tests/saas-foundation.test.ts +464 -0
  65. package/tests/trust-kpi.test.ts +120 -0
  66. package/tests/trust.test.ts +584 -0
package/src/saas.ts ADDED
@@ -0,0 +1,1031 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import type { DriftReport, DriftConfig } from './types.js'
4
+
5
+ export interface SaasPolicy {
6
+ freeUserThreshold: number
7
+ maxRunsPerWorkspacePerMonth: number
8
+ maxReposPerWorkspace: number
9
+ retentionDays: number
10
+ strictActorEnforcement: boolean
11
+ maxWorkspacesPerOrganizationByPlan: Record<SaasPlan, number>
12
+ }
13
+
14
+ export type SaasRole = 'owner' | 'member' | 'viewer'
15
+ export type SaasPlan = 'free' | 'sponsor' | 'team' | 'business'
16
+
17
+ export interface SaasUser {
18
+ id: string
19
+ createdAt: string
20
+ lastSeenAt: string
21
+ }
22
+
23
+ export interface SaasOrganization {
24
+ id: string
25
+ plan: SaasPlan
26
+ createdAt: string
27
+ lastSeenAt: string
28
+ workspaceIds: string[]
29
+ }
30
+
31
+ export interface SaasWorkspace {
32
+ id: string
33
+ organizationId: string
34
+ createdAt: string
35
+ lastSeenAt: string
36
+ userIds: string[]
37
+ repoIds: string[]
38
+ }
39
+
40
+ export interface SaasRepo {
41
+ id: string
42
+ organizationId: string
43
+ workspaceId: string
44
+ name: string
45
+ createdAt: string
46
+ lastSeenAt: string
47
+ }
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
+
69
+ export interface SaasSnapshot {
70
+ id: string
71
+ createdAt: string
72
+ scannedAt: string
73
+ organizationId: string
74
+ workspaceId: string
75
+ userId: string
76
+ role: SaasRole
77
+ plan: SaasPlan
78
+ repoId: string
79
+ repoName: string
80
+ targetPath: string
81
+ totalScore: number
82
+ totalIssues: number
83
+ totalFiles: number
84
+ summary: {
85
+ errors: number
86
+ warnings: number
87
+ infos: number
88
+ }
89
+ }
90
+
91
+ export interface SaasStore {
92
+ version: number
93
+ policy: SaasPolicy
94
+ users: Record<string, SaasUser>
95
+ organizations: Record<string, SaasOrganization>
96
+ workspaces: Record<string, SaasWorkspace>
97
+ memberships: Record<string, SaasMembership>
98
+ repos: Record<string, SaasRepo>
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
157
+ }
158
+
159
+ export interface SaasSummary {
160
+ policy: SaasPolicy
161
+ usersRegistered: number
162
+ workspacesActive: number
163
+ reposActive: number
164
+ runsPerMonth: Record<string, number>
165
+ totalSnapshots: number
166
+ phase: 'free' | 'paid'
167
+ thresholdReached: boolean
168
+ freeUsersRemaining: number
169
+ }
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
+
188
+ export interface IngestOptions {
189
+ organizationId?: string
190
+ workspaceId: string
191
+ userId: string
192
+ role?: SaasRole
193
+ plan?: SaasPlan
194
+ repoName?: string
195
+ actorUserId?: string
196
+ storeFile?: string
197
+ policy?: SaasPolicyOverrides
198
+ }
199
+
200
+ const STORE_VERSION = 3
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
+ }
257
+
258
+ export const DEFAULT_SAAS_POLICY: SaasPolicy = {
259
+ freeUserThreshold: 7500,
260
+ maxRunsPerWorkspacePerMonth: 500,
261
+ maxReposPerWorkspace: 20,
262
+ retentionDays: 90,
263
+ strictActorEnforcement: false,
264
+ maxWorkspacesPerOrganizationByPlan: {
265
+ free: 20,
266
+ sponsor: 50,
267
+ team: 200,
268
+ business: 1000,
269
+ },
270
+ }
271
+
272
+ export function resolveSaasPolicy(policy?: SaasPolicyOverrides | DriftConfig['saas']): SaasPolicy {
273
+ const customPlanLimits = (policy && 'maxWorkspacesPerOrganizationByPlan' in policy)
274
+ ? (policy.maxWorkspacesPerOrganizationByPlan ?? {})
275
+ : {}
276
+ return {
277
+ ...DEFAULT_SAAS_POLICY,
278
+ ...(policy ?? {}),
279
+ maxWorkspacesPerOrganizationByPlan: {
280
+ ...DEFAULT_SAAS_POLICY.maxWorkspacesPerOrganizationByPlan,
281
+ ...customPlanLimits,
282
+ },
283
+ }
284
+ }
285
+
286
+ export function defaultSaasStorePath(root = '.'): string {
287
+ return resolve(root, '.drift-cloud', 'store.json')
288
+ }
289
+
290
+ function ensureStoreFile(storeFile: string, policy?: SaasPolicyOverrides): void {
291
+ const dir = dirname(storeFile)
292
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
293
+ if (!existsSync(storeFile)) {
294
+ const initial = createEmptyStore(policy)
295
+ writeFileSync(storeFile, JSON.stringify(initial, null, 2), 'utf8')
296
+ }
297
+ }
298
+
299
+ function createEmptyStore(policy?: SaasPolicyOverrides): SaasStore {
300
+ return {
301
+ version: STORE_VERSION,
302
+ policy: resolveSaasPolicy(policy),
303
+ users: {},
304
+ organizations: {},
305
+ workspaces: {},
306
+ memberships: {},
307
+ repos: {},
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,
381
+ }
382
+ }
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
+
403
+ function monthKey(isoDate: string): string {
404
+ const date = new Date(isoDate)
405
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0')
406
+ return `${date.getUTCFullYear()}-${month}`
407
+ }
408
+
409
+ function daysAgo(days: number): number {
410
+ const now = Date.now()
411
+ return now - days * 24 * 60 * 60 * 1000
412
+ }
413
+
414
+ function applyRetention(store: SaasStore): void {
415
+ const cutoff = daysAgo(store.policy.retentionDays)
416
+ store.snapshots = store.snapshots.filter((snapshot) => {
417
+ return new Date(snapshot.createdAt).getTime() >= cutoff
418
+ })
419
+ }
420
+
421
+ function saveStore(storeFile: string, store: SaasStore): void {
422
+ writeFileSync(storeFile, JSON.stringify(store, null, 2), 'utf8')
423
+ }
424
+
425
+ function loadStoreInternal(storeFile: string, policy?: SaasPolicyOverrides): SaasStore {
426
+ ensureStoreFile(storeFile, policy)
427
+ const raw = readFileSync(storeFile, 'utf8')
428
+ const parsed = JSON.parse(raw) as Partial<SaasStore>
429
+
430
+ const merged = createEmptyStore(parsed.policy)
431
+ merged.version = parsed.version ?? STORE_VERSION
432
+ merged.users = parsed.users ?? {}
433
+ merged.organizations = parsed.organizations ?? {}
434
+ merged.workspaces = parsed.workspaces ?? {}
435
+ merged.memberships = parsed.memberships ?? {}
436
+ merged.repos = parsed.repos ?? {}
437
+ merged.snapshots = parsed.snapshots ?? []
438
+ merged.planChanges = parsed.planChanges ?? []
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
+
470
+ applyRetention(merged)
471
+
472
+ return merged
473
+ }
474
+
475
+ function isWorkspaceActive(workspace: SaasWorkspace): boolean {
476
+ return new Date(workspace.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS)
477
+ }
478
+
479
+ function isRepoActive(repo: SaasRepo): boolean {
480
+ return new Date(repo.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS)
481
+ }
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
+
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
+
513
+ const usersRegistered = Object.keys(store.users).length
514
+ const isFreePhase = usersRegistered < store.policy.freeUserThreshold
515
+ if (!isFreePhase) return
516
+
517
+ if (!store.users[options.userId] && usersRegistered + 1 > store.policy.freeUserThreshold) {
518
+ throw new Error(`Free threshold reached (${store.policy.freeUserThreshold} users).`)
519
+ }
520
+
521
+ const workspace = store.workspaces[scoped.workspaceKey]
522
+ const repoExists = Boolean(store.repos[scoped.repoId])
523
+ const repoCount = workspace?.repoIds.length ?? 0
524
+
525
+ if (!repoExists && repoCount >= store.policy.maxReposPerWorkspace) {
526
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`)
527
+ }
528
+
529
+ const currentMonth = monthKey(nowIso)
530
+ const runsThisMonth = store.snapshots.filter((snapshot) => {
531
+ return (
532
+ snapshot.organizationId === scoped.organizationId
533
+ && snapshot.workspaceId === scoped.workspaceId
534
+ && monthKey(snapshot.createdAt) === currentMonth
535
+ )
536
+ }).length
537
+
538
+ if (runsThisMonth >= 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,
649
+ }
650
+ }
651
+
652
+ export function ingestSnapshotFromReport(report: DriftReport, options: IngestOptions): SaasSnapshot {
653
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
654
+ const store = loadStoreInternal(storeFile, options.policy)
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
+ }
685
+
686
+ assertGuardrails(store, options, nowIso)
687
+
688
+ const user = store.users[options.userId]
689
+ if (user) {
690
+ user.lastSeenAt = nowIso
691
+ } else {
692
+ store.users[options.userId] = {
693
+ id: options.userId,
694
+ createdAt: nowIso,
695
+ lastSeenAt: nowIso,
696
+ }
697
+ }
698
+
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]
734
+ if (workspace) {
735
+ workspace.lastSeenAt = nowIso
736
+ if (!workspace.userIds.includes(options.userId)) workspace.userIds.push(options.userId)
737
+ } else {
738
+ store.workspaces[scoped.workspaceKey] = {
739
+ id: scoped.workspaceId,
740
+ organizationId: scoped.organizationId,
741
+ createdAt: nowIso,
742
+ lastSeenAt: nowIso,
743
+ userIds: [options.userId],
744
+ repoIds: [],
745
+ }
746
+ const org = store.organizations[scoped.organizationId]
747
+ if (!org.workspaceIds.includes(scoped.workspaceId)) org.workspaceIds.push(scoped.workspaceId)
748
+ }
749
+
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]
771
+ if (repo) {
772
+ repo.lastSeenAt = nowIso
773
+ } else {
774
+ store.repos[scoped.repoId] = {
775
+ id: scoped.repoId,
776
+ organizationId: scoped.organizationId,
777
+ workspaceId: scoped.workspaceId,
778
+ name: scoped.repoName,
779
+ createdAt: nowIso,
780
+ lastSeenAt: nowIso,
781
+ }
782
+ const ws = store.workspaces[scoped.workspaceKey]
783
+ if (!ws.repoIds.includes(scoped.repoId)) ws.repoIds.push(scoped.repoId)
784
+ }
785
+
786
+ const snapshot: SaasSnapshot = {
787
+ id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
788
+ createdAt: nowIso,
789
+ scannedAt: report.scannedAt,
790
+ organizationId: scoped.organizationId,
791
+ workspaceId: scoped.workspaceId,
792
+ userId: options.userId,
793
+ role,
794
+ plan: normalizePlan(store.organizations[scoped.organizationId]?.plan ?? requestedPlan),
795
+ repoId: scoped.repoId,
796
+ repoName: scoped.repoName,
797
+ targetPath: report.targetPath,
798
+ totalScore: report.totalScore,
799
+ totalIssues: report.totalIssues,
800
+ totalFiles: report.totalFiles,
801
+ summary: {
802
+ errors: report.summary.errors,
803
+ warnings: report.summary.warnings,
804
+ infos: report.summary.infos,
805
+ },
806
+ }
807
+
808
+ store.snapshots.push(snapshot)
809
+ applyRetention(store)
810
+ saveStore(storeFile, store)
811
+
812
+ return snapshot
813
+ }
814
+
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[] {
823
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
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
+
837
+ saveStore(storeFile, store)
838
+ return store.snapshots
839
+ .filter((snapshot) => matchesTenantScope(snapshot, options))
840
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
841
+ }
842
+
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
877
+
878
+ const runsPerMonth: Record<string, number> = {}
879
+ for (const snapshot of scopedSnapshots) {
880
+ const key = monthKey(snapshot.createdAt)
881
+ runsPerMonth[key] = (runsPerMonth[key] ?? 0) + 1
882
+ }
883
+
884
+ const thresholdReached = usersRegistered >= store.policy.freeUserThreshold
885
+
886
+ return {
887
+ policy: store.policy,
888
+ usersRegistered,
889
+ workspacesActive,
890
+ reposActive,
891
+ runsPerMonth,
892
+ totalSnapshots: scopedSnapshots.length,
893
+ phase: thresholdReached ? 'paid' : 'free',
894
+ thresholdReached,
895
+ freeUsersRemaining: Math.max(0, store.policy.freeUserThreshold - usersRegistered),
896
+ }
897
+ }
898
+
899
+ function escapeHtml(value: string): string {
900
+ return value
901
+ .replaceAll('&', '&amp;')
902
+ .replaceAll('<', '&lt;')
903
+ .replaceAll('>', '&gt;')
904
+ .replaceAll('"', '&quot;')
905
+ .replaceAll("'", '&#39;')
906
+ }
907
+
908
+ export function generateSaasDashboardHtml(options?: { storeFile?: string; policy?: SaasPolicyOverrides }): string {
909
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
910
+ const store = loadStoreInternal(storeFile, options?.policy)
911
+ const summary = getSaasSummary(options)
912
+
913
+ const workspaceStats = Object.values(store.workspaces)
914
+ .map((workspace) => {
915
+ const snapshots = store.snapshots.filter((snapshot) => {
916
+ return snapshot.organizationId === workspace.organizationId && snapshot.workspaceId === workspace.id
917
+ })
918
+ const runs = snapshots.length
919
+ const avgScore = runs === 0
920
+ ? 0
921
+ : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
922
+ const lastRun = snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.createdAt ?? 'n/a'
923
+ return {
924
+ organizationId: workspace.organizationId,
925
+ id: workspace.id,
926
+ runs,
927
+ avgScore,
928
+ lastRun,
929
+ }
930
+ })
931
+ .sort((a, b) => b.avgScore - a.avgScore)
932
+
933
+ const repoStats = Object.values(store.repos)
934
+ .map((repo) => {
935
+ const snapshots = store.snapshots.filter((snapshot) => snapshot.repoId === repo.id)
936
+ const runs = snapshots.length
937
+ const avgScore = runs === 0
938
+ ? 0
939
+ : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
940
+ return {
941
+ workspaceId: repo.workspaceId,
942
+ name: repo.name,
943
+ runs,
944
+ avgScore,
945
+ }
946
+ })
947
+ .sort((a, b) => b.avgScore - a.avgScore)
948
+ .slice(0, 15)
949
+
950
+ const runsRows = Object.entries(summary.runsPerMonth)
951
+ .sort(([a], [b]) => a.localeCompare(b))
952
+ .map(([month, count]) => {
953
+ const width = Math.max(8, count * 8)
954
+ return `<tr><td>${escapeHtml(month)}</td><td>${count}</td><td><div class="bar" style="width:${width}px"></div></td></tr>`
955
+ })
956
+ .join('')
957
+
958
+ const workspaceRows = workspaceStats
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>`)
960
+ .join('')
961
+
962
+ const repoRows = repoStats
963
+ .map((repo) => `<tr><td>${escapeHtml(repo.workspaceId)}</td><td>${escapeHtml(repo.name)}</td><td>${repo.runs}</td><td>${repo.avgScore}</td></tr>`)
964
+ .join('')
965
+
966
+ return `<!doctype html>
967
+ <html lang="en">
968
+ <head>
969
+ <meta charset="utf-8" />
970
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
971
+ <title>drift cloud dashboard</title>
972
+ <style>
973
+ :root { color-scheme: light; }
974
+ body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: #f4f7fb; color: #0f172a; }
975
+ main { max-width: 980px; margin: 0 auto; padding: 24px; }
976
+ h1 { margin: 0 0 6px; }
977
+ p.meta { margin: 0 0 20px; color: #475569; }
978
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; }
979
+ .card { background: #ffffff; border-radius: 10px; padding: 14px; border: 1px solid #dbe3ef; }
980
+ .card .label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; }
981
+ .card .value { font-size: 26px; font-weight: 700; margin-top: 4px; }
982
+ table { width: 100%; border-collapse: collapse; margin-top: 10px; background: #ffffff; border: 1px solid #dbe3ef; border-radius: 10px; overflow: hidden; }
983
+ th, td { padding: 10px; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 14px; }
984
+ th { background: #eef2f9; }
985
+ .section { margin-top: 18px; }
986
+ .bar { height: 10px; background: linear-gradient(90deg, #0ea5e9, #22c55e); border-radius: 999px; }
987
+ .pill { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; font-weight: 600; }
988
+ .pill.free { background: #dcfce7; color: #166534; }
989
+ .pill.paid { background: #fee2e2; color: #991b1b; }
990
+ </style>
991
+ </head>
992
+ <body>
993
+ <main>
994
+ <h1>drift cloud dashboard</h1>
995
+ <p class="meta">Store: ${escapeHtml(storeFile)}</p>
996
+ <div class="cards">
997
+ <div class="card"><div class="label">Plan Phase</div><div class="value"><span class="pill ${summary.phase}">${summary.phase.toUpperCase()}</span></div></div>
998
+ <div class="card"><div class="label">Users</div><div class="value">${summary.usersRegistered}</div></div>
999
+ <div class="card"><div class="label">Active Workspaces</div><div class="value">${summary.workspacesActive}</div></div>
1000
+ <div class="card"><div class="label">Active Repos</div><div class="value">${summary.reposActive}</div></div>
1001
+ <div class="card"><div class="label">Snapshots</div><div class="value">${summary.totalSnapshots}</div></div>
1002
+ <div class="card"><div class="label">Free Seats Left</div><div class="value">${summary.freeUsersRemaining}</div></div>
1003
+ </div>
1004
+
1005
+ <section class="section">
1006
+ <h2>Runs Per Month</h2>
1007
+ <table>
1008
+ <thead><tr><th>Month</th><th>Runs</th><th>Trend</th></tr></thead>
1009
+ <tbody>${runsRows || '<tr><td colspan="3">No runs yet</td></tr>'}</tbody>
1010
+ </table>
1011
+ </section>
1012
+
1013
+ <section class="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>
1020
+
1021
+ <section class="section">
1022
+ <h2>Repo Hotspots</h2>
1023
+ <table>
1024
+ <thead><tr><th>Workspace</th><th>Repo</th><th>Runs</th><th>Avg Score</th></tr></thead>
1025
+ <tbody>${repoRows || '<tr><td colspan="4">No repo data</td></tr>'}</tbody>
1026
+ </table>
1027
+ </section>
1028
+ </main>
1029
+ </body>
1030
+ </html>`
1031
+ }