@eduardbar/drift 1.2.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/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +94 -9
- package/AGENTS.md +75 -245
- package/CHANGELOG.md +28 -0
- package/README.md +308 -51
- 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 +204 -0
- package/dist/cli.js +693 -67
- package/dist/config.js +16 -2
- package/dist/diff.js +66 -10
- 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/git.js +12 -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 +12 -3
- package/dist/index.js +6 -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 +2 -1
- package/dist/plugins.js +80 -28
- package/dist/printer.js +4 -0
- 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 +4 -3
- 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/phase3-configurable.js +23 -15
- package/dist/rules/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- 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 -82
- package/dist/saas.js +7 -320
- 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 +7 -0
- package/dist/trust-kpi.js +185 -0
- 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 +37 -0
- package/dist/trust.js +168 -0
- 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 -211
- package/docs/PRD.md +187 -109
- package/docs/plugin-contract.md +61 -0
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +87 -0
- package/package.json +6 -3
- 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/analyzer.ts +484 -155
- package/src/benchmark.ts +266 -0
- package/src/cli.ts +840 -85
- package/src/config.ts +19 -2
- package/src/diff.ts +84 -10
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/git.ts +16 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +83 -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 +148 -27
- package/src/printer.ts +4 -0
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +6 -4
- 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/phase3-configurable.ts +39 -26
- package/src/rules/shared.ts +31 -3
- 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 -433
- 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 +210 -0
- 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 +260 -0
- 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 -238
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/diff.test.ts +124 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +80 -1
- package/tests/phase1-init-doctor-guard.test.ts +199 -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/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +147 -0
- package/tests/trust.test.ts +602 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { SaasPolicyOverrides, SaasQueryOptions, SaasSnapshot, SaasSummary } from './types.js'
|
|
3
|
+
import { DASHBOARD_BAR_MIN_WIDTH, DASHBOARD_BAR_UNIT, DASHBOARD_REPO_LIMIT } from './constants.js'
|
|
4
|
+
import { assertPermissionInStore, defaultSaasStorePath, loadStoreInternal, saveStore } from './store.js'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_ORGANIZATION_ID,
|
|
7
|
+
computeRunsPerMonth,
|
|
8
|
+
computeUsersRegistered,
|
|
9
|
+
escapeHtml,
|
|
10
|
+
isRepoActive,
|
|
11
|
+
isWorkspaceActive,
|
|
12
|
+
matchesRepoScope,
|
|
13
|
+
matchesTenantScope,
|
|
14
|
+
matchesWorkspaceScope,
|
|
15
|
+
} from './helpers.js'
|
|
16
|
+
|
|
17
|
+
function assertSummaryReadPermission(store: ReturnType<typeof loadStoreInternal>, options?: SaasQueryOptions): void {
|
|
18
|
+
const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId)
|
|
19
|
+
if (!options?.actorUserId && !shouldEnforceActorForScope) return
|
|
20
|
+
|
|
21
|
+
const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID
|
|
22
|
+
assertPermissionInStore(store, {
|
|
23
|
+
operation: 'summary:read',
|
|
24
|
+
organizationId,
|
|
25
|
+
workspaceId: options?.workspaceId,
|
|
26
|
+
actorUserId: options?.actorUserId,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildWorkspaceStats(store: ReturnType<typeof loadStoreInternal>): Array<{
|
|
31
|
+
organizationId: string
|
|
32
|
+
id: string
|
|
33
|
+
runs: number
|
|
34
|
+
avgScore: number
|
|
35
|
+
lastRun: string
|
|
36
|
+
}> {
|
|
37
|
+
return Object.values(store.workspaces)
|
|
38
|
+
.map((workspace) => {
|
|
39
|
+
const snapshots = store.snapshots.filter((snapshot) => snapshot.organizationId === workspace.organizationId && snapshot.workspaceId === workspace.id)
|
|
40
|
+
const runs = snapshots.length
|
|
41
|
+
const avgScore = runs === 0 ? 0 : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
|
|
42
|
+
const lastRun = snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.createdAt ?? 'n/a'
|
|
43
|
+
return {
|
|
44
|
+
organizationId: workspace.organizationId,
|
|
45
|
+
id: workspace.id,
|
|
46
|
+
runs,
|
|
47
|
+
avgScore,
|
|
48
|
+
lastRun,
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.sort((a, b) => b.avgScore - a.avgScore)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildRepoStats(store: ReturnType<typeof loadStoreInternal>): Array<{ workspaceId: string; name: string; runs: number; avgScore: number }> {
|
|
55
|
+
return Object.values(store.repos)
|
|
56
|
+
.map((repo) => {
|
|
57
|
+
const snapshots = store.snapshots.filter((snapshot) => snapshot.repoId === repo.id)
|
|
58
|
+
const runs = snapshots.length
|
|
59
|
+
const avgScore = runs === 0 ? 0 : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
|
|
60
|
+
return {
|
|
61
|
+
workspaceId: repo.workspaceId,
|
|
62
|
+
name: repo.name,
|
|
63
|
+
runs,
|
|
64
|
+
avgScore,
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.sort((a, b) => b.avgScore - a.avgScore)
|
|
68
|
+
.slice(0, DASHBOARD_REPO_LIMIT)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildRunsRows(summary: SaasSummary): string {
|
|
72
|
+
return Object.entries(summary.runsPerMonth)
|
|
73
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
74
|
+
.map(([month, count]) => {
|
|
75
|
+
const width = Math.max(DASHBOARD_BAR_MIN_WIDTH, count * DASHBOARD_BAR_UNIT)
|
|
76
|
+
return `<tr><td>${escapeHtml(month)}</td><td>${count}</td><td><div class="bar" style="width:${width}px"></div></td></tr>`
|
|
77
|
+
})
|
|
78
|
+
.join('')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildWorkspaceRows(workspaceStats: Array<{ organizationId: string; id: string; runs: number; avgScore: number; lastRun: string }>): string {
|
|
82
|
+
return workspaceStats
|
|
83
|
+
.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>`)
|
|
84
|
+
.join('')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildRepoRows(repoStats: Array<{ workspaceId: string; name: string; runs: number; avgScore: number }>): string {
|
|
88
|
+
return repoStats
|
|
89
|
+
.map((repo) => `<tr><td>${escapeHtml(repo.workspaceId)}</td><td>${escapeHtml(repo.name)}</td><td>${repo.runs}</td><td>${repo.avgScore}</td></tr>`)
|
|
90
|
+
.join('')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderDashboardHtmlDocument(input: {
|
|
94
|
+
storeFile: string
|
|
95
|
+
summary: SaasSummary
|
|
96
|
+
runsRows: string
|
|
97
|
+
workspaceRows: string
|
|
98
|
+
repoRows: string
|
|
99
|
+
}): string {
|
|
100
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>drift cloud dashboard</title><style>:root { color-scheme: light; } body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: #f4f7fb; color: #0f172a; } main { max-width: 980px; margin: 0 auto; padding: 24px; } h1 { margin: 0 0 6px; } p.meta { margin: 0 0 20px; color: #475569; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; } .card { background: #ffffff; border-radius: 10px; padding: 14px; border: 1px solid #dbe3ef; } .card .label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; } .card .value { font-size: 26px; font-weight: 700; margin-top: 4px; } table { width: 100%; border-collapse: collapse; margin-top: 10px; background: #ffffff; border: 1px solid #dbe3ef; border-radius: 10px; overflow: hidden; } th, td { padding: 10px; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 14px; } th { background: #eef2f9; } .section { margin-top: 18px; } .bar { height: 10px; background: linear-gradient(90deg, #0ea5e9, #22c55e); border-radius: 999px; } .pill { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; font-weight: 600; } .pill.free { background: #dcfce7; color: #166534; } .pill.paid { background: #fee2e2; color: #991b1b; }</style></head><body><main><h1>drift cloud dashboard</h1><p class="meta">Store: ${escapeHtml(input.storeFile)}</p><div class="cards"><div class="card"><div class="label">Plan Phase</div><div class="value"><span class="pill ${input.summary.phase}">${input.summary.phase.toUpperCase()}</span></div></div><div class="card"><div class="label">Users</div><div class="value">${input.summary.usersRegistered}</div></div><div class="card"><div class="label">Active Workspaces</div><div class="value">${input.summary.workspacesActive}</div></div><div class="card"><div class="label">Active Repos</div><div class="value">${input.summary.reposActive}</div></div><div class="card"><div class="label">Snapshots</div><div class="value">${input.summary.totalSnapshots}</div></div><div class="card"><div class="label">Free Seats Left</div><div class="value">${input.summary.freeUsersRemaining}</div></div></div><section class="section"><h2>Runs Per Month</h2><table><thead><tr><th>Month</th><th>Runs</th><th>Trend</th></tr></thead><tbody>${input.runsRows || '<tr><td colspan="3">No runs yet</td></tr>'}</tbody></table></section><section class="section"><h2>Workspace Hotspots</h2><table><thead><tr><th>Organization</th><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead><tbody>${input.workspaceRows || '<tr><td colspan="5">No workspace data</td></tr>'}</tbody></table></section><section class="section"><h2>Repo Hotspots</h2><table><thead><tr><th>Workspace</th><th>Repo</th><th>Runs</th><th>Avg Score</th></tr></thead><tbody>${input.repoRows || '<tr><td colspan="4">No repo data</td></tr>'}</tbody></table></section></main></body></html>`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function listSaasSnapshots(options?: SaasQueryOptions): SaasSnapshot[] {
|
|
104
|
+
const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
|
|
105
|
+
const store = loadStoreInternal(storeFile, options?.policy)
|
|
106
|
+
|
|
107
|
+
const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId)
|
|
108
|
+
if (options?.actorUserId || shouldEnforceActorForScope) {
|
|
109
|
+
const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID
|
|
110
|
+
assertPermissionInStore(store, {
|
|
111
|
+
operation: 'snapshot:read',
|
|
112
|
+
organizationId,
|
|
113
|
+
workspaceId: options?.workspaceId,
|
|
114
|
+
actorUserId: options?.actorUserId,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
saveStore(storeFile, store)
|
|
119
|
+
return store.snapshots
|
|
120
|
+
.filter((snapshot) => matchesTenantScope(snapshot, options))
|
|
121
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getSaasSummary(options?: SaasQueryOptions): SaasSummary {
|
|
125
|
+
const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
|
|
126
|
+
const store = loadStoreInternal(storeFile, options?.policy)
|
|
127
|
+
|
|
128
|
+
assertSummaryReadPermission(store, options)
|
|
129
|
+
saveStore(storeFile, store)
|
|
130
|
+
|
|
131
|
+
const scopedSnapshots = store.snapshots.filter((snapshot) => matchesTenantScope(snapshot, options))
|
|
132
|
+
const scopedWorkspaces = Object.values(store.workspaces).filter((workspace) => matchesWorkspaceScope(workspace, options))
|
|
133
|
+
const scopedRepos = Object.values(store.repos).filter((repo) => matchesRepoScope(repo, options))
|
|
134
|
+
|
|
135
|
+
const usersRegistered = computeUsersRegistered(store, scopedSnapshots, options)
|
|
136
|
+
const workspacesActive = scopedWorkspaces.filter((workspace) => isWorkspaceActive(workspace)).length
|
|
137
|
+
const reposActive = scopedRepos.filter((repo) => isRepoActive(repo)).length
|
|
138
|
+
const runsPerMonth = computeRunsPerMonth(scopedSnapshots)
|
|
139
|
+
const thresholdReached = usersRegistered >= store.policy.freeUserThreshold
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
policy: store.policy,
|
|
143
|
+
usersRegistered,
|
|
144
|
+
workspacesActive,
|
|
145
|
+
reposActive,
|
|
146
|
+
runsPerMonth,
|
|
147
|
+
totalSnapshots: scopedSnapshots.length,
|
|
148
|
+
phase: thresholdReached ? 'paid' : 'free',
|
|
149
|
+
thresholdReached,
|
|
150
|
+
freeUsersRemaining: Math.max(0, store.policy.freeUserThreshold - usersRegistered),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function generateSaasDashboardHtml(options?: { storeFile?: string; policy?: SaasPolicyOverrides }): string {
|
|
155
|
+
const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
|
|
156
|
+
const store = loadStoreInternal(storeFile, options?.policy)
|
|
157
|
+
const summary = getSaasSummary(options)
|
|
158
|
+
|
|
159
|
+
const workspaceStats = buildWorkspaceStats(store)
|
|
160
|
+
const repoStats = buildRepoStats(store)
|
|
161
|
+
const runsRows = buildRunsRows(summary)
|
|
162
|
+
const workspaceRows = buildWorkspaceRows(workspaceStats)
|
|
163
|
+
const repoRows = buildRepoRows(repoStats)
|
|
164
|
+
|
|
165
|
+
return renderDashboardHtmlDocument({
|
|
166
|
+
storeFile,
|
|
167
|
+
summary,
|
|
168
|
+
runsRows,
|
|
169
|
+
workspaceRows,
|
|
170
|
+
repoRows,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SaasOperation, SaasPermissionContext, SaasRole } from './types.js'
|
|
2
|
+
|
|
3
|
+
export class SaasPermissionError extends Error {
|
|
4
|
+
readonly code = 'SAAS_PERMISSION_DENIED'
|
|
5
|
+
readonly operation: SaasOperation
|
|
6
|
+
readonly organizationId: string
|
|
7
|
+
readonly workspaceId?: string
|
|
8
|
+
readonly actorUserId?: string
|
|
9
|
+
readonly requiredRole: SaasRole
|
|
10
|
+
readonly actorRole?: SaasRole
|
|
11
|
+
|
|
12
|
+
constructor(context: SaasPermissionContext, requiredRole: SaasRole, actorRole?: SaasRole) {
|
|
13
|
+
const actor = context.actorUserId ?? 'unknown-actor'
|
|
14
|
+
const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : ''
|
|
15
|
+
const actualRole = actorRole ?? 'none'
|
|
16
|
+
super(
|
|
17
|
+
`Permission denied for operation '${context.operation}'. actor='${actor}' organization='${context.organizationId}'${workspaceSuffix} requiredRole='${requiredRole}' actualRole='${actualRole}'.`,
|
|
18
|
+
)
|
|
19
|
+
this.name = 'SaasPermissionError'
|
|
20
|
+
this.operation = context.operation
|
|
21
|
+
this.organizationId = context.organizationId
|
|
22
|
+
this.workspaceId = context.workspaceId
|
|
23
|
+
this.actorUserId = context.actorUserId
|
|
24
|
+
this.requiredRole = requiredRole
|
|
25
|
+
this.actorRole = actorRole
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class SaasActorRequiredError extends Error {
|
|
30
|
+
readonly code = 'SAAS_ACTOR_REQUIRED'
|
|
31
|
+
readonly operation: SaasOperation
|
|
32
|
+
readonly organizationId: string
|
|
33
|
+
readonly workspaceId?: string
|
|
34
|
+
|
|
35
|
+
constructor(context: SaasPermissionContext) {
|
|
36
|
+
const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : ''
|
|
37
|
+
super(
|
|
38
|
+
`Actor is required for operation '${context.operation}'. organization='${context.organizationId}'${workspaceSuffix}.`,
|
|
39
|
+
)
|
|
40
|
+
this.name = 'SaasActorRequiredError'
|
|
41
|
+
this.operation = context.operation
|
|
42
|
+
this.organizationId = context.organizationId
|
|
43
|
+
this.workspaceId = context.workspaceId
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -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
|
+
}
|