@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/dist/saas.js ADDED
@@ -0,0 +1,762 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ const STORE_VERSION = 3;
4
+ const ACTIVE_WINDOW_DAYS = 30;
5
+ const DEFAULT_ORGANIZATION_ID = 'default-org';
6
+ const VALID_ROLES = ['owner', 'member', 'viewer'];
7
+ const VALID_PLANS = ['free', 'sponsor', 'team', 'business'];
8
+ const ROLE_PRIORITY = { viewer: 1, member: 2, owner: 3 };
9
+ const REQUIRED_ROLE_BY_OPERATION = {
10
+ 'snapshot:write': 'member',
11
+ 'snapshot:read': 'viewer',
12
+ 'summary:read': 'viewer',
13
+ 'billing:write': 'owner',
14
+ 'billing:read': 'viewer',
15
+ };
16
+ export class SaasPermissionError extends Error {
17
+ code = 'SAAS_PERMISSION_DENIED';
18
+ operation;
19
+ organizationId;
20
+ workspaceId;
21
+ actorUserId;
22
+ requiredRole;
23
+ actorRole;
24
+ constructor(context, requiredRole, actorRole) {
25
+ const actor = context.actorUserId ?? 'unknown-actor';
26
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : '';
27
+ const actualRole = actorRole ?? 'none';
28
+ super(`Permission denied for operation '${context.operation}'. actor='${actor}' organization='${context.organizationId}'${workspaceSuffix} requiredRole='${requiredRole}' actualRole='${actualRole}'.`);
29
+ this.name = 'SaasPermissionError';
30
+ this.operation = context.operation;
31
+ this.organizationId = context.organizationId;
32
+ this.workspaceId = context.workspaceId;
33
+ this.actorUserId = context.actorUserId;
34
+ this.requiredRole = requiredRole;
35
+ this.actorRole = actorRole;
36
+ }
37
+ }
38
+ export class SaasActorRequiredError extends Error {
39
+ code = 'SAAS_ACTOR_REQUIRED';
40
+ operation;
41
+ organizationId;
42
+ workspaceId;
43
+ constructor(context) {
44
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : '';
45
+ super(`Actor is required for operation '${context.operation}'. organization='${context.organizationId}'${workspaceSuffix}.`);
46
+ this.name = 'SaasActorRequiredError';
47
+ this.operation = context.operation;
48
+ this.organizationId = context.organizationId;
49
+ this.workspaceId = context.workspaceId;
50
+ }
51
+ }
52
+ export const DEFAULT_SAAS_POLICY = {
53
+ freeUserThreshold: 7500,
54
+ maxRunsPerWorkspacePerMonth: 500,
55
+ maxReposPerWorkspace: 20,
56
+ retentionDays: 90,
57
+ strictActorEnforcement: false,
58
+ maxWorkspacesPerOrganizationByPlan: {
59
+ free: 20,
60
+ sponsor: 50,
61
+ team: 200,
62
+ business: 1000,
63
+ },
64
+ };
65
+ export function resolveSaasPolicy(policy) {
66
+ const customPlanLimits = (policy && 'maxWorkspacesPerOrganizationByPlan' in policy)
67
+ ? (policy.maxWorkspacesPerOrganizationByPlan ?? {})
68
+ : {};
69
+ return {
70
+ ...DEFAULT_SAAS_POLICY,
71
+ ...(policy ?? {}),
72
+ maxWorkspacesPerOrganizationByPlan: {
73
+ ...DEFAULT_SAAS_POLICY.maxWorkspacesPerOrganizationByPlan,
74
+ ...customPlanLimits,
75
+ },
76
+ };
77
+ }
78
+ export function defaultSaasStorePath(root = '.') {
79
+ return resolve(root, '.drift-cloud', 'store.json');
80
+ }
81
+ function ensureStoreFile(storeFile, policy) {
82
+ const dir = dirname(storeFile);
83
+ if (!existsSync(dir))
84
+ mkdirSync(dir, { recursive: true });
85
+ if (!existsSync(storeFile)) {
86
+ const initial = createEmptyStore(policy);
87
+ writeFileSync(storeFile, JSON.stringify(initial, null, 2), 'utf8');
88
+ }
89
+ }
90
+ function createEmptyStore(policy) {
91
+ return {
92
+ version: STORE_VERSION,
93
+ policy: resolveSaasPolicy(policy),
94
+ users: {},
95
+ organizations: {},
96
+ workspaces: {},
97
+ memberships: {},
98
+ repos: {},
99
+ snapshots: [],
100
+ planChanges: [],
101
+ };
102
+ }
103
+ function normalizePlan(plan) {
104
+ if (!plan)
105
+ return 'free';
106
+ return VALID_PLANS.includes(plan) ? plan : 'free';
107
+ }
108
+ function normalizeRole(role) {
109
+ if (!role)
110
+ return 'member';
111
+ return VALID_ROLES.includes(role) ? role : 'member';
112
+ }
113
+ function hasRoleAtLeast(role, requiredRole) {
114
+ if (!role)
115
+ return false;
116
+ return ROLE_PRIORITY[role] >= ROLE_PRIORITY[requiredRole];
117
+ }
118
+ function resolveActorRole(store, organizationId, actorUserId, workspaceId) {
119
+ if (workspaceId) {
120
+ const scopedMembershipId = membershipKey(organizationId, workspaceId, actorUserId);
121
+ return store.memberships[scopedMembershipId]?.role;
122
+ }
123
+ let highestRole;
124
+ for (const membership of Object.values(store.memberships)) {
125
+ if (membership.organizationId !== organizationId)
126
+ continue;
127
+ if (membership.userId !== actorUserId)
128
+ continue;
129
+ if (!highestRole || ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highestRole]) {
130
+ highestRole = membership.role;
131
+ if (highestRole === 'owner')
132
+ break;
133
+ }
134
+ }
135
+ return highestRole;
136
+ }
137
+ function assertPermissionInStore(store, context) {
138
+ const requiredRole = REQUIRED_ROLE_BY_OPERATION[context.operation];
139
+ if (!context.actorUserId) {
140
+ if (store.policy.strictActorEnforcement) {
141
+ throw new SaasActorRequiredError(context);
142
+ }
143
+ return { requiredRole };
144
+ }
145
+ const actorRole = resolveActorRole(store, context.organizationId, context.actorUserId, context.workspaceId);
146
+ if (!hasRoleAtLeast(actorRole, requiredRole)) {
147
+ throw new SaasPermissionError(context, requiredRole, actorRole);
148
+ }
149
+ return { requiredRole, actorRole };
150
+ }
151
+ export function getRequiredRoleForOperation(operation) {
152
+ return REQUIRED_ROLE_BY_OPERATION[operation];
153
+ }
154
+ export function assertSaasPermission(context) {
155
+ const storeFile = resolve(context.storeFile ?? defaultSaasStorePath());
156
+ const store = loadStoreInternal(storeFile, context.policy);
157
+ return assertPermissionInStore(store, context);
158
+ }
159
+ export function getSaasEffectiveLimits(input) {
160
+ const policy = resolveSaasPolicy(input.policy);
161
+ return {
162
+ plan: input.plan,
163
+ maxWorkspaces: policy.maxWorkspacesPerOrganizationByPlan[input.plan],
164
+ maxReposPerWorkspace: policy.maxReposPerWorkspace,
165
+ maxRunsPerWorkspacePerMonth: policy.maxRunsPerWorkspacePerMonth,
166
+ retentionDays: policy.retentionDays,
167
+ };
168
+ }
169
+ export function getOrganizationEffectiveLimits(options) {
170
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
171
+ const store = loadStoreInternal(storeFile, options.policy);
172
+ const plan = normalizePlan(store.organizations[options.organizationId]?.plan);
173
+ return getSaasEffectiveLimits({ plan, policy: store.policy });
174
+ }
175
+ function workspaceKey(organizationId, workspaceId) {
176
+ return `${organizationId}:${workspaceId}`;
177
+ }
178
+ function repoKey(organizationId, workspaceId, repoName) {
179
+ return `${workspaceKey(organizationId, workspaceId)}:${repoName}`;
180
+ }
181
+ function membershipKey(organizationId, workspaceId, userId) {
182
+ return `${workspaceKey(organizationId, workspaceId)}:${userId}`;
183
+ }
184
+ function monthKey(isoDate) {
185
+ const date = new Date(isoDate);
186
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
187
+ return `${date.getUTCFullYear()}-${month}`;
188
+ }
189
+ function daysAgo(days) {
190
+ const now = Date.now();
191
+ return now - days * 24 * 60 * 60 * 1000;
192
+ }
193
+ function applyRetention(store) {
194
+ const cutoff = daysAgo(store.policy.retentionDays);
195
+ store.snapshots = store.snapshots.filter((snapshot) => {
196
+ return new Date(snapshot.createdAt).getTime() >= cutoff;
197
+ });
198
+ }
199
+ function saveStore(storeFile, store) {
200
+ writeFileSync(storeFile, JSON.stringify(store, null, 2), 'utf8');
201
+ }
202
+ function loadStoreInternal(storeFile, policy) {
203
+ ensureStoreFile(storeFile, policy);
204
+ const raw = readFileSync(storeFile, 'utf8');
205
+ const parsed = JSON.parse(raw);
206
+ const merged = createEmptyStore(parsed.policy);
207
+ merged.version = parsed.version ?? STORE_VERSION;
208
+ merged.users = parsed.users ?? {};
209
+ merged.organizations = parsed.organizations ?? {};
210
+ merged.workspaces = parsed.workspaces ?? {};
211
+ merged.memberships = parsed.memberships ?? {};
212
+ merged.repos = parsed.repos ?? {};
213
+ merged.snapshots = parsed.snapshots ?? [];
214
+ merged.planChanges = parsed.planChanges ?? [];
215
+ merged.policy = resolveSaasPolicy({ ...merged.policy, ...policy });
216
+ for (const workspace of Object.values(merged.workspaces)) {
217
+ if (!workspace.organizationId)
218
+ workspace.organizationId = DEFAULT_ORGANIZATION_ID;
219
+ }
220
+ for (const repo of Object.values(merged.repos)) {
221
+ if (!repo.organizationId)
222
+ repo.organizationId = DEFAULT_ORGANIZATION_ID;
223
+ }
224
+ for (const snapshot of merged.snapshots) {
225
+ if (!snapshot.organizationId)
226
+ snapshot.organizationId = DEFAULT_ORGANIZATION_ID;
227
+ if (!snapshot.plan)
228
+ snapshot.plan = 'free';
229
+ if (!snapshot.role)
230
+ snapshot.role = 'member';
231
+ }
232
+ for (const workspace of Object.values(merged.workspaces)) {
233
+ const orgId = workspace.organizationId;
234
+ const existingOrg = merged.organizations[orgId];
235
+ if (!existingOrg) {
236
+ merged.organizations[orgId] = {
237
+ id: orgId,
238
+ plan: 'free',
239
+ createdAt: workspace.createdAt,
240
+ lastSeenAt: workspace.lastSeenAt,
241
+ workspaceIds: [workspace.id],
242
+ };
243
+ continue;
244
+ }
245
+ if (!existingOrg.workspaceIds.includes(workspace.id))
246
+ existingOrg.workspaceIds.push(workspace.id);
247
+ if (workspace.lastSeenAt > existingOrg.lastSeenAt)
248
+ existingOrg.lastSeenAt = workspace.lastSeenAt;
249
+ }
250
+ applyRetention(merged);
251
+ return merged;
252
+ }
253
+ function isWorkspaceActive(workspace) {
254
+ return new Date(workspace.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS);
255
+ }
256
+ function isRepoActive(repo) {
257
+ return new Date(repo.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS);
258
+ }
259
+ function resolveScopedIdentity(options) {
260
+ const organizationId = options.organizationId ?? DEFAULT_ORGANIZATION_ID;
261
+ const workspaceId = options.workspaceId;
262
+ const repoName = options.repoName ?? 'default';
263
+ return {
264
+ organizationId,
265
+ workspaceId,
266
+ workspaceKey: workspaceKey(organizationId, workspaceId),
267
+ repoName,
268
+ repoId: repoKey(organizationId, workspaceId, repoName),
269
+ };
270
+ }
271
+ function assertGuardrails(store, options, nowIso) {
272
+ const scoped = resolveScopedIdentity(options);
273
+ const organization = store.organizations[scoped.organizationId];
274
+ const effectivePlan = normalizePlan(options.plan ?? organization?.plan);
275
+ const workspaceLimit = store.policy.maxWorkspacesPerOrganizationByPlan[effectivePlan];
276
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey]);
277
+ const workspaceCount = organization?.workspaceIds.length ?? 0;
278
+ if (!workspaceExists && workspaceCount >= workspaceLimit) {
279
+ throw new Error(`Organization '${scoped.organizationId}' on plan '${effectivePlan}' reached max workspaces (${workspaceLimit}).`);
280
+ }
281
+ const usersRegistered = Object.keys(store.users).length;
282
+ const isFreePhase = usersRegistered < store.policy.freeUserThreshold;
283
+ if (!isFreePhase)
284
+ return;
285
+ if (!store.users[options.userId] && usersRegistered + 1 > store.policy.freeUserThreshold) {
286
+ throw new Error(`Free threshold reached (${store.policy.freeUserThreshold} users).`);
287
+ }
288
+ const workspace = store.workspaces[scoped.workspaceKey];
289
+ const repoExists = Boolean(store.repos[scoped.repoId]);
290
+ const repoCount = workspace?.repoIds.length ?? 0;
291
+ if (!repoExists && repoCount >= store.policy.maxReposPerWorkspace) {
292
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`);
293
+ }
294
+ const currentMonth = monthKey(nowIso);
295
+ const runsThisMonth = store.snapshots.filter((snapshot) => {
296
+ return (snapshot.organizationId === scoped.organizationId
297
+ && snapshot.workspaceId === scoped.workspaceId
298
+ && monthKey(snapshot.createdAt) === currentMonth);
299
+ }).length;
300
+ if (runsThisMonth >= store.policy.maxRunsPerWorkspacePerMonth) {
301
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max monthly runs (${store.policy.maxRunsPerWorkspacePerMonth}).`);
302
+ }
303
+ }
304
+ function appendPlanChange(store, input) {
305
+ const change = {
306
+ id: `${input.changedAt}-${Math.random().toString(16).slice(2, 10)}`,
307
+ organizationId: input.organizationId,
308
+ fromPlan: input.fromPlan,
309
+ toPlan: input.toPlan,
310
+ changedAt: input.changedAt,
311
+ changedByUserId: input.changedByUserId,
312
+ reason: input.reason,
313
+ };
314
+ store.planChanges.push(change);
315
+ return change;
316
+ }
317
+ export function changeOrganizationPlan(options) {
318
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
319
+ const store = loadStoreInternal(storeFile, options.policy);
320
+ const nowIso = new Date().toISOString();
321
+ const organization = store.organizations[options.organizationId];
322
+ if (!organization) {
323
+ throw new Error(`Organization '${options.organizationId}' does not exist.`);
324
+ }
325
+ assertPermissionInStore(store, {
326
+ operation: 'billing:write',
327
+ organizationId: options.organizationId,
328
+ actorUserId: options.actorUserId,
329
+ });
330
+ const nextPlan = normalizePlan(options.newPlan);
331
+ if (organization.plan === nextPlan) {
332
+ const unchanged = appendPlanChange(store, {
333
+ organizationId: organization.id,
334
+ fromPlan: organization.plan,
335
+ toPlan: nextPlan,
336
+ changedAt: nowIso,
337
+ changedByUserId: options.actorUserId,
338
+ reason: options.reason,
339
+ });
340
+ saveStore(storeFile, store);
341
+ return unchanged;
342
+ }
343
+ const previousPlan = organization.plan;
344
+ organization.plan = nextPlan;
345
+ organization.lastSeenAt = nowIso;
346
+ const change = appendPlanChange(store, {
347
+ organizationId: organization.id,
348
+ fromPlan: previousPlan,
349
+ toPlan: nextPlan,
350
+ changedAt: nowIso,
351
+ changedByUserId: options.actorUserId,
352
+ reason: options.reason,
353
+ });
354
+ saveStore(storeFile, store);
355
+ return change;
356
+ }
357
+ export function listOrganizationPlanChanges(options) {
358
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
359
+ const store = loadStoreInternal(storeFile, options.policy);
360
+ assertPermissionInStore(store, {
361
+ operation: 'billing:read',
362
+ organizationId: options.organizationId,
363
+ actorUserId: options.actorUserId,
364
+ });
365
+ return store.planChanges
366
+ .filter((change) => change.organizationId === options.organizationId)
367
+ .sort((a, b) => b.changedAt.localeCompare(a.changedAt));
368
+ }
369
+ export function getOrganizationUsageSnapshot(options) {
370
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
371
+ const store = loadStoreInternal(storeFile, options.policy);
372
+ assertPermissionInStore(store, {
373
+ operation: 'billing:read',
374
+ organizationId: options.organizationId,
375
+ actorUserId: options.actorUserId,
376
+ });
377
+ const organization = store.organizations[options.organizationId];
378
+ if (!organization) {
379
+ throw new Error(`Organization '${options.organizationId}' does not exist.`);
380
+ }
381
+ const month = options.month ?? monthKey(new Date().toISOString());
382
+ const organizationRunSnapshots = store.snapshots.filter((snapshot) => snapshot.organizationId === options.organizationId);
383
+ return {
384
+ organizationId: options.organizationId,
385
+ plan: organization.plan,
386
+ capturedAt: new Date().toISOString(),
387
+ workspaceCount: organization.workspaceIds.length,
388
+ repoCount: organization.workspaceIds
389
+ .map((workspaceId) => store.workspaces[workspaceKey(options.organizationId, workspaceId)])
390
+ .filter((workspace) => Boolean(workspace))
391
+ .reduce((count, workspace) => count + workspace.repoIds.length, 0),
392
+ runCount: organizationRunSnapshots.length,
393
+ runCountThisMonth: organizationRunSnapshots.filter((snapshot) => monthKey(snapshot.createdAt) === month).length,
394
+ };
395
+ }
396
+ export function ingestSnapshotFromReport(report, options) {
397
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
398
+ const store = loadStoreInternal(storeFile, options.policy);
399
+ const nowIso = new Date().toISOString();
400
+ const scoped = resolveScopedIdentity(options);
401
+ const requestedPlan = normalizePlan(options.plan);
402
+ if (store.policy.strictActorEnforcement && !options.actorUserId) {
403
+ throw new SaasActorRequiredError({
404
+ operation: 'snapshot:write',
405
+ organizationId: scoped.organizationId,
406
+ workspaceId: scoped.workspaceId,
407
+ });
408
+ }
409
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey]);
410
+ const organizationExists = Boolean(store.organizations[scoped.organizationId]);
411
+ if (options.actorUserId) {
412
+ if (workspaceExists) {
413
+ assertPermissionInStore(store, {
414
+ operation: 'snapshot:write',
415
+ organizationId: scoped.organizationId,
416
+ workspaceId: scoped.workspaceId,
417
+ actorUserId: options.actorUserId,
418
+ });
419
+ }
420
+ else if (organizationExists) {
421
+ assertPermissionInStore(store, {
422
+ operation: 'billing:write',
423
+ organizationId: scoped.organizationId,
424
+ actorUserId: options.actorUserId,
425
+ });
426
+ }
427
+ }
428
+ assertGuardrails(store, options, nowIso);
429
+ const user = store.users[options.userId];
430
+ if (user) {
431
+ user.lastSeenAt = nowIso;
432
+ }
433
+ else {
434
+ store.users[options.userId] = {
435
+ id: options.userId,
436
+ createdAt: nowIso,
437
+ lastSeenAt: nowIso,
438
+ };
439
+ }
440
+ const existingOrg = store.organizations[scoped.organizationId];
441
+ const plan = normalizePlan(existingOrg?.plan ?? requestedPlan);
442
+ if (existingOrg) {
443
+ existingOrg.lastSeenAt = nowIso;
444
+ if (options.plan && existingOrg.plan !== requestedPlan) {
445
+ if (options.actorUserId) {
446
+ assertPermissionInStore(store, {
447
+ operation: 'billing:write',
448
+ organizationId: scoped.organizationId,
449
+ actorUserId: options.actorUserId,
450
+ });
451
+ }
452
+ const previousPlan = existingOrg.plan;
453
+ existingOrg.plan = requestedPlan;
454
+ appendPlanChange(store, {
455
+ organizationId: scoped.organizationId,
456
+ fromPlan: previousPlan,
457
+ toPlan: requestedPlan,
458
+ changedAt: nowIso,
459
+ changedByUserId: options.actorUserId ?? options.userId,
460
+ reason: 'ingest-option-plan-change',
461
+ });
462
+ }
463
+ }
464
+ else {
465
+ store.organizations[scoped.organizationId] = {
466
+ id: scoped.organizationId,
467
+ plan,
468
+ createdAt: nowIso,
469
+ lastSeenAt: nowIso,
470
+ workspaceIds: [],
471
+ };
472
+ }
473
+ const workspace = store.workspaces[scoped.workspaceKey];
474
+ if (workspace) {
475
+ workspace.lastSeenAt = nowIso;
476
+ if (!workspace.userIds.includes(options.userId))
477
+ workspace.userIds.push(options.userId);
478
+ }
479
+ else {
480
+ store.workspaces[scoped.workspaceKey] = {
481
+ id: scoped.workspaceId,
482
+ organizationId: scoped.organizationId,
483
+ createdAt: nowIso,
484
+ lastSeenAt: nowIso,
485
+ userIds: [options.userId],
486
+ repoIds: [],
487
+ };
488
+ const org = store.organizations[scoped.organizationId];
489
+ if (!org.workspaceIds.includes(scoped.workspaceId))
490
+ org.workspaceIds.push(scoped.workspaceId);
491
+ }
492
+ const membershipId = membershipKey(scoped.organizationId, scoped.workspaceId, options.userId);
493
+ const membership = store.memberships[membershipId];
494
+ let role = normalizeRole(options.role);
495
+ if (!membership && !workspace)
496
+ role = 'owner';
497
+ if (membership) {
498
+ membership.lastSeenAt = nowIso;
499
+ if (options.role)
500
+ membership.role = normalizeRole(options.role);
501
+ role = membership.role;
502
+ }
503
+ else {
504
+ store.memberships[membershipId] = {
505
+ id: membershipId,
506
+ organizationId: scoped.organizationId,
507
+ workspaceId: scoped.workspaceId,
508
+ userId: options.userId,
509
+ role,
510
+ createdAt: nowIso,
511
+ lastSeenAt: nowIso,
512
+ };
513
+ }
514
+ const repo = store.repos[scoped.repoId];
515
+ if (repo) {
516
+ repo.lastSeenAt = nowIso;
517
+ }
518
+ else {
519
+ store.repos[scoped.repoId] = {
520
+ id: scoped.repoId,
521
+ organizationId: scoped.organizationId,
522
+ workspaceId: scoped.workspaceId,
523
+ name: scoped.repoName,
524
+ createdAt: nowIso,
525
+ lastSeenAt: nowIso,
526
+ };
527
+ const ws = store.workspaces[scoped.workspaceKey];
528
+ if (!ws.repoIds.includes(scoped.repoId))
529
+ ws.repoIds.push(scoped.repoId);
530
+ }
531
+ const snapshot = {
532
+ id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
533
+ createdAt: nowIso,
534
+ scannedAt: report.scannedAt,
535
+ organizationId: scoped.organizationId,
536
+ workspaceId: scoped.workspaceId,
537
+ userId: options.userId,
538
+ role,
539
+ plan: normalizePlan(store.organizations[scoped.organizationId]?.plan ?? requestedPlan),
540
+ repoId: scoped.repoId,
541
+ repoName: scoped.repoName,
542
+ targetPath: report.targetPath,
543
+ totalScore: report.totalScore,
544
+ totalIssues: report.totalIssues,
545
+ totalFiles: report.totalFiles,
546
+ summary: {
547
+ errors: report.summary.errors,
548
+ warnings: report.summary.warnings,
549
+ infos: report.summary.infos,
550
+ },
551
+ };
552
+ store.snapshots.push(snapshot);
553
+ applyRetention(store);
554
+ saveStore(storeFile, store);
555
+ return snapshot;
556
+ }
557
+ function matchesTenantScope(snapshot, options) {
558
+ if (!options?.organizationId && !options?.workspaceId)
559
+ return true;
560
+ if (options.organizationId && snapshot.organizationId !== options.organizationId)
561
+ return false;
562
+ if (options.workspaceId && snapshot.workspaceId !== options.workspaceId)
563
+ return false;
564
+ return true;
565
+ }
566
+ export function listSaasSnapshots(options) {
567
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath());
568
+ const store = loadStoreInternal(storeFile, options?.policy);
569
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId);
570
+ if (options?.actorUserId || shouldEnforceActorForScope) {
571
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID;
572
+ assertPermissionInStore(store, {
573
+ operation: 'snapshot:read',
574
+ organizationId,
575
+ workspaceId: options?.workspaceId,
576
+ actorUserId: options?.actorUserId,
577
+ });
578
+ }
579
+ saveStore(storeFile, store);
580
+ return store.snapshots
581
+ .filter((snapshot) => matchesTenantScope(snapshot, options))
582
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
583
+ }
584
+ export function getSaasSummary(options) {
585
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath());
586
+ const store = loadStoreInternal(storeFile, options?.policy);
587
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId);
588
+ if (options?.actorUserId || shouldEnforceActorForScope) {
589
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID;
590
+ assertPermissionInStore(store, {
591
+ operation: 'summary:read',
592
+ organizationId,
593
+ workspaceId: options?.workspaceId,
594
+ actorUserId: options?.actorUserId,
595
+ });
596
+ }
597
+ saveStore(storeFile, store);
598
+ const scopedSnapshots = store.snapshots.filter((snapshot) => matchesTenantScope(snapshot, options));
599
+ const scopedWorkspaces = Object.values(store.workspaces).filter((workspace) => {
600
+ if (options?.organizationId && workspace.organizationId !== options.organizationId)
601
+ return false;
602
+ if (options?.workspaceId && workspace.id !== options.workspaceId)
603
+ return false;
604
+ return true;
605
+ });
606
+ const scopedRepos = Object.values(store.repos).filter((repo) => {
607
+ if (options?.organizationId && repo.organizationId !== options.organizationId)
608
+ return false;
609
+ if (options?.workspaceId && repo.workspaceId !== options.workspaceId)
610
+ return false;
611
+ return true;
612
+ });
613
+ const usersRegistered = options?.organizationId || options?.workspaceId
614
+ ? new Set(scopedSnapshots.map((snapshot) => snapshot.userId)).size
615
+ : Object.keys(store.users).length;
616
+ const workspacesActive = scopedWorkspaces.filter(isWorkspaceActive).length;
617
+ const reposActive = scopedRepos.filter(isRepoActive).length;
618
+ const runsPerMonth = {};
619
+ for (const snapshot of scopedSnapshots) {
620
+ const key = monthKey(snapshot.createdAt);
621
+ runsPerMonth[key] = (runsPerMonth[key] ?? 0) + 1;
622
+ }
623
+ const thresholdReached = usersRegistered >= store.policy.freeUserThreshold;
624
+ return {
625
+ policy: store.policy,
626
+ usersRegistered,
627
+ workspacesActive,
628
+ reposActive,
629
+ runsPerMonth,
630
+ totalSnapshots: scopedSnapshots.length,
631
+ phase: thresholdReached ? 'paid' : 'free',
632
+ thresholdReached,
633
+ freeUsersRemaining: Math.max(0, store.policy.freeUserThreshold - usersRegistered),
634
+ };
635
+ }
636
+ function escapeHtml(value) {
637
+ return value
638
+ .replaceAll('&', '&amp;')
639
+ .replaceAll('<', '&lt;')
640
+ .replaceAll('>', '&gt;')
641
+ .replaceAll('"', '&quot;')
642
+ .replaceAll("'", '&#39;');
643
+ }
644
+ export function generateSaasDashboardHtml(options) {
645
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath());
646
+ const store = loadStoreInternal(storeFile, options?.policy);
647
+ const summary = getSaasSummary(options);
648
+ const workspaceStats = Object.values(store.workspaces)
649
+ .map((workspace) => {
650
+ const snapshots = store.snapshots.filter((snapshot) => {
651
+ return snapshot.organizationId === workspace.organizationId && snapshot.workspaceId === workspace.id;
652
+ });
653
+ const runs = snapshots.length;
654
+ const avgScore = runs === 0
655
+ ? 0
656
+ : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs);
657
+ const lastRun = snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.createdAt ?? 'n/a';
658
+ return {
659
+ organizationId: workspace.organizationId,
660
+ id: workspace.id,
661
+ runs,
662
+ avgScore,
663
+ lastRun,
664
+ };
665
+ })
666
+ .sort((a, b) => b.avgScore - a.avgScore);
667
+ const repoStats = Object.values(store.repos)
668
+ .map((repo) => {
669
+ const snapshots = store.snapshots.filter((snapshot) => snapshot.repoId === repo.id);
670
+ const runs = snapshots.length;
671
+ const avgScore = runs === 0
672
+ ? 0
673
+ : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs);
674
+ return {
675
+ workspaceId: repo.workspaceId,
676
+ name: repo.name,
677
+ runs,
678
+ avgScore,
679
+ };
680
+ })
681
+ .sort((a, b) => b.avgScore - a.avgScore)
682
+ .slice(0, 15);
683
+ const runsRows = Object.entries(summary.runsPerMonth)
684
+ .sort(([a], [b]) => a.localeCompare(b))
685
+ .map(([month, count]) => {
686
+ const width = Math.max(8, count * 8);
687
+ return `<tr><td>${escapeHtml(month)}</td><td>${count}</td><td><div class="bar" style="width:${width}px"></div></td></tr>`;
688
+ })
689
+ .join('');
690
+ const workspaceRows = workspaceStats
691
+ .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>`)
692
+ .join('');
693
+ const repoRows = repoStats
694
+ .map((repo) => `<tr><td>${escapeHtml(repo.workspaceId)}</td><td>${escapeHtml(repo.name)}</td><td>${repo.runs}</td><td>${repo.avgScore}</td></tr>`)
695
+ .join('');
696
+ return `<!doctype html>
697
+ <html lang="en">
698
+ <head>
699
+ <meta charset="utf-8" />
700
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
701
+ <title>drift cloud dashboard</title>
702
+ <style>
703
+ :root { color-scheme: light; }
704
+ body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: #f4f7fb; color: #0f172a; }
705
+ main { max-width: 980px; margin: 0 auto; padding: 24px; }
706
+ h1 { margin: 0 0 6px; }
707
+ p.meta { margin: 0 0 20px; color: #475569; }
708
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; }
709
+ .card { background: #ffffff; border-radius: 10px; padding: 14px; border: 1px solid #dbe3ef; }
710
+ .card .label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; }
711
+ .card .value { font-size: 26px; font-weight: 700; margin-top: 4px; }
712
+ table { width: 100%; border-collapse: collapse; margin-top: 10px; background: #ffffff; border: 1px solid #dbe3ef; border-radius: 10px; overflow: hidden; }
713
+ th, td { padding: 10px; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 14px; }
714
+ th { background: #eef2f9; }
715
+ .section { margin-top: 18px; }
716
+ .bar { height: 10px; background: linear-gradient(90deg, #0ea5e9, #22c55e); border-radius: 999px; }
717
+ .pill { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; font-weight: 600; }
718
+ .pill.free { background: #dcfce7; color: #166534; }
719
+ .pill.paid { background: #fee2e2; color: #991b1b; }
720
+ </style>
721
+ </head>
722
+ <body>
723
+ <main>
724
+ <h1>drift cloud dashboard</h1>
725
+ <p class="meta">Store: ${escapeHtml(storeFile)}</p>
726
+ <div class="cards">
727
+ <div class="card"><div class="label">Plan Phase</div><div class="value"><span class="pill ${summary.phase}">${summary.phase.toUpperCase()}</span></div></div>
728
+ <div class="card"><div class="label">Users</div><div class="value">${summary.usersRegistered}</div></div>
729
+ <div class="card"><div class="label">Active Workspaces</div><div class="value">${summary.workspacesActive}</div></div>
730
+ <div class="card"><div class="label">Active Repos</div><div class="value">${summary.reposActive}</div></div>
731
+ <div class="card"><div class="label">Snapshots</div><div class="value">${summary.totalSnapshots}</div></div>
732
+ <div class="card"><div class="label">Free Seats Left</div><div class="value">${summary.freeUsersRemaining}</div></div>
733
+ </div>
734
+
735
+ <section class="section">
736
+ <h2>Runs Per Month</h2>
737
+ <table>
738
+ <thead><tr><th>Month</th><th>Runs</th><th>Trend</th></tr></thead>
739
+ <tbody>${runsRows || '<tr><td colspan="3">No runs yet</td></tr>'}</tbody>
740
+ </table>
741
+ </section>
742
+
743
+ <section class="section">
744
+ <h2>Workspace Hotspots</h2>
745
+ <table>
746
+ <thead><tr><th>Organization</th><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead>
747
+ <tbody>${workspaceRows || '<tr><td colspan="5">No workspace data</td></tr>'}</tbody>
748
+ </table>
749
+ </section>
750
+
751
+ <section class="section">
752
+ <h2>Repo Hotspots</h2>
753
+ <table>
754
+ <thead><tr><th>Workspace</th><th>Repo</th><th>Runs</th><th>Avg Score</th></tr></thead>
755
+ <tbody>${repoRows || '<tr><td colspan="4">No repo data</td></tr>'}</tbody>
756
+ </table>
757
+ </section>
758
+ </main>
759
+ </body>
760
+ </html>`;
761
+ }
762
+ //# sourceMappingURL=saas.js.map