@odavl/guardian 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1345 -60
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +21 -11
  7. package/policies/landing-demo.json +22 -0
  8. package/src/enterprise/audit-logger.js +166 -0
  9. package/src/enterprise/pdf-exporter.js +267 -0
  10. package/src/enterprise/rbac-gate.js +142 -0
  11. package/src/enterprise/rbac.js +239 -0
  12. package/src/enterprise/site-manager.js +180 -0
  13. package/src/founder/feedback-system.js +156 -0
  14. package/src/founder/founder-tracker.js +213 -0
  15. package/src/founder/usage-signals.js +141 -0
  16. package/src/guardian/alert-ledger.js +121 -0
  17. package/src/guardian/attempt-engine.js +568 -7
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +24 -0
  21. package/src/guardian/baseline.js +12 -4
  22. package/src/guardian/breakage-intelligence.js +1 -0
  23. package/src/guardian/ci-cli.js +121 -0
  24. package/src/guardian/ci-output.js +4 -3
  25. package/src/guardian/cli-summary.js +79 -92
  26. package/src/guardian/config-loader.js +162 -0
  27. package/src/guardian/drift-detector.js +100 -0
  28. package/src/guardian/enhanced-html-reporter.js +221 -4
  29. package/src/guardian/env-guard.js +127 -0
  30. package/src/guardian/failure-intelligence.js +173 -0
  31. package/src/guardian/first-run-profile.js +89 -0
  32. package/src/guardian/first-run.js +6 -1
  33. package/src/guardian/flag-validator.js +17 -3
  34. package/src/guardian/html-reporter.js +2 -0
  35. package/src/guardian/human-reporter.js +431 -0
  36. package/src/guardian/index.js +22 -19
  37. package/src/guardian/init-command.js +9 -5
  38. package/src/guardian/intent-detector.js +146 -0
  39. package/src/guardian/journey-definitions.js +132 -0
  40. package/src/guardian/journey-scan-cli.js +145 -0
  41. package/src/guardian/journey-scanner.js +583 -0
  42. package/src/guardian/junit-reporter.js +18 -1
  43. package/src/guardian/live-cli.js +95 -0
  44. package/src/guardian/live-scheduler-runner.js +137 -0
  45. package/src/guardian/live-scheduler.js +146 -0
  46. package/src/guardian/market-reporter.js +341 -81
  47. package/src/guardian/pattern-analyzer.js +348 -0
  48. package/src/guardian/policy.js +80 -3
  49. package/src/guardian/preset-loader.js +9 -6
  50. package/src/guardian/reality.js +1278 -117
  51. package/src/guardian/reporter.js +27 -41
  52. package/src/guardian/run-artifacts.js +212 -0
  53. package/src/guardian/run-cleanup.js +207 -0
  54. package/src/guardian/run-latest.js +90 -0
  55. package/src/guardian/run-list.js +211 -0
  56. package/src/guardian/scan-presets.js +100 -11
  57. package/src/guardian/selector-fallbacks.js +394 -0
  58. package/src/guardian/semantic-contact-finder.js +2 -1
  59. package/src/guardian/site-introspection.js +257 -0
  60. package/src/guardian/smoke.js +2 -2
  61. package/src/guardian/snapshot-schema.js +25 -1
  62. package/src/guardian/snapshot.js +46 -2
  63. package/src/guardian/stability-scorer.js +169 -0
  64. package/src/guardian/template-command.js +184 -0
  65. package/src/guardian/text-formatters.js +426 -0
  66. package/src/guardian/verdict.js +320 -0
  67. package/src/guardian/verdicts.js +74 -0
  68. package/src/guardian/watch-runner.js +3 -7
  69. package/src/payments/stripe-checkout.js +169 -0
  70. package/src/plans/plan-definitions.js +148 -0
  71. package/src/plans/plan-manager.js +211 -0
  72. package/src/plans/usage-tracker.js +210 -0
  73. package/src/recipes/recipe-engine.js +188 -0
  74. package/src/recipes/recipe-failure-analysis.js +159 -0
  75. package/src/recipes/recipe-registry.js +134 -0
  76. package/src/recipes/recipe-runtime.js +507 -0
  77. package/src/recipes/recipe-store.js +410 -0
  78. package/guardian-contract-v1.md +0 -149
  79. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  80. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  81. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  82. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  83. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  84. /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Unified Verdict Builder
3
+ * Deterministic, explainable verdict and confidence scoring
4
+ */
5
+
6
+ function clamp01(x) { return Math.max(0, Math.min(1, x)); }
7
+
8
+ const isExecutedAttempt = (attempt) => attempt && attempt.outcome !== 'SKIPPED';
9
+
10
+ function mapRunResultToVerdict(runResult) {
11
+ const r = (runResult || '').toUpperCase();
12
+ if (r === 'PASSED') return 'READY';
13
+ if (r === 'FAILED') return 'DO_NOT_LAUNCH';
14
+ if (r === 'INSUFFICIENT_EVIDENCE') return 'INSUFFICIENT_EVIDENCE';
15
+ return 'FRICTION'; // WARN or anything else
16
+ }
17
+
18
+ function computeCoverageScore(snapshot) {
19
+ const disc = snapshot.discovery || {};
20
+ if (disc.interactionsDiscovered && disc.interactionsDiscovered > 0) {
21
+ return clamp01((disc.interactionsExecuted || 0) / (disc.interactionsDiscovered || 1));
22
+ }
23
+ const attempts = snapshot.attempts || [];
24
+ const executedAttempts = attempts.filter(isExecutedAttempt);
25
+ const executed = executedAttempts.length;
26
+ return executed > 0 ? 1 : 0; // if we have executed attempts, consider coverage adequate
27
+ }
28
+
29
+ function computeEvidenceScore(snapshot) {
30
+ const e = snapshot.evidence || {};
31
+ const features = [
32
+ !!e.marketReportJson,
33
+ !!e.marketReportHtml,
34
+ !!e.traceZip,
35
+ !!(e.attemptArtifacts && Object.keys(e.attemptArtifacts).length > 0)
36
+ ];
37
+ const present = features.filter(Boolean).length;
38
+ return clamp01(present / features.length);
39
+ }
40
+
41
+ function computePenalty(snapshot) {
42
+ const attempts = snapshot.attempts || [];
43
+ const failures = attempts.filter(a => a.outcome === 'FAILURE').length;
44
+ const frictions = attempts.filter(a => a.outcome === 'FRICTION').length;
45
+ const perFailure = 0.15;
46
+ const perFriction = 0.1;
47
+ const raw = failures * perFailure + frictions * perFriction;
48
+ return Math.min(0.5, raw);
49
+ }
50
+
51
+ function levelFromScore(score) {
52
+ if (score >= 0.66) return 'high';
53
+ if (score >= 0.33) return 'medium';
54
+ return 'low';
55
+ }
56
+
57
+ function collectKeyFindings(snapshot) {
58
+ const findings = [];
59
+ const attempts = snapshot.attempts || [];
60
+ const executedAttempts = attempts.filter(a => a.outcome !== 'SKIPPED');
61
+ const skippedAttempts = attempts.filter(a => a.outcome === 'SKIPPED');
62
+ const successful = executedAttempts.filter(a => a.outcome === 'SUCCESS').length;
63
+ const failed = executedAttempts.filter(a => a.outcome === 'FAILURE').length;
64
+ const friction = executedAttempts.filter(a => a.outcome === 'FRICTION').length;
65
+
66
+ // Attempt outcomes
67
+ if (executedAttempts.length > 0) {
68
+ findings.push(`${successful} of ${executedAttempts.length} executed attempts completed`);
69
+ if (failed > 0) findings.push(`${failed} attempt did not complete`);
70
+ if (friction > 0) findings.push(`${friction} attempt showed friction (slower or rougher than baseline)`);
71
+ if (skippedAttempts.length > 0) {
72
+ findings.push(`${skippedAttempts.length} attempt was not executed`);
73
+ }
74
+ }
75
+
76
+ // Market impact
77
+ const mi = snapshot.marketImpactSummary || {};
78
+ const counts = mi.countsBySeverity || { CRITICAL: 0, WARNING: 0, INFO: 0 };
79
+ if (counts.CRITICAL > 0 || counts.WARNING > 0) {
80
+ findings.push(`${counts.CRITICAL} critical risk and ${counts.WARNING} warning risk identified`);
81
+ } else {
82
+ findings.push(`No critical or warning risks identified`);
83
+ }
84
+
85
+ // Discovery coverage
86
+ const disc = snapshot.discovery || {};
87
+ if ((disc.interactionsExecuted || 0) > 0) {
88
+ findings.push(`${disc.interactionsExecuted}/${disc.interactionsDiscovered || 0} discovered interactions executed`);
89
+ }
90
+
91
+ // Evidence
92
+ const e = snapshot.evidence || {};
93
+ const hasReports = !!(e.marketReportJson && e.marketReportHtml);
94
+ const hasTraces = !!e.traceZip;
95
+ const hasScreenshots = !!(e.attemptArtifacts && Object.keys(e.attemptArtifacts).length > 0);
96
+ const evidenceCount = [hasReports, hasTraces, hasScreenshots].filter(Boolean).length;
97
+ if (evidenceCount === 3) {
98
+ findings.push(`Complete evidence: reports, traces, and screenshots captured`);
99
+ } else if (evidenceCount > 0) {
100
+ findings.push(`Partial evidence: ${[hasReports && 'reports', hasTraces && 'traces', hasScreenshots && 'screenshots'].filter(Boolean).join(', ')}`);
101
+ }
102
+
103
+ return findings.slice(0, 7);
104
+ }
105
+
106
+ function collectEvidenceRefs(snapshot) {
107
+ const e = snapshot.evidence || {};
108
+ const reportPaths = [];
109
+ if (e.marketReportJson) reportPaths.push(e.marketReportJson);
110
+ if (e.marketReportHtml) reportPaths.push(e.marketReportHtml);
111
+ const traces = e.traceZip ? [e.traceZip] : [];
112
+ const screenshots = [];
113
+ if (e.attemptArtifacts) {
114
+ Object.values(e.attemptArtifacts).forEach(a => {
115
+ if (a.screenshotDir) screenshots.push(a.screenshotDir);
116
+ });
117
+ }
118
+ const affectedPages = (snapshot.discovery?.pagesVisited || []).slice(0, 10);
119
+ return { reportPaths, traces, screenshots, affectedPages };
120
+ }
121
+
122
+ function defaultLimits() {
123
+ return [
124
+ 'Live-site variability may affect signals',
125
+ 'Browser/viewport differences can change outcomes',
126
+ 'Coverage limited to configured attempts and discovered interactions'
127
+ ];
128
+ }
129
+
130
+ function situationalLimits(snapshot) {
131
+ const limits = [];
132
+ const attempts = snapshot.attempts || [];
133
+ const skippedCount = attempts.filter(a => a.outcome === 'SKIPPED').length;
134
+ const disc = snapshot.discovery || {};
135
+
136
+ // Skipped attempts
137
+ if (skippedCount > 0) {
138
+ limits.push(`${skippedCount} additional attempt was not executed; verdict is therefore limited to fewer paths`);
139
+ }
140
+
141
+ // Discovery coverage gaps
142
+ const discovered = disc.interactionsDiscovered || 0;
143
+ const executed = disc.interactionsExecuted || 0;
144
+ if (discovered > 0 && executed < discovered) {
145
+ limits.push(`${discovered - executed} of ${discovered} discovered interactions were not tested`);
146
+ }
147
+
148
+ // Evidence gaps
149
+ const e = snapshot.evidence || {};
150
+ if (!e.traceZip) {
151
+ limits.push(`Detailed timing traces were not captured; timing-related issues may not be visible`);
152
+ }
153
+ if (!e.marketReportJson) {
154
+ limits.push(`Market risk analysis was not run; business impact is unassessed`);
155
+ }
156
+
157
+ // Environment variability
158
+ limits.push(`Live site behavior shifts over time; this verdict reflects this moment`);
159
+
160
+ // Browser/environment
161
+ limits.push(`Results are specific to this browser and viewport; other browsers may differ`);
162
+
163
+ return limits.slice(0, 6);
164
+ }
165
+
166
+ /**
167
+ * Build unified verdict from snapshot
168
+ */
169
+ function deriveRunResult(snapshot) {
170
+ const meta = snapshot.meta || {};
171
+ if (meta.result) return meta.result;
172
+ const attempts = snapshot.attempts || [];
173
+
174
+ // Count different outcome types
175
+ const executedAttempts = attempts.filter(a => a.outcome !== 'SKIPPED' && a.outcome !== 'NOT_APPLICABLE');
176
+ const discoveryFailedAttempts = attempts.filter(a => a.outcome === 'DISCOVERY_FAILED');
177
+ const notApplicableAttempts = attempts.filter(a => a.outcome === 'NOT_APPLICABLE');
178
+ const skippedCount = attempts.filter(a => a.outcome === 'SKIPPED').length;
179
+
180
+ const executed = executedAttempts.length;
181
+ const successful = executedAttempts.filter(a => a.outcome === 'SUCCESS').length;
182
+ const failed = executedAttempts.filter(a => a.outcome === 'FAILURE').length;
183
+
184
+ // If NOTHING executed (all skipped, not applicable, or discovery failed), it's insufficient
185
+ if (executed === 0 && (discoveryFailedAttempts.length > 0 || skippedCount > 0)) {
186
+ return 'INSUFFICIENT_EVIDENCE';
187
+ }
188
+
189
+ if (executed === 0) return 'WARN';
190
+ if (failed === 0 && successful === executed) return 'PASSED';
191
+ if (failed === executed) return 'FAILED';
192
+ return 'WARN';
193
+ }
194
+
195
+ function buildVerdictExplanation(snapshot, verdictStr, executedCount, successCount, failureCount, skippedCount, evidenceScore, penaltyApplied) {
196
+ let why = '';
197
+
198
+ if (verdictStr === 'READY') {
199
+ why = `All ${executedCount} executed attempts completed successfully without critical issues. `;
200
+ if (skippedCount > 0) {
201
+ why += `${skippedCount} additional attempt was not executed. `;
202
+ }
203
+ why += `Evidence is ${evidenceScore >= 0.75 ? 'complete' : 'sufficient'}. `;
204
+ why += `This differs from FRICTION because all attempts succeeded, and from DO_NOT_LAUNCH because no critical issues were found.`;
205
+ } else if (verdictStr === 'DO_NOT_LAUNCH') {
206
+ why = `${failureCount} critical issue was found, preventing safe launch. `;
207
+ why += `This differs from READY because critical issues must be resolved, and from FRICTION because severity exceeds acceptable limits.`;
208
+ } else if (verdictStr === 'INSUFFICIENT_EVIDENCE') {
209
+ why = `No meaningful attempts executed on this site. Most attempts were skipped (not applicable to site) or element discovery failed. `;
210
+ why += `This site may not match the tested user journeys, or it may be uninstrumented (missing expected UI elements/patterns). `;
211
+ why += `Verdict cannot be determined without successful execution of at least one relevant journey.`;
212
+ } else {
213
+ why = `Results show ${successCount} of ${executedCount} attempted success with some roughness. `;
214
+ if (failureCount > 0) {
215
+ why += `${failureCount} attempt did not complete. `;
216
+ }
217
+ why += `This differs from READY because outcomes were mixed, and from DO_NOT_LAUNCH because no critical failure was found.`;
218
+ }
219
+
220
+ return why;
221
+ }
222
+
223
+ function deriveNextRunHint(snapshot, confidenceLevel) {
224
+ // Non-intrusive guard: suppress when confidence is high and limits are empty
225
+ const limits = situationalLimits(snapshot) || [];
226
+ if (confidenceLevel === 'high' && limits.length === 0) {
227
+ return null;
228
+ }
229
+
230
+ // (a) Evidence completeness
231
+ const e = snapshot.evidence || {};
232
+ const hasReports = !!(e.marketReportJson && e.marketReportHtml);
233
+ const hasTrace = !!e.traceZip;
234
+ const hasScreenshots = !!(e.attemptArtifacts && Object.keys(e.attemptArtifacts).length > 0);
235
+ if (!hasTrace || !hasScreenshots) {
236
+ return 'Capture additional evidence (e.g., traces) to strengthen timing and flow visibility.';
237
+ }
238
+
239
+ // (b) Coverage completeness
240
+ const disc = snapshot.discovery || {};
241
+ const discovered = disc.interactionsDiscovered || 0;
242
+ const executed = disc.interactionsExecuted || 0;
243
+ if (discovered > 0 && executed < discovered) {
244
+ return 'Expand coverage to include additional discovered interactions.';
245
+ }
246
+
247
+ // (c) Recurring timeouts/flakiness within this run
248
+ const attempts = snapshot.attempts || [];
249
+ const flows = snapshot.flows || [];
250
+ const timeoutRegex = /(timeout|timed out)/i;
251
+ const timeoutCount = (
252
+ attempts.filter(a => typeof a.error === 'string' && timeoutRegex.test(a.error)).length +
253
+ flows.filter(f => typeof f.error === 'string' && timeoutRegex.test(f.error)).length
254
+ );
255
+ const frictionCount = attempts.filter(a => a.friction && a.friction.isFriction).length;
256
+ if (timeoutCount >= 2 || frictionCount >= 2) {
257
+ return 'Use a conservative timeout profile to reduce flakiness.';
258
+ }
259
+
260
+ // (d) Otherwise: no hint
261
+ return null;
262
+ }
263
+
264
+ function buildVerdict(snapshot) {
265
+ const runRes = deriveRunResult(snapshot);
266
+ const verdictStr = mapRunResultToVerdict(runRes || 'WARN');
267
+
268
+ const outcomeScore = verdictStr === 'READY' ? 1 : verdictStr === 'DO_NOT_LAUNCH' ? 0.2 : 0.5;
269
+ const coverageScore = computeCoverageScore(snapshot); // 0..1
270
+ const evidenceScore = computeEvidenceScore(snapshot); // 0..1
271
+ const penalty = computePenalty(snapshot); // 0..0.5
272
+
273
+ const raw = 0.5 * outcomeScore + 0.2 * coverageScore + 0.3 * evidenceScore - penalty;
274
+ const score = clamp01(raw);
275
+ const level = levelFromScore(score);
276
+
277
+ // Detailed metrics for explanation
278
+ const attempts = snapshot.attempts || [];
279
+ const executedAttempts = attempts.filter(a => a.outcome !== 'SKIPPED');
280
+ const executedCount = executedAttempts.length;
281
+ const successCount = executedAttempts.filter(a => a.outcome === 'SUCCESS').length;
282
+ const failureCount = executedAttempts.filter(a => a.outcome === 'FAILURE').length;
283
+ const skippedCount = attempts.filter(a => a.outcome === 'SKIPPED').length;
284
+
285
+ const reasons = [];
286
+ if (successCount === executedCount && executedCount > 0) {
287
+ reasons.push(`All ${executedCount} executed attempts completed`);
288
+ } else if (successCount > 0) {
289
+ reasons.push(`${successCount} of ${executedCount} executed attempts completed`);
290
+ }
291
+ if (coverageScore > 0.66) reasons.push('Good coverage from discovered interactions');
292
+ if (evidenceScore >= 0.75) reasons.push('Complete evidence captured (reports, traces, screenshots)');
293
+ if (penalty > 0) reasons.push(`${failureCount > 0 ? 'Incomplete attempts' : 'Friction signals'} observed (${(penalty * 100).toFixed(0)}% impact)`);
294
+ // Do not include 'not executed' in drivers; it appears in findings/limits
295
+
296
+ const why = buildVerdictExplanation(
297
+ snapshot,
298
+ verdictStr,
299
+ executedCount,
300
+ successCount,
301
+ failureCount,
302
+ skippedCount,
303
+ evidenceScore,
304
+ penalty > 0
305
+ );
306
+
307
+ const nextRunHint = deriveNextRunHint(snapshot, level);
308
+
309
+ return {
310
+ verdict: verdictStr,
311
+ confidence: { level, score, reasons },
312
+ why,
313
+ keyFindings: collectKeyFindings(snapshot),
314
+ evidence: collectEvidenceRefs(snapshot),
315
+ limits: situationalLimits(snapshot),
316
+ nextRunHint
317
+ };
318
+ }
319
+
320
+ module.exports = { buildVerdict };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Verdict mapping utilities
3
+ * Canonical user-facing verdicts: READY, FRICTION, DO_NOT_LAUNCH
4
+ * Internal engine verdicts (legacy): OBSERVED, PARTIAL, INSUFFICIENT_DATA
5
+ */
6
+
7
+ function toCanonicalVerdict(internalVerdict) {
8
+ switch (String(internalVerdict || '').toUpperCase()) {
9
+ // Internal engine verdicts
10
+ case 'OBSERVED':
11
+ return 'READY';
12
+ case 'PARTIAL':
13
+ return 'FRICTION';
14
+ case 'INSUFFICIENT_DATA':
15
+ return 'DO_NOT_LAUNCH';
16
+ // Synonyms from other components
17
+ case 'PASS':
18
+ case 'SUCCESS':
19
+ return 'READY';
20
+ case 'WARN':
21
+ case 'WARNING':
22
+ case 'FRICTION':
23
+ return 'FRICTION';
24
+ case 'FAIL':
25
+ case 'FAILURE':
26
+ case 'DO_NOT_LAUNCH':
27
+ return 'DO_NOT_LAUNCH';
28
+ default:
29
+ return 'DO_NOT_LAUNCH';
30
+ }
31
+ }
32
+
33
+ function toInternalVerdict(canonicalVerdict) {
34
+ switch (String(canonicalVerdict || '').toUpperCase()) {
35
+ case 'READY':
36
+ return 'OBSERVED';
37
+ case 'FRICTION':
38
+ return 'PARTIAL';
39
+ case 'DO_NOT_LAUNCH':
40
+ return 'INSUFFICIENT_DATA';
41
+ default:
42
+ return 'INSUFFICIENT_DATA';
43
+ }
44
+ }
45
+
46
+ function mapExitCodeFromCanonical(canonicalVerdict) {
47
+ switch (String(canonicalVerdict || '').toUpperCase()) {
48
+ case 'READY':
49
+ return 0;
50
+ case 'FRICTION':
51
+ return 1;
52
+ case 'DO_NOT_LAUNCH':
53
+ default:
54
+ return 2;
55
+ }
56
+ }
57
+
58
+ module.exports = { toCanonicalVerdict, toInternalVerdict, mapExitCodeFromCanonical };
59
+
60
+ // Journey scan mapping (SAFE/RISK/DO_NOT_LAUNCH → canonical)
61
+ function toCanonicalJourneyVerdict(journeyVerdict) {
62
+ switch (String(journeyVerdict || '').toUpperCase()) {
63
+ case 'SAFE':
64
+ return 'READY';
65
+ case 'RISK':
66
+ return 'FRICTION';
67
+ case 'DO_NOT_LAUNCH':
68
+ return 'DO_NOT_LAUNCH';
69
+ default:
70
+ return 'DO_NOT_LAUNCH';
71
+ }
72
+ }
73
+
74
+ module.exports.toCanonicalJourneyVerdict = toCanonicalJourneyVerdict;
@@ -19,17 +19,13 @@ function isIgnored(filePath, artifactsDir = './artifacts') {
19
19
  if (artifactsDir) {
20
20
  ignorePrefixes.push(path.normalize(artifactsDir));
21
21
  }
22
- return parts.some(p => ignorePrefixes.includes(p)) || normalized.includes('market-run');
22
+ // Ignore any changes within artifacts directory (run outputs)
23
+ return parts.some(p => ignorePrefixes.includes(p));
23
24
  }
24
25
 
25
26
  function collectWatchPaths(config) {
26
27
  const roots = [
27
- 'guardian.config.json',
28
- 'guardian.policy.json',
29
- 'guardian.profile.docs.yaml',
30
- 'guardian.profile.ecommerce.yaml',
31
- 'guardian.profile.marketing.yaml',
32
- 'guardian.profile.saas.yaml',
28
+ 'config',
33
29
  'flows',
34
30
  'policies',
35
31
  'data',
@@ -0,0 +1,169 @@
1
+ /**
2
+ * ODAVL Guardian Stripe Integration
3
+ * Real Stripe checkout for PRO and BUSINESS plans
4
+ */
5
+
6
+ const { PLANS } = require('../plans/plan-definitions');
7
+ const { setCurrentPlan } = require('../plans/plan-manager');
8
+
9
+ // Stripe configuration
10
+ const STRIPE_PUBLIC_KEY = process.env.STRIPE_PUBLIC_KEY || '';
11
+ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || '';
12
+ const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
13
+
14
+ /**
15
+ * Create Stripe checkout session
16
+ * This is called from the website when user clicks "Upgrade"
17
+ */
18
+ async function createCheckoutSession(planId, successUrl, cancelUrl) {
19
+ if (!STRIPE_SECRET_KEY) {
20
+ throw new Error('Stripe is not configured. Set STRIPE_SECRET_KEY environment variable.');
21
+ }
22
+
23
+ const stripe = require('stripe')(STRIPE_SECRET_KEY);
24
+ const plan = PLANS[planId.toUpperCase()];
25
+
26
+ if (!plan || !plan.stripePriceId) {
27
+ throw new Error(`Invalid plan: ${planId}`);
28
+ }
29
+
30
+ const session = await stripe.checkout.sessions.create({
31
+ mode: 'subscription',
32
+ payment_method_types: ['card'],
33
+ line_items: [
34
+ {
35
+ price: plan.stripePriceId,
36
+ quantity: 1,
37
+ },
38
+ ],
39
+ success_url: successUrl,
40
+ cancel_url: cancelUrl,
41
+ metadata: {
42
+ planId: plan.id,
43
+ },
44
+ });
45
+
46
+ return {
47
+ sessionId: session.id,
48
+ url: session.url,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Handle Stripe webhook events
54
+ * Called when payment succeeds or subscription changes
55
+ */
56
+ async function handleWebhook(requestBody, signature) {
57
+ if (!STRIPE_SECRET_KEY || !STRIPE_WEBHOOK_SECRET) {
58
+ throw new Error('Stripe webhooks not configured');
59
+ }
60
+
61
+ const stripe = require('stripe')(STRIPE_SECRET_KEY);
62
+
63
+ let event;
64
+ try {
65
+ event = stripe.webhooks.constructEvent(
66
+ requestBody,
67
+ signature,
68
+ STRIPE_WEBHOOK_SECRET
69
+ );
70
+ } catch (err) {
71
+ throw new Error(`Webhook signature verification failed: ${err.message}`);
72
+ }
73
+
74
+ // Handle the event
75
+ switch (event.type) {
76
+ case 'checkout.session.completed': {
77
+ const session = event.data.object;
78
+ const planId = session.metadata.planId;
79
+ const customerId = session.customer;
80
+ const subscriptionId = session.subscription;
81
+
82
+ // Activate the plan
83
+ setCurrentPlan(planId, {
84
+ customerId,
85
+ subscriptionId,
86
+ });
87
+
88
+ return {
89
+ success: true,
90
+ planId,
91
+ customerId,
92
+ };
93
+ }
94
+
95
+ case 'customer.subscription.updated':
96
+ case 'customer.subscription.deleted': {
97
+ const subscription = event.data.object;
98
+ const customerId = subscription.customer;
99
+
100
+ if (subscription.status === 'active') {
101
+ // Keep subscription active
102
+ return { success: true, status: 'active' };
103
+ } else if (['canceled', 'unpaid'].includes(subscription.status)) {
104
+ // Downgrade to FREE
105
+ setCurrentPlan('free');
106
+ return { success: true, status: 'downgraded' };
107
+ }
108
+
109
+ return { success: true, status: subscription.status };
110
+ }
111
+
112
+ default:
113
+ console.log(`Unhandled event type: ${event.type}`);
114
+ return { success: true, ignored: true };
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Get Stripe public key for client-side
120
+ */
121
+ function getStripePublicKey() {
122
+ return STRIPE_PUBLIC_KEY;
123
+ }
124
+
125
+ /**
126
+ * Create customer portal session
127
+ * Allows users to manage their subscription
128
+ */
129
+ async function createPortalSession(customerId, returnUrl) {
130
+ if (!STRIPE_SECRET_KEY) {
131
+ throw new Error('Stripe is not configured');
132
+ }
133
+
134
+ const stripe = require('stripe')(STRIPE_SECRET_KEY);
135
+
136
+ const session = await stripe.billingPortal.sessions.create({
137
+ customer: customerId,
138
+ return_url: returnUrl,
139
+ });
140
+
141
+ return {
142
+ url: session.url,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Verify Stripe is configured
148
+ */
149
+ function isStripeConfigured() {
150
+ return !!(STRIPE_SECRET_KEY && STRIPE_PUBLIC_KEY);
151
+ }
152
+
153
+ /**
154
+ * Get checkout URL for a plan
155
+ * Helper for CLI
156
+ */
157
+ function getCheckoutUrl(planId) {
158
+ const baseUrl = process.env.GUARDIAN_WEBSITE_URL || 'https://guardian.odavl.com';
159
+ return `${baseUrl}/checkout?plan=${planId}`;
160
+ }
161
+
162
+ module.exports = {
163
+ createCheckoutSession,
164
+ handleWebhook,
165
+ getStripePublicKey,
166
+ createPortalSession,
167
+ isStripeConfigured,
168
+ getCheckoutUrl,
169
+ };