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