@eduardbar/drift 1.3.0 → 1.4.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 (168) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +60 -0
  3. package/.github/actions/drift-review/action.yml +131 -0
  4. package/.github/actions/drift-scan/README.md +28 -32
  5. package/.github/actions/drift-scan/action.yml +78 -14
  6. package/.github/workflows/review-pr.yml +34 -41
  7. package/AGENTS.md +75 -251
  8. package/CHANGELOG.md +28 -0
  9. package/README.md +148 -41
  10. package/dist/benchmark.d.ts +1 -1
  11. package/dist/benchmark.js +71 -52
  12. package/dist/cli.js +243 -8
  13. package/dist/config.js +16 -2
  14. package/dist/diff.js +42 -50
  15. package/dist/doctor.d.ts +5 -0
  16. package/dist/doctor.js +133 -0
  17. package/dist/format.d.ts +17 -0
  18. package/dist/format.js +45 -0
  19. package/dist/guard-types.d.ts +57 -0
  20. package/dist/guard-types.js +2 -0
  21. package/dist/guard.d.ts +14 -0
  22. package/dist/guard.js +239 -0
  23. package/dist/index.d.ts +10 -3
  24. package/dist/index.js +4 -1
  25. package/dist/init.d.ts +15 -0
  26. package/dist/init.js +273 -0
  27. package/dist/map-cycles.d.ts +2 -0
  28. package/dist/map-cycles.js +34 -0
  29. package/dist/map-svg.d.ts +19 -0
  30. package/dist/map-svg.js +97 -0
  31. package/dist/map.js +78 -138
  32. package/dist/metrics.js +70 -55
  33. package/dist/output-metadata.d.ts +13 -0
  34. package/dist/output-metadata.js +17 -0
  35. package/dist/plugins-capabilities.d.ts +4 -0
  36. package/dist/plugins-capabilities.js +21 -0
  37. package/dist/plugins-messages.d.ts +10 -0
  38. package/dist/plugins-messages.js +16 -0
  39. package/dist/plugins-rules.d.ts +9 -0
  40. package/dist/plugins-rules.js +137 -0
  41. package/dist/plugins.d.ts +1 -1
  42. package/dist/plugins.js +45 -142
  43. package/dist/reporter-constants.d.ts +16 -0
  44. package/dist/reporter-constants.js +39 -0
  45. package/dist/reporter.d.ts +3 -3
  46. package/dist/reporter.js +35 -55
  47. package/dist/review.d.ts +2 -1
  48. package/dist/review.js +2 -1
  49. package/dist/rules/phase3-configurable.js +23 -15
  50. package/dist/saas/constants.d.ts +15 -0
  51. package/dist/saas/constants.js +48 -0
  52. package/dist/saas/dashboard.d.ts +8 -0
  53. package/dist/saas/dashboard.js +132 -0
  54. package/dist/saas/errors.d.ts +19 -0
  55. package/dist/saas/errors.js +37 -0
  56. package/dist/saas/helpers.d.ts +21 -0
  57. package/dist/saas/helpers.js +110 -0
  58. package/dist/saas/ingest.d.ts +3 -0
  59. package/dist/saas/ingest.js +249 -0
  60. package/dist/saas/organization.d.ts +5 -0
  61. package/dist/saas/organization.js +82 -0
  62. package/dist/saas/plan-change.d.ts +10 -0
  63. package/dist/saas/plan-change.js +15 -0
  64. package/dist/saas/store.d.ts +21 -0
  65. package/dist/saas/store.js +159 -0
  66. package/dist/saas/types.d.ts +191 -0
  67. package/dist/saas/types.js +2 -0
  68. package/dist/saas.d.ts +8 -218
  69. package/dist/saas.js +7 -761
  70. package/dist/sarif.d.ts +74 -0
  71. package/dist/sarif.js +122 -0
  72. package/dist/trust-advanced.d.ts +14 -0
  73. package/dist/trust-advanced.js +65 -0
  74. package/dist/trust-kpi-fs.d.ts +3 -0
  75. package/dist/trust-kpi-fs.js +141 -0
  76. package/dist/trust-kpi-parse.d.ts +7 -0
  77. package/dist/trust-kpi-parse.js +186 -0
  78. package/dist/trust-kpi-types.d.ts +16 -0
  79. package/dist/trust-kpi-types.js +2 -0
  80. package/dist/trust-kpi.d.ts +1 -3
  81. package/dist/trust-kpi.js +6 -266
  82. package/dist/trust-policy.d.ts +32 -0
  83. package/dist/trust-policy.js +160 -0
  84. package/dist/trust-render.d.ts +9 -0
  85. package/dist/trust-render.js +54 -0
  86. package/dist/trust-scoring.d.ts +9 -0
  87. package/dist/trust-scoring.js +208 -0
  88. package/dist/trust.d.ts +4 -32
  89. package/dist/trust.js +29 -432
  90. package/dist/types/app.d.ts +30 -0
  91. package/dist/types/app.js +2 -0
  92. package/dist/types/config.d.ts +25 -0
  93. package/dist/types/config.js +2 -0
  94. package/dist/types/core.d.ts +100 -0
  95. package/dist/types/core.js +2 -0
  96. package/dist/types/diff.d.ts +55 -0
  97. package/dist/types/diff.js +2 -0
  98. package/dist/types/plugin.d.ts +41 -0
  99. package/dist/types/plugin.js +2 -0
  100. package/dist/types/trust.d.ts +120 -0
  101. package/dist/types/trust.js +2 -0
  102. package/dist/types.d.ts +8 -365
  103. package/docs/release-notes-draft.md +40 -0
  104. package/docs/rules-catalog.md +49 -0
  105. package/docs/trust-core-release-checklist.md +37 -5
  106. package/package.json +3 -2
  107. package/packages/vscode-drift/src/code-actions.ts +1 -1
  108. package/schemas/drift-ai-output.v1.json +162 -0
  109. package/schemas/drift-report.v1.json +151 -0
  110. package/schemas/drift-trust.v1.json +131 -0
  111. package/scripts/smoke-repo.mjs +394 -0
  112. package/src/benchmark.ts +75 -53
  113. package/src/cli.ts +285 -13
  114. package/src/config.ts +19 -2
  115. package/src/diff.ts +57 -48
  116. package/src/doctor.ts +173 -0
  117. package/src/format.ts +81 -0
  118. package/src/guard-types.ts +64 -0
  119. package/src/guard.ts +324 -0
  120. package/src/index.ts +35 -0
  121. package/src/init.ts +298 -0
  122. package/src/map-cycles.ts +38 -0
  123. package/src/map-svg.ts +124 -0
  124. package/src/map.ts +111 -142
  125. package/src/metrics.ts +78 -59
  126. package/src/output-metadata.ts +30 -0
  127. package/src/plugins-capabilities.ts +36 -0
  128. package/src/plugins-messages.ts +35 -0
  129. package/src/plugins-rules.ts +296 -0
  130. package/src/plugins.ts +76 -283
  131. package/src/reporter-constants.ts +46 -0
  132. package/src/reporter.ts +64 -65
  133. package/src/review.ts +4 -2
  134. package/src/rules/phase3-configurable.ts +39 -26
  135. package/src/saas/constants.ts +56 -0
  136. package/src/saas/dashboard.ts +172 -0
  137. package/src/saas/errors.ts +45 -0
  138. package/src/saas/helpers.ts +140 -0
  139. package/src/saas/ingest.ts +278 -0
  140. package/src/saas/organization.ts +99 -0
  141. package/src/saas/plan-change.ts +19 -0
  142. package/src/saas/store.ts +172 -0
  143. package/src/saas/types.ts +216 -0
  144. package/src/saas.ts +49 -1031
  145. package/src/sarif.ts +232 -0
  146. package/src/trust-advanced.ts +99 -0
  147. package/src/trust-kpi-fs.ts +169 -0
  148. package/src/trust-kpi-parse.ts +219 -0
  149. package/src/trust-kpi-types.ts +19 -0
  150. package/src/trust-kpi.ts +8 -316
  151. package/src/trust-policy.ts +246 -0
  152. package/src/trust-render.ts +61 -0
  153. package/src/trust-scoring.ts +231 -0
  154. package/src/trust.ts +62 -576
  155. package/src/types/app.ts +30 -0
  156. package/src/types/config.ts +27 -0
  157. package/src/types/core.ts +105 -0
  158. package/src/types/diff.ts +61 -0
  159. package/src/types/plugin.ts +46 -0
  160. package/src/types/trust.ts +134 -0
  161. package/src/types.ts +78 -409
  162. package/tests/cli-sarif.test.ts +92 -0
  163. package/tests/format.test.ts +157 -0
  164. package/tests/new-features.test.ts +10 -2
  165. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  166. package/tests/sarif.test.ts +160 -0
  167. package/tests/trust-kpi.test.ts +31 -4
  168. package/tests/trust.test.ts +18 -0
@@ -0,0 +1,110 @@
1
+ import { ACTIVE_WINDOW_DAYS, DEFAULT_ORGANIZATION_ID, DEFAULT_SAAS_POLICY, ROLE_PRIORITY, VALID_PLANS, VALID_ROLES, daysAgo, } from './constants.js';
2
+ export function resolveSaasPolicy(policy) {
3
+ const customPlanLimits = (policy && 'maxWorkspacesPerOrganizationByPlan' in policy)
4
+ ? (policy.maxWorkspacesPerOrganizationByPlan ?? {})
5
+ : {};
6
+ return {
7
+ ...DEFAULT_SAAS_POLICY,
8
+ ...(policy ?? {}),
9
+ maxWorkspacesPerOrganizationByPlan: {
10
+ ...DEFAULT_SAAS_POLICY.maxWorkspacesPerOrganizationByPlan,
11
+ ...customPlanLimits,
12
+ },
13
+ };
14
+ }
15
+ export function normalizePlan(plan) {
16
+ if (!plan)
17
+ return 'free';
18
+ return VALID_PLANS.includes(plan) ? plan : 'free';
19
+ }
20
+ export function normalizeRole(role) {
21
+ if (!role)
22
+ return 'member';
23
+ return VALID_ROLES.includes(role) ? role : 'member';
24
+ }
25
+ export function hasRoleAtLeast(role, requiredRole) {
26
+ if (!role)
27
+ return false;
28
+ return ROLE_PRIORITY[role] >= ROLE_PRIORITY[requiredRole];
29
+ }
30
+ export function workspaceKey(organizationId, workspaceId) {
31
+ return `${organizationId}:${workspaceId}`;
32
+ }
33
+ function repoKey(organizationId, workspaceId, repoName) {
34
+ return `${workspaceKey(organizationId, workspaceId)}:${repoName}`;
35
+ }
36
+ export function membershipKey(organizationId, workspaceId, userId) {
37
+ return `${workspaceKey(organizationId, workspaceId)}:${userId}`;
38
+ }
39
+ export function monthKey(isoDate) {
40
+ const date = new Date(isoDate);
41
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
42
+ return `${date.getUTCFullYear()}-${month}`;
43
+ }
44
+ export function resolveScopedIdentity(options) {
45
+ const organizationId = options.organizationId ?? DEFAULT_ORGANIZATION_ID;
46
+ const workspaceId = options.workspaceId;
47
+ const repoName = options.repoName ?? 'default';
48
+ return {
49
+ organizationId,
50
+ workspaceId,
51
+ workspaceKey: workspaceKey(organizationId, workspaceId),
52
+ repoName,
53
+ repoId: repoKey(organizationId, workspaceId, repoName),
54
+ };
55
+ }
56
+ export function isWorkspaceActive(workspace) {
57
+ return new Date(workspace.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS);
58
+ }
59
+ export function isRepoActive(repo) {
60
+ return new Date(repo.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS);
61
+ }
62
+ export function matchesTenantScope(snapshot, options) {
63
+ if (!options?.organizationId && !options?.workspaceId)
64
+ return true;
65
+ if (options.organizationId && snapshot.organizationId !== options.organizationId)
66
+ return false;
67
+ if (options.workspaceId && snapshot.workspaceId !== options.workspaceId)
68
+ return false;
69
+ return true;
70
+ }
71
+ export function matchesWorkspaceScope(workspace, options) {
72
+ if (options?.organizationId && workspace.organizationId !== options.organizationId)
73
+ return false;
74
+ if (options?.workspaceId && workspace.id !== options.workspaceId)
75
+ return false;
76
+ return true;
77
+ }
78
+ export function matchesRepoScope(repo, options) {
79
+ if (options?.organizationId && repo.organizationId !== options.organizationId)
80
+ return false;
81
+ if (options?.workspaceId && repo.workspaceId !== options.workspaceId)
82
+ return false;
83
+ return true;
84
+ }
85
+ export function computeRunsPerMonth(snapshots) {
86
+ const runsPerMonth = {};
87
+ for (const snapshot of snapshots) {
88
+ const key = monthKey(snapshot.createdAt);
89
+ runsPerMonth[key] = (runsPerMonth[key] ?? 0) + 1;
90
+ }
91
+ return runsPerMonth;
92
+ }
93
+ export function computeUsersRegistered(store, snapshots, options) {
94
+ if (!options?.organizationId && !options?.workspaceId)
95
+ return Object.keys(store.users).length;
96
+ return new Set(snapshots.map((snapshot) => snapshot.userId)).size;
97
+ }
98
+ export function escapeHtml(value) {
99
+ return value
100
+ .replaceAll('&', '&')
101
+ .replaceAll('<', '&lt;')
102
+ .replaceAll('>', '&gt;')
103
+ .replaceAll('"', '&quot;')
104
+ .replaceAll("'", '&#39;');
105
+ }
106
+ export function mergePolicy(policy, base) {
107
+ return resolveSaasPolicy({ ...base, ...(policy ?? {}) });
108
+ }
109
+ export { DEFAULT_ORGANIZATION_ID };
110
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1,3 @@
1
+ import type { DriftReportInput, IngestOptions, SaasSnapshot } from './types.js';
2
+ export declare function ingestSnapshotFromReport(report: DriftReportInput, options: IngestOptions): SaasSnapshot;
3
+ //# sourceMappingURL=ingest.d.ts.map
@@ -0,0 +1,249 @@
1
+ import { resolve } from 'node:path';
2
+ import { createRandomId } from './constants.js';
3
+ import { SaasActorRequiredError } from './errors.js';
4
+ import { applyRetentionPolicy, assertPermissionInStore, defaultSaasStorePath, loadStoreInternal, saveStore } from './store.js';
5
+ import { membershipKey, monthKey, normalizePlan, normalizeRole, resolveScopedIdentity } from './helpers.js';
6
+ import { appendPlanChange } from './plan-change.js';
7
+ function assertWorkspaceLimit(store, scoped, effectivePlan) {
8
+ const organization = store.organizations[scoped.organizationId];
9
+ const workspaceLimit = store.policy.maxWorkspacesPerOrganizationByPlan[effectivePlan];
10
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey]);
11
+ const workspaceCount = organization?.workspaceIds.length ?? 0;
12
+ if (!workspaceExists && workspaceCount >= workspaceLimit) {
13
+ throw new Error(`Organization '${scoped.organizationId}' on plan '${effectivePlan}' reached max workspaces (${workspaceLimit}).`);
14
+ }
15
+ }
16
+ function assertFreeThresholdLimit(store, userId) {
17
+ const usersRegistered = Object.keys(store.users).length;
18
+ const isFreePhase = usersRegistered < store.policy.freeUserThreshold;
19
+ if (!isFreePhase)
20
+ return;
21
+ if (!store.users[userId] && usersRegistered + 1 > store.policy.freeUserThreshold) {
22
+ throw new Error(`Free threshold reached (${store.policy.freeUserThreshold} users).`);
23
+ }
24
+ }
25
+ function assertRepoLimit(store, scoped) {
26
+ const workspace = store.workspaces[scoped.workspaceKey];
27
+ const repoExists = Boolean(store.repos[scoped.repoId]);
28
+ const repoCount = workspace?.repoIds.length ?? 0;
29
+ if (!repoExists && repoCount >= store.policy.maxReposPerWorkspace) {
30
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`);
31
+ }
32
+ }
33
+ function countWorkspaceRunsThisMonth(store, scoped, currentMonth) {
34
+ return store.snapshots.filter((snapshot) => {
35
+ return snapshot.organizationId === scoped.organizationId
36
+ && snapshot.workspaceId === scoped.workspaceId
37
+ && monthKey(snapshot.createdAt) === currentMonth;
38
+ }).length;
39
+ }
40
+ function assertGuardrails(store, options, nowIso) {
41
+ const scoped = resolveScopedIdentity(options);
42
+ const organization = store.organizations[scoped.organizationId];
43
+ const effectivePlan = normalizePlan(options.plan ?? organization?.plan);
44
+ assertWorkspaceLimit(store, scoped, effectivePlan);
45
+ assertFreeThresholdLimit(store, options.userId);
46
+ assertRepoLimit(store, scoped);
47
+ const currentMonth = monthKey(nowIso);
48
+ const runsThisMonth = countWorkspaceRunsThisMonth(store, scoped, currentMonth);
49
+ if (runsThisMonth >= store.policy.maxRunsPerWorkspacePerMonth) {
50
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max monthly runs (${store.policy.maxRunsPerWorkspacePerMonth}).`);
51
+ }
52
+ }
53
+ function upsertUser(store, userId, nowIso) {
54
+ const user = store.users[userId];
55
+ if (user) {
56
+ user.lastSeenAt = nowIso;
57
+ return;
58
+ }
59
+ store.users[userId] = {
60
+ id: userId,
61
+ createdAt: nowIso,
62
+ lastSeenAt: nowIso,
63
+ };
64
+ }
65
+ function maybeUpdateOrganizationPlanFromIngest(context) {
66
+ const { store, scoped, requestedPlan, options, nowIso } = context;
67
+ const existingOrg = store.organizations[scoped.organizationId];
68
+ if (!existingOrg || !options.plan || existingOrg.plan === requestedPlan)
69
+ return;
70
+ if (options.actorUserId) {
71
+ assertPermissionInStore(store, {
72
+ operation: 'billing:write',
73
+ organizationId: scoped.organizationId,
74
+ actorUserId: options.actorUserId,
75
+ });
76
+ }
77
+ const previousPlan = existingOrg.plan;
78
+ existingOrg.plan = requestedPlan;
79
+ appendPlanChange(store, {
80
+ organizationId: scoped.organizationId,
81
+ fromPlan: previousPlan,
82
+ toPlan: requestedPlan,
83
+ changedAt: nowIso,
84
+ changedByUserId: options.actorUserId ?? options.userId,
85
+ reason: 'ingest-option-plan-change',
86
+ });
87
+ }
88
+ function ensureOrganizationForIngest(context) {
89
+ const { store, scoped, requestedPlan, nowIso } = context;
90
+ const existingOrg = store.organizations[scoped.organizationId];
91
+ if (existingOrg) {
92
+ existingOrg.lastSeenAt = nowIso;
93
+ maybeUpdateOrganizationPlanFromIngest(context);
94
+ return;
95
+ }
96
+ store.organizations[scoped.organizationId] = {
97
+ id: scoped.organizationId,
98
+ plan: requestedPlan,
99
+ createdAt: nowIso,
100
+ lastSeenAt: nowIso,
101
+ workspaceIds: [],
102
+ };
103
+ }
104
+ function upsertWorkspaceForIngest(store, scoped, userId, nowIso) {
105
+ const workspace = store.workspaces[scoped.workspaceKey];
106
+ if (workspace) {
107
+ workspace.lastSeenAt = nowIso;
108
+ if (!workspace.userIds.includes(userId))
109
+ workspace.userIds.push(userId);
110
+ return { wasCreated: false };
111
+ }
112
+ store.workspaces[scoped.workspaceKey] = {
113
+ id: scoped.workspaceId,
114
+ organizationId: scoped.organizationId,
115
+ createdAt: nowIso,
116
+ lastSeenAt: nowIso,
117
+ userIds: [userId],
118
+ repoIds: [],
119
+ };
120
+ const org = store.organizations[scoped.organizationId];
121
+ if (!org.workspaceIds.includes(scoped.workspaceId))
122
+ org.workspaceIds.push(scoped.workspaceId);
123
+ return { wasCreated: true };
124
+ }
125
+ function upsertMembershipForIngest(context, workspaceWasCreated) {
126
+ const { store, scoped, options, nowIso } = context;
127
+ const membershipId = membershipKey(scoped.organizationId, scoped.workspaceId, options.userId);
128
+ const membership = store.memberships[membershipId];
129
+ let role = normalizeRole(options.role);
130
+ if (!membership && workspaceWasCreated)
131
+ role = 'owner';
132
+ if (membership) {
133
+ membership.lastSeenAt = nowIso;
134
+ if (options.role)
135
+ membership.role = normalizeRole(options.role);
136
+ return membership.role;
137
+ }
138
+ store.memberships[membershipId] = {
139
+ id: membershipId,
140
+ organizationId: scoped.organizationId,
141
+ workspaceId: scoped.workspaceId,
142
+ userId: options.userId,
143
+ role,
144
+ createdAt: nowIso,
145
+ lastSeenAt: nowIso,
146
+ };
147
+ return role;
148
+ }
149
+ function upsertRepoForIngest(store, scoped, nowIso) {
150
+ const repo = store.repos[scoped.repoId];
151
+ if (repo) {
152
+ repo.lastSeenAt = nowIso;
153
+ return;
154
+ }
155
+ store.repos[scoped.repoId] = {
156
+ id: scoped.repoId,
157
+ organizationId: scoped.organizationId,
158
+ workspaceId: scoped.workspaceId,
159
+ name: scoped.repoName,
160
+ createdAt: nowIso,
161
+ lastSeenAt: nowIso,
162
+ };
163
+ const workspace = store.workspaces[scoped.workspaceKey];
164
+ if (!workspace.repoIds.includes(scoped.repoId))
165
+ workspace.repoIds.push(scoped.repoId);
166
+ }
167
+ function createSnapshotFromReport(report, context, role) {
168
+ const { store, scoped, options, nowIso, requestedPlan } = context;
169
+ return {
170
+ id: createRandomId(String(Date.now())),
171
+ createdAt: nowIso,
172
+ scannedAt: report.scannedAt,
173
+ organizationId: scoped.organizationId,
174
+ workspaceId: scoped.workspaceId,
175
+ userId: options.userId,
176
+ role,
177
+ plan: normalizePlan(store.organizations[scoped.organizationId]?.plan ?? requestedPlan),
178
+ repoId: scoped.repoId,
179
+ repoName: scoped.repoName,
180
+ targetPath: report.targetPath,
181
+ totalScore: report.totalScore,
182
+ totalIssues: report.totalIssues,
183
+ totalFiles: report.totalFiles,
184
+ summary: {
185
+ errors: report.summary.errors,
186
+ warnings: report.summary.warnings,
187
+ infos: report.summary.infos,
188
+ },
189
+ };
190
+ }
191
+ function assertIngestActorRequirement(options, scoped, store) {
192
+ if (!store.policy.strictActorEnforcement || options.actorUserId)
193
+ return;
194
+ throw new SaasActorRequiredError({
195
+ operation: 'snapshot:write',
196
+ organizationId: scoped.organizationId,
197
+ workspaceId: scoped.workspaceId,
198
+ });
199
+ }
200
+ function assertIngestPermissionForActor(store, scoped, actorUserId) {
201
+ if (!actorUserId)
202
+ return;
203
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey]);
204
+ const organizationExists = Boolean(store.organizations[scoped.organizationId]);
205
+ if (workspaceExists) {
206
+ assertPermissionInStore(store, {
207
+ operation: 'snapshot:write',
208
+ organizationId: scoped.organizationId,
209
+ workspaceId: scoped.workspaceId,
210
+ actorUserId,
211
+ });
212
+ return;
213
+ }
214
+ if (organizationExists) {
215
+ assertPermissionInStore(store, {
216
+ operation: 'billing:write',
217
+ organizationId: scoped.organizationId,
218
+ actorUserId,
219
+ });
220
+ }
221
+ }
222
+ export function ingestSnapshotFromReport(report, options) {
223
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
224
+ const store = loadStoreInternal(storeFile, options.policy);
225
+ const nowIso = new Date().toISOString();
226
+ const scoped = resolveScopedIdentity(options);
227
+ const requestedPlan = normalizePlan(options.plan);
228
+ const context = {
229
+ store,
230
+ scoped,
231
+ options,
232
+ nowIso,
233
+ requestedPlan,
234
+ };
235
+ assertIngestActorRequirement(options, scoped, store);
236
+ assertIngestPermissionForActor(store, scoped, options.actorUserId);
237
+ assertGuardrails(store, options, nowIso);
238
+ upsertUser(store, options.userId, nowIso);
239
+ ensureOrganizationForIngest(context);
240
+ const workspaceState = upsertWorkspaceForIngest(store, scoped, options.userId, nowIso);
241
+ const role = upsertMembershipForIngest(context, workspaceState.wasCreated);
242
+ upsertRepoForIngest(store, scoped, nowIso);
243
+ const snapshot = createSnapshotFromReport(report, context, role);
244
+ store.snapshots.push(snapshot);
245
+ applyRetentionPolicy(store);
246
+ saveStore(storeFile, store);
247
+ return snapshot;
248
+ }
249
+ //# sourceMappingURL=ingest.js.map
@@ -0,0 +1,5 @@
1
+ import type { ChangeOrganizationPlanOptions, SaasOrganizationUsageSnapshot, SaasPlanChange, SaasPlanChangeQueryOptions, SaasUsageQueryOptions } from './types.js';
2
+ export declare function changeOrganizationPlan(options: ChangeOrganizationPlanOptions): SaasPlanChange;
3
+ export declare function listOrganizationPlanChanges(options: SaasPlanChangeQueryOptions): SaasPlanChange[];
4
+ export declare function getOrganizationUsageSnapshot(options: SaasUsageQueryOptions): SaasOrganizationUsageSnapshot;
5
+ //# sourceMappingURL=organization.d.ts.map
@@ -0,0 +1,82 @@
1
+ import { resolve } from 'node:path';
2
+ import { assertPermissionInStore, defaultSaasStorePath, loadStoreInternal, saveStore } from './store.js';
3
+ import { monthKey, normalizePlan, workspaceKey } from './helpers.js';
4
+ import { appendPlanChange } from './plan-change.js';
5
+ export function changeOrganizationPlan(options) {
6
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
7
+ const store = loadStoreInternal(storeFile, options.policy);
8
+ const nowIso = new Date().toISOString();
9
+ const organization = store.organizations[options.organizationId];
10
+ if (!organization)
11
+ throw new Error(`Organization '${options.organizationId}' does not exist.`);
12
+ assertPermissionInStore(store, {
13
+ operation: 'billing:write',
14
+ organizationId: options.organizationId,
15
+ actorUserId: options.actorUserId,
16
+ });
17
+ const nextPlan = normalizePlan(options.newPlan);
18
+ if (organization.plan === nextPlan) {
19
+ const unchanged = appendPlanChange(store, {
20
+ organizationId: organization.id,
21
+ fromPlan: organization.plan,
22
+ toPlan: nextPlan,
23
+ changedAt: nowIso,
24
+ changedByUserId: options.actorUserId,
25
+ reason: options.reason,
26
+ });
27
+ saveStore(storeFile, store);
28
+ return unchanged;
29
+ }
30
+ const previousPlan = organization.plan;
31
+ organization.plan = nextPlan;
32
+ organization.lastSeenAt = nowIso;
33
+ const change = appendPlanChange(store, {
34
+ organizationId: organization.id,
35
+ fromPlan: previousPlan,
36
+ toPlan: nextPlan,
37
+ changedAt: nowIso,
38
+ changedByUserId: options.actorUserId,
39
+ reason: options.reason,
40
+ });
41
+ saveStore(storeFile, store);
42
+ return change;
43
+ }
44
+ export function listOrganizationPlanChanges(options) {
45
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
46
+ const store = loadStoreInternal(storeFile, options.policy);
47
+ assertPermissionInStore(store, {
48
+ operation: 'billing:read',
49
+ organizationId: options.organizationId,
50
+ actorUserId: options.actorUserId,
51
+ });
52
+ return store.planChanges
53
+ .filter((change) => change.organizationId === options.organizationId)
54
+ .sort((a, b) => b.changedAt.localeCompare(a.changedAt));
55
+ }
56
+ export function getOrganizationUsageSnapshot(options) {
57
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
58
+ const store = loadStoreInternal(storeFile, options.policy);
59
+ assertPermissionInStore(store, {
60
+ operation: 'billing:read',
61
+ organizationId: options.organizationId,
62
+ actorUserId: options.actorUserId,
63
+ });
64
+ const organization = store.organizations[options.organizationId];
65
+ if (!organization)
66
+ throw new Error(`Organization '${options.organizationId}' does not exist.`);
67
+ const month = options.month ?? monthKey(new Date().toISOString());
68
+ const organizationRunSnapshots = store.snapshots.filter((snapshot) => snapshot.organizationId === options.organizationId);
69
+ return {
70
+ organizationId: options.organizationId,
71
+ plan: organization.plan,
72
+ capturedAt: new Date().toISOString(),
73
+ workspaceCount: organization.workspaceIds.length,
74
+ repoCount: organization.workspaceIds
75
+ .map((workspaceId) => store.workspaces[workspaceKey(options.organizationId, workspaceId)])
76
+ .filter((workspace) => Boolean(workspace))
77
+ .reduce((count, workspace) => count + workspace.repoIds.length, 0),
78
+ runCount: organizationRunSnapshots.length,
79
+ runCountThisMonth: organizationRunSnapshots.filter((snapshot) => monthKey(snapshot.createdAt) === month).length,
80
+ };
81
+ }
82
+ //# sourceMappingURL=organization.js.map
@@ -0,0 +1,10 @@
1
+ import type { SaasPlan, SaasPlanChange, SaasStore } from './types.js';
2
+ export declare function appendPlanChange(store: SaasStore, input: {
3
+ organizationId: string;
4
+ fromPlan: SaasPlan;
5
+ toPlan: SaasPlan;
6
+ changedByUserId: string;
7
+ reason?: string;
8
+ changedAt: string;
9
+ }): SaasPlanChange;
10
+ //# sourceMappingURL=plan-change.d.ts.map
@@ -0,0 +1,15 @@
1
+ import { createRandomId } from './constants.js';
2
+ export function appendPlanChange(store, input) {
3
+ const change = {
4
+ id: createRandomId(input.changedAt),
5
+ organizationId: input.organizationId,
6
+ fromPlan: input.fromPlan,
7
+ toPlan: input.toPlan,
8
+ changedAt: input.changedAt,
9
+ changedByUserId: input.changedByUserId,
10
+ reason: input.reason,
11
+ };
12
+ store.planChanges.push(change);
13
+ return change;
14
+ }
15
+ //# sourceMappingURL=plan-change.js.map
@@ -0,0 +1,21 @@
1
+ import type { SaasEffectiveLimits, SaasOperation, SaasPermissionContext, SaasPermissionResult, SaasPlan, SaasPolicyOverrides, SaasRole, SaasStore } from './types.js';
2
+ export declare function defaultSaasStorePath(root?: string): string;
3
+ export declare function applyRetentionPolicy(store: SaasStore): void;
4
+ export declare function saveStore(storeFile: string, store: SaasStore): void;
5
+ export declare function loadStoreInternal(storeFile: string, policy?: SaasPolicyOverrides): SaasStore;
6
+ export declare function assertPermissionInStore(store: SaasStore, context: SaasPermissionContext): SaasPermissionResult;
7
+ export declare function getRequiredRoleForOperation(operation: SaasOperation): SaasRole;
8
+ export declare function assertSaasPermission(context: SaasPermissionContext & {
9
+ storeFile?: string;
10
+ policy?: SaasPolicyOverrides;
11
+ }): SaasPermissionResult;
12
+ export declare function getSaasEffectiveLimits(input: {
13
+ plan: SaasPlan;
14
+ policy?: SaasPolicyOverrides;
15
+ }): SaasEffectiveLimits;
16
+ export declare function getOrganizationEffectiveLimits(options: {
17
+ organizationId: string;
18
+ storeFile?: string;
19
+ policy?: SaasPolicyOverrides;
20
+ }): SaasEffectiveLimits;
21
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1,159 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { DEFAULT_ORGANIZATION_ID, REQUIRED_ROLE_BY_OPERATION, ROLE_PRIORITY, STORE_VERSION } from './constants.js';
4
+ import { SaasActorRequiredError, SaasPermissionError } from './errors.js';
5
+ import { hasRoleAtLeast, membershipKey, mergePolicy, normalizePlan, resolveSaasPolicy } from './helpers.js';
6
+ const HOURS_PER_DAY = 24;
7
+ const MINUTES_PER_HOUR = 60;
8
+ const SECONDS_PER_MINUTE = 60;
9
+ const MILLISECONDS_PER_SECOND = 1000;
10
+ export function defaultSaasStorePath(root = '.') {
11
+ return resolve(root, '.drift-cloud', 'store.json');
12
+ }
13
+ function createEmptyStore(policy) {
14
+ return {
15
+ version: STORE_VERSION,
16
+ policy: resolveSaasPolicy(policy),
17
+ users: {},
18
+ organizations: {},
19
+ workspaces: {},
20
+ memberships: {},
21
+ repos: {},
22
+ snapshots: [],
23
+ planChanges: [],
24
+ };
25
+ }
26
+ function ensureStoreFile(storeFile, policy) {
27
+ const dir = dirname(storeFile);
28
+ if (!existsSync(dir))
29
+ mkdirSync(dir, { recursive: true });
30
+ if (!existsSync(storeFile)) {
31
+ const initial = createEmptyStore(policy);
32
+ writeFileSync(storeFile, JSON.stringify(initial, null, 2), 'utf8');
33
+ }
34
+ }
35
+ function applyStoreEntityDefaults(store) {
36
+ for (const workspace of Object.values(store.workspaces)) {
37
+ if (!workspace.organizationId)
38
+ workspace.organizationId = DEFAULT_ORGANIZATION_ID;
39
+ }
40
+ for (const repo of Object.values(store.repos)) {
41
+ if (!repo.organizationId)
42
+ repo.organizationId = DEFAULT_ORGANIZATION_ID;
43
+ }
44
+ for (const snapshot of store.snapshots) {
45
+ if (!snapshot.organizationId)
46
+ snapshot.organizationId = DEFAULT_ORGANIZATION_ID;
47
+ if (!snapshot.plan)
48
+ snapshot.plan = 'free';
49
+ if (!snapshot.role)
50
+ snapshot.role = 'member';
51
+ }
52
+ }
53
+ function ensureOrganizationFromWorkspace(store, workspace) {
54
+ const orgId = workspace.organizationId;
55
+ const existingOrg = store.organizations[orgId];
56
+ if (!existingOrg) {
57
+ store.organizations[orgId] = {
58
+ id: orgId,
59
+ plan: 'free',
60
+ createdAt: workspace.createdAt,
61
+ lastSeenAt: workspace.lastSeenAt,
62
+ workspaceIds: [workspace.id],
63
+ };
64
+ return;
65
+ }
66
+ if (!existingOrg.workspaceIds.includes(workspace.id))
67
+ existingOrg.workspaceIds.push(workspace.id);
68
+ if (workspace.lastSeenAt > existingOrg.lastSeenAt)
69
+ existingOrg.lastSeenAt = workspace.lastSeenAt;
70
+ }
71
+ function hydrateOrganizationsFromWorkspaces(store) {
72
+ for (const workspace of Object.values(store.workspaces)) {
73
+ ensureOrganizationFromWorkspace(store, workspace);
74
+ }
75
+ }
76
+ export function applyRetentionPolicy(store) {
77
+ const millisecondsPerDay = HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;
78
+ const cutoff = Date.now() - store.policy.retentionDays * millisecondsPerDay;
79
+ store.snapshots = store.snapshots.filter((snapshot) => new Date(snapshot.createdAt).getTime() >= cutoff);
80
+ }
81
+ export function saveStore(storeFile, store) {
82
+ writeFileSync(storeFile, JSON.stringify(store, null, 2), 'utf8');
83
+ }
84
+ export function loadStoreInternal(storeFile, policy) {
85
+ ensureStoreFile(storeFile, policy);
86
+ const raw = readFileSync(storeFile, 'utf8');
87
+ const parsed = JSON.parse(raw);
88
+ const merged = createEmptyStore(parsed.policy);
89
+ merged.version = parsed.version ?? STORE_VERSION;
90
+ merged.users = parsed.users ?? {};
91
+ merged.organizations = parsed.organizations ?? {};
92
+ merged.workspaces = parsed.workspaces ?? {};
93
+ merged.memberships = parsed.memberships ?? {};
94
+ merged.repos = parsed.repos ?? {};
95
+ merged.snapshots = parsed.snapshots ?? [];
96
+ merged.planChanges = parsed.planChanges ?? [];
97
+ merged.policy = mergePolicy(policy, merged.policy);
98
+ applyStoreEntityDefaults(merged);
99
+ hydrateOrganizationsFromWorkspaces(merged);
100
+ applyRetentionPolicy(merged);
101
+ return merged;
102
+ }
103
+ function resolveActorRole(store, organizationId, actorUserId, workspaceId) {
104
+ if (workspaceId) {
105
+ const scopedMembershipId = membershipKey(organizationId, workspaceId, actorUserId);
106
+ return store.memberships[scopedMembershipId]?.role;
107
+ }
108
+ let highestRole;
109
+ for (const membership of Object.values(store.memberships)) {
110
+ if (membership.organizationId !== organizationId)
111
+ continue;
112
+ if (membership.userId !== actorUserId)
113
+ continue;
114
+ if (!highestRole || ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highestRole])
115
+ highestRole = membership.role;
116
+ if (highestRole === 'owner')
117
+ break;
118
+ }
119
+ return highestRole;
120
+ }
121
+ export function assertPermissionInStore(store, context) {
122
+ const requiredRole = REQUIRED_ROLE_BY_OPERATION[context.operation];
123
+ if (!context.actorUserId) {
124
+ if (store.policy.strictActorEnforcement)
125
+ throw new SaasActorRequiredError(context);
126
+ return { requiredRole };
127
+ }
128
+ const actorRole = resolveActorRole(store, context.organizationId, context.actorUserId, context.workspaceId);
129
+ if (!hasRoleAtLeast(actorRole, requiredRole)) {
130
+ throw new SaasPermissionError(context, requiredRole, actorRole);
131
+ }
132
+ return { requiredRole, actorRole };
133
+ }
134
+ export function getRequiredRoleForOperation(operation) {
135
+ return REQUIRED_ROLE_BY_OPERATION[operation];
136
+ }
137
+ export function assertSaasPermission(context) {
138
+ const storeFile = resolve(context.storeFile ?? defaultSaasStorePath());
139
+ const store = loadStoreInternal(storeFile, context.policy);
140
+ return assertPermissionInStore(store, context);
141
+ }
142
+ export function getSaasEffectiveLimits(input) {
143
+ const policy = resolveSaasPolicy(input.policy);
144
+ const plan = normalizePlan(input.plan);
145
+ return {
146
+ plan,
147
+ maxWorkspaces: policy.maxWorkspacesPerOrganizationByPlan[plan],
148
+ maxReposPerWorkspace: policy.maxReposPerWorkspace,
149
+ maxRunsPerWorkspacePerMonth: policy.maxRunsPerWorkspacePerMonth,
150
+ retentionDays: policy.retentionDays,
151
+ };
152
+ }
153
+ export function getOrganizationEffectiveLimits(options) {
154
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
155
+ const store = loadStoreInternal(storeFile, options.policy);
156
+ const plan = normalizePlan(store.organizations[options.organizationId]?.plan);
157
+ return getSaasEffectiveLimits({ plan, policy: store.policy });
158
+ }
159
+ //# sourceMappingURL=store.js.map