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