ai-saas-guard 0.3.0 → 0.5.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/README.md CHANGED
@@ -51,8 +51,8 @@ The CLI is published on npm as `ai-saas-guard`, and the GitHub Action is availab
51
51
  | JSON and SARIF output | Available |
52
52
  | Composite GitHub Action | Available |
53
53
  | Project config | `.ai-saas-guard.json` rule toggles, severity overrides, and fail thresholds |
54
- | Versioned Action tags | `v0.3.0`, `v0` |
55
- | npm package | `ai-saas-guard@0.3.0` |
54
+ | Versioned Action tags | `v0.5.0`, `v0` |
55
+ | npm package | `ai-saas-guard@0.5.0` |
56
56
  | npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
57
57
 
58
58
  ## Quick Start
@@ -121,7 +121,7 @@ Evidence:
121
121
  | --- | --- |
122
122
  | Secrets and env | Secret-like values, risky `NEXT_PUBLIC_*` exposure |
123
123
  | Stripe | Missing webhook route, unsigned webhook handling, parsed-body signature risk, missing idempotency, missing failure/cancel/update/refund paths |
124
- | Supabase | RLS disabled on sensitive tables, `USING (true)`, missing ownership filters, public storage hints |
124
+ | Supabase | RLS disabled on sensitive tables, broad `USING`/`WITH CHECK`, tenant membership patterns, weak write checks, storage object policy scope |
125
125
  | API routes | Auth checks without obvious ownership guards, missing rate-limit hints on sensitive mutation routes |
126
126
  | MCP | Plaintext secrets, non-localhost binds, broad filesystem/write access, shell tools, raw SQL tools |
127
127
  | Deploy config | Next static export/runtime mismatches, Edge runtime with Node-only APIs, missing important env documentation |
@@ -170,6 +170,10 @@ If `--base` cannot be resolved, `pr-risk` emits `pr-risk.diff-unavailable` inste
170
170
  | `check-stripe` | Inspect webhook handlers and billing lifecycle coverage |
171
171
  | `check-mcp` | Inventory MCP configs and classify side effects |
172
172
 
173
+ ## Stripe Webhook Replay
174
+
175
+ Use [docs/stripe-webhook-replay.md](docs/stripe-webhook-replay.md) after `check-stripe` flags missing signature verification, idempotency, lifecycle handlers, or entitlement updates. The cookbook maps findings to concrete `stripe listen` and `stripe trigger` commands for checkout success, failed renewal, subscription update, cancellation, refund, duplicate delivery, and out-of-order event review.
176
+
173
177
  ## Project Configuration
174
178
 
175
179
  Add `.ai-saas-guard.json` at the repository root to tune findings without changing scanner code. The CLI auto-loads this file from `--root` when it exists. Use `--config <file>` to point to a different JSON file.
@@ -191,7 +195,7 @@ Add `.ai-saas-guard.json` at the repository root to tune findings without changi
191
195
 
192
196
  ## GitHub Action
193
197
 
194
- The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.3.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
198
+ The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.5.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
195
199
 
196
200
  ```yaml
197
201
  name: ai-saas-guard
@@ -320,10 +324,9 @@ Open-source core:
320
324
 
321
325
  Near-term priorities:
322
326
 
323
- - expanded Supabase RLS fixtures
324
- - Stripe webhook replay cookbook
325
327
  - launch-readiness checklist content
326
328
  - false-positive suppression and rule stability labels
329
+ - GitHub App design note for the potential hosted layer
327
330
 
328
331
  Potential paid layer later:
329
332
 
@@ -66,7 +66,7 @@ export const RULE_CATALOG = {
66
66
  ruleId: "supabase.rls.broad-policy",
67
67
  severity: "critical",
68
68
  title: "Broad Supabase RLS policy",
69
- why: "`USING (true)` often turns login into public data access.",
69
+ why: "`USING (true)` or `WITH CHECK (true)` can turn login into broad data access or writes.",
70
70
  stability: "default"
71
71
  },
72
72
  "supabase.rls.missing-ownership-filter": {
@@ -76,6 +76,13 @@ export const RULE_CATALOG = {
76
76
  why: "Policies need resource ownership or tenant membership checks.",
77
77
  stability: "default"
78
78
  },
79
+ "supabase.rls.weak-with-check": {
80
+ ruleId: "supabase.rls.weak-with-check",
81
+ severity: "high",
82
+ title: "Supabase write policy has a weak WITH CHECK predicate",
83
+ why: "Insert and update policies need WITH CHECK predicates tied to the current user or tenant membership.",
84
+ stability: "default"
85
+ },
79
86
  "supabase.table.missing-owner-column": {
80
87
  ruleId: "supabase.table.missing-owner-column",
81
88
  severity: "medium",
@@ -10,8 +10,8 @@ const criticalEvents = [
10
10
  const eventPattern = /["']((?:checkout\.session\.completed|invoice\.payment_failed|customer\.subscription\.(?:deleted|updated|created)|charge\.(?:refunded|dispute\.created)|refund\.(?:created|updated)))["']/g;
11
11
  export async function checkStripe(input) {
12
12
  const context = await resolveScanContext(input);
13
- const files = context.files;
14
- const webhookFiles = files.filter((file) => {
13
+ const runtimeFiles = context.files.filter((file) => !isDocumentationFile(file.path));
14
+ const webhookFiles = runtimeFiles.filter((file) => {
15
15
  const path = file.path.toLowerCase();
16
16
  const content = file.content.toLowerCase();
17
17
  return ((path.includes("stripe") && path.includes("webhook")) ||
@@ -19,7 +19,7 @@ export async function checkStripe(input) {
19
19
  content.includes("stripe-signature") ||
20
20
  content.includes("checkout.session.completed"));
21
21
  });
22
- const stripeSignalFiles = files.filter((file) => !/\.(md|txt)$/i.test(file.path) && !file.path.startsWith("docs/"));
22
+ const stripeSignalFiles = runtimeFiles;
23
23
  const usesStripe = webhookFiles.length > 0 ||
24
24
  stripeSignalFiles.some((file) => /STRIPE_SECRET_KEY|STRIPE_WEBHOOK_SECRET|from\s+["']stripe["']|require\(["']stripe["']\)|new\s+Stripe|stripe\.webhooks|checkout\.session\.completed/i.test(`${file.path}\n${file.content}`));
25
25
  const findings = [];
@@ -164,3 +164,7 @@ function firstSnippetMatching(content, pattern) {
164
164
  const line = firstLineMatching(content, pattern);
165
165
  return line ? lineAt(content, line) : undefined;
166
166
  }
167
+ function isDocumentationFile(path) {
168
+ const normalizedPath = path.replace(/\\/g, "/").toLowerCase();
169
+ return normalizedPath.startsWith("docs/") || /\.(md|mdx|rst|txt)$/i.test(normalizedPath);
170
+ }
@@ -28,18 +28,24 @@ export async function checkSupabase(input) {
28
28
  for (const match of file.content.matchAll(/alter\s+table\s+([a-zA-Z0-9_."]+)\s+enable\s+row\s+level\s+security/gi)) {
29
29
  rlsEnabledTables.add(normalizeSqlIdentifier(match[1]));
30
30
  }
31
- for (const match of file.content.matchAll(/create\s+policy\s+"?([^"\n]+)"?\s+on\s+([a-zA-Z0-9_."]+)[\s\S]*?(?:using\s*\(([\s\S]*?)\)|with\s+check\s*\(([\s\S]*?)\))\s*;/gi)) {
32
- const policyName = match[1].trim();
33
- const tableName = normalizeSqlIdentifier(match[2]);
34
- const predicate = `${match[3] ?? ""} ${match[4] ?? ""}`.trim();
35
- const line = lineNumberForIndex(file.content, match.index ?? 0);
36
- if (/\btrue\b/i.test(predicate) || /\bto\s+(public|anon|authenticated)\b[\s\S]*\busing\s*\(\s*true\s*\)/i.test(match[0])) {
31
+ for (const policy of parsePolicies(file.content)) {
32
+ const { name: policyName, tableName, line } = policy;
33
+ const predicates = [policy.usingPredicate, policy.withCheckPredicate].filter((value) => Boolean(value));
34
+ if (isStorageObjectsTable(tableName)) {
35
+ const riskyStoragePredicate = predicates.find((predicate) => isUnscopedStoragePredicate(predicate));
36
+ if (riskyStoragePredicate) {
37
+ findings.push(storageFinding(file.path, file.content, line, "Supabase storage.objects policy lacks owner or tenant scope"));
38
+ }
39
+ continue;
40
+ }
41
+ const broadPredicate = predicates.find((predicate) => isBroadPredicate(predicate));
42
+ if (broadPredicate || /\bto\s+(public|anon|authenticated)\b[\s\S]*\busing\s*\(\s*true\s*\)/i.test(policy.statement)) {
37
43
  riskyPolicies.push({
38
44
  file: file.path,
39
45
  line,
40
46
  policyName,
41
47
  tableName,
42
- reason: "Policy predicate is broad (`USING (true)` or equivalent)."
48
+ reason: "Policy predicate is broad (`USING (true)`, `WITH CHECK (true)`, or equivalent)."
43
49
  });
44
50
  findings.push(finding({
45
51
  ruleId: "supabase.rls.broad-policy",
@@ -51,7 +57,8 @@ export async function checkSupabase(input) {
51
57
  suggestedFix: "Replace broad predicates with ownership checks such as `auth.uid() = user_id` or a tenant membership join."
52
58
  }));
53
59
  }
54
- if (!/\bauth\.uid\s*\(/i.test(predicate) && !ownershipColumnPattern.test(predicate) && sensitiveTablePattern.test(tableName)) {
60
+ const combinedPredicate = predicates.join(" ");
61
+ if (!/\bauth\.uid\s*\(/i.test(combinedPredicate) && !ownershipColumnPattern.test(combinedPredicate) && sensitiveTablePattern.test(tableName)) {
55
62
  riskyPolicies.push({
56
63
  file: file.path,
57
64
  line,
@@ -69,18 +76,28 @@ export async function checkSupabase(input) {
69
76
  suggestedFix: "Reference `auth.uid()` and a stable ownership or membership column in every sensitive table policy."
70
77
  }));
71
78
  }
79
+ if (sensitiveTablePattern.test(tableName) && hasWeakWithCheck(policy)) {
80
+ riskyPolicies.push({
81
+ file: file.path,
82
+ line,
83
+ policyName,
84
+ tableName,
85
+ reason: "Write policy has a weak or missing `WITH CHECK` predicate."
86
+ });
87
+ findings.push(finding({
88
+ ruleId: "supabase.rls.weak-with-check",
89
+ title: `Supabase write policy "${policyName}" has a weak WITH CHECK predicate`,
90
+ severity: "high",
91
+ evidence: [{ file: file.path, line, snippet: lineAt(file.content, line) }],
92
+ why: "A write policy can read the right tenant rows but still allow inserted or updated rows to move into another owner or tenant unless `WITH CHECK` is scoped.",
93
+ suggestedVerification: "As User A, try inserting or updating a row with User B's owner, organization, workspace, or tenant ID and confirm the database rejects it.",
94
+ suggestedFix: "Add a `WITH CHECK` predicate tied to `auth.uid()` and the same owner, tenant, or membership relationship used by the read policy."
95
+ }));
96
+ }
72
97
  }
73
- for (const match of file.content.matchAll(/storage\.buckets[\s\S]{0,200}\bpublic\b\s*[,=]\s*true|create\s+policy[\s\S]{0,200}\bstorage\.objects[\s\S]{0,200}using\s*\(\s*true\s*\)/gi)) {
98
+ for (const match of file.content.matchAll(/storage\.buckets[\s\S]{0,200}\bpublic\b\s*[,=]\s*true/gi)) {
74
99
  const line = lineNumberForIndex(file.content, match.index ?? 0);
75
- findings.push(finding({
76
- ruleId: "supabase.storage.public-bucket",
77
- title: "Supabase storage policy or bucket appears public",
78
- severity: "high",
79
- evidence: [{ file: file.path, line, snippet: lineAt(file.content, line) }],
80
- why: "Public buckets can expose uploads, invoices, profile documents, or tenant files even when database rows are protected.",
81
- suggestedVerification: "Upload a private file as User A and confirm unauthenticated users and User B cannot fetch it by URL.",
82
- suggestedFix: "Make buckets private by default and add storage object policies scoped by owner or tenant."
83
- }));
100
+ findings.push(storageFinding(file.path, file.content, line, "Supabase storage bucket appears public"));
84
101
  }
85
102
  }
86
103
  for (const table of tables) {
@@ -124,3 +141,100 @@ export async function checkSupabase(input) {
124
141
  function normalizeSqlIdentifier(value) {
125
142
  return value.replace(/"/g, "").trim().toLowerCase();
126
143
  }
144
+ function parsePolicies(content) {
145
+ const policies = [];
146
+ const policyPattern = /create\s+policy\s+(?:"([^"]+)"|([^\s\n]+))\s+on\s+([a-zA-Z0-9_."]+)[\s\S]*?;/gi;
147
+ for (const match of content.matchAll(policyPattern)) {
148
+ const statement = match[0];
149
+ policies.push({
150
+ name: (match[1] ?? match[2] ?? "").trim(),
151
+ tableName: normalizeSqlIdentifier(match[3]),
152
+ operation: parsePolicyOperation(statement),
153
+ usingPredicate: extractPredicate(statement, "using"),
154
+ withCheckPredicate: extractPredicate(statement, "with check"),
155
+ statement,
156
+ line: lineNumberForIndex(content, match.index ?? 0)
157
+ });
158
+ }
159
+ return policies;
160
+ }
161
+ function parsePolicyOperation(statement) {
162
+ const match = /\bfor\s+(select|insert|update|delete|all)\b/i.exec(statement);
163
+ const operation = match?.[1]?.toLowerCase();
164
+ if (operation === "select" ||
165
+ operation === "insert" ||
166
+ operation === "update" ||
167
+ operation === "delete" ||
168
+ operation === "all") {
169
+ return operation;
170
+ }
171
+ return "unknown";
172
+ }
173
+ function extractPredicate(statement, clause) {
174
+ const clausePattern = clause === "using" ? /\busing\s*\(/i : /\bwith\s+check\s*\(/i;
175
+ const match = clausePattern.exec(statement);
176
+ if (!match)
177
+ return undefined;
178
+ const openParen = match.index + match[0].lastIndexOf("(");
179
+ return extractBalancedParentheses(statement, openParen);
180
+ }
181
+ function extractBalancedParentheses(value, openParen) {
182
+ let depth = 0;
183
+ let inSingleQuote = false;
184
+ for (let index = openParen; index < value.length; index += 1) {
185
+ const char = value[index];
186
+ const next = value[index + 1];
187
+ if (char === "'" && next === "'") {
188
+ index += 1;
189
+ continue;
190
+ }
191
+ if (char === "'") {
192
+ inSingleQuote = !inSingleQuote;
193
+ continue;
194
+ }
195
+ if (inSingleQuote)
196
+ continue;
197
+ if (char === "(")
198
+ depth += 1;
199
+ if (char === ")") {
200
+ depth -= 1;
201
+ if (depth === 0)
202
+ return value.slice(openParen + 1, index).trim();
203
+ }
204
+ }
205
+ return undefined;
206
+ }
207
+ function isBroadPredicate(predicate) {
208
+ return predicate.trim().toLowerCase() === "true";
209
+ }
210
+ function hasWeakWithCheck(policy) {
211
+ if (policy.operation !== "insert" && policy.operation !== "update" && policy.operation !== "all")
212
+ return false;
213
+ if (!policy.withCheckPredicate)
214
+ return policy.operation === "insert";
215
+ if (isBroadPredicate(policy.withCheckPredicate))
216
+ return true;
217
+ return !isScopedOwnershipPredicate(policy.withCheckPredicate);
218
+ }
219
+ function isScopedOwnershipPredicate(predicate) {
220
+ return /\bauth\.uid\s*\(/i.test(predicate) && ownershipColumnPattern.test(predicate);
221
+ }
222
+ function isStorageObjectsTable(tableName) {
223
+ return tableName === "storage.objects";
224
+ }
225
+ function isUnscopedStoragePredicate(predicate) {
226
+ if (isBroadPredicate(predicate))
227
+ return true;
228
+ return !/\bauth\.uid\s*\(|\bowner\b|\bowner_id\b|\buser_id\b|\btenant_id\b|\borganization_id\b|\bworkspace_id\b/i.test(predicate);
229
+ }
230
+ function storageFinding(filePath, content, line, title) {
231
+ return finding({
232
+ ruleId: "supabase.storage.public-bucket",
233
+ title,
234
+ severity: "high",
235
+ evidence: [{ file: filePath, line, snippet: lineAt(content, line) }],
236
+ why: "Storage object policies or public buckets can expose tenant files even when database rows are protected.",
237
+ suggestedVerification: "Upload a private file as User A and confirm unauthenticated users and User B cannot fetch it by URL or object path.",
238
+ suggestedFix: "Keep buckets private and scope storage object policies by `owner`, `auth.uid()`, tenant, workspace, or a membership relationship."
239
+ });
240
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  `ai-saas-guard` ships as a composite GitHub Action for pull request and code scanning workflows.
4
4
 
5
- Use `zr9959/ai-saas-guard@v0` for the latest compatible pre-1.0 Action. Use a specific tag such as `v0.3.0` or a reviewed commit SHA when reproducibility is more important than automatic minor updates.
5
+ Use `zr9959/ai-saas-guard@v0` for the latest compatible pre-1.0 Action. Use a specific tag such as `v0.5.0` or a reviewed commit SHA when reproducibility is more important than automatic minor updates.
6
6
 
7
7
  ## PR Summary
8
8
 
@@ -5,10 +5,10 @@
5
5
  ## Current State
6
6
 
7
7
  - Package name: `ai-saas-guard`
8
- - Current version: `0.3.0`
8
+ - Current version: `0.5.0`
9
9
  - npm registry state: published at <https://www.npmjs.com/package/ai-saas-guard>
10
10
  - First npm-published version: `0.1.1`
11
- - GitHub Release: `v0.3.0`
11
+ - GitHub Release: `v0.5.0`
12
12
  - Publish workflow: `.github/workflows/npm-publish.yml`
13
13
  - Trusted Publisher: GitHub Actions, `zr9959/ai-saas-guard`, workflow `npm-publish.yml`, allowed action `npm publish`
14
14
  - Long-lived npm publish token: not required
@@ -17,7 +17,7 @@
17
17
 
18
18
  Use GitHub Actions with npm Trusted Publisher/OIDC:
19
19
 
20
- 1. Create and review a release tag such as `v0.3.0`.
20
+ 1. Create and review a release tag such as `v0.5.0`.
21
21
  2. Publish from the GitHub Release or run the `Publish npm` workflow manually with `ref` set to that tag.
22
22
  3. Keep `permissions.id-token: write` in the workflow so npm can exchange the GitHub Actions OIDC identity for a short-lived publish credential.
23
23
  4. Run `npm publish --access public` from the workflow. Trusted publishing automatically generates provenance for this public package from this public repository.
@@ -40,7 +40,8 @@ Implemented surfaces:
40
40
 
41
41
  - secret-like values and risky public env exposure
42
42
  - Stripe webhook signature, raw body, idempotency, and lifecycle handler heuristics
43
- - Supabase RLS, broad policy, ownership filter, and public storage heuristics
43
+ - Stripe webhook replay cookbook for checkout, renewal failure, updates, cancellation, refunds, duplicate delivery, and out-of-order review
44
+ - Supabase RLS, tenant membership, ownership filter, weak `WITH CHECK`, and storage object policy heuristics
44
45
  - sensitive API route heuristics
45
46
  - MCP config side-effect and secret-bearing risk inventory
46
47
  - Next/Vercel deploy and runtime footguns
@@ -100,8 +101,6 @@ GitHub Project:
100
101
  Current issue set:
101
102
 
102
103
  - #1 Add launch-readiness checklist content
103
- - #5 Write Stripe webhook replay cookbook
104
- - #7 Expand Supabase RLS fixtures and ownership patterns
105
104
 
106
105
  CI:
107
106
 
@@ -113,7 +112,7 @@ CI:
113
112
  Publishing:
114
113
 
115
114
  - npm package: `ai-saas-guard`
116
- - Current release line: `v0.3.0`
115
+ - Current release line: `v0.5.0`
117
116
  - Publish workflow: `.github/workflows/npm-publish.yml`
118
117
  - Trusted Publisher: GitHub Actions for `zr9959/ai-saas-guard`, workflow `npm-publish.yml`
119
118
  - Long-lived npm publish tokens should not be required.
@@ -140,11 +139,9 @@ Not allowed:
140
139
 
141
140
  Recommended order:
142
141
 
143
- 1. Expand Supabase RLS fixtures and ownership patterns.
144
- 2. Write Stripe webhook replay cookbook.
145
- 3. Add launch-readiness checklist content.
146
- 4. Improve false-positive suppression and rule stability labels.
147
- 5. Add a GitHub App design note for the potential hosted layer.
142
+ 1. Add launch-readiness checklist content.
143
+ 2. Improve false-positive suppression and rule stability labels.
144
+ 3. Add a GitHub App design note for the potential hosted layer.
148
145
 
149
146
  For every feature, keep the scanner evidence-first:
150
147
 
package/docs/rules.md CHANGED
@@ -27,11 +27,12 @@ Rule metadata is centralized in `src/rules/catalog.ts` and covered by tests so S
27
27
 
28
28
  | Rule ID | Severity | Why it exists |
29
29
  | --- | --- | --- |
30
- | `supabase.rls.broad-policy` | critical | `USING (true)` often turns login into public data access. |
30
+ | `supabase.rls.broad-policy` | critical | `USING (true)` or `WITH CHECK (true)` can turn login into broad data access or writes. |
31
31
  | `supabase.rls.missing-ownership-filter` | high | Policies need resource ownership or tenant membership checks. |
32
+ | `supabase.rls.weak-with-check` | high | Write policies need `WITH CHECK` predicates tied to the current user or tenant membership. |
32
33
  | `supabase.table.missing-owner-column` | medium | Sensitive tables are hard to protect without owner/tenant keys. |
33
34
  | `supabase.rls.not-enabled` | critical | User-data tables should enable row level security. |
34
- | `supabase.storage.public-bucket` | high | Storage buckets can leak files even when database rows are protected. |
35
+ | `supabase.storage.public-bucket` | high | Storage buckets or unscoped storage object policies can leak files even when database rows are protected. |
35
36
 
36
37
  ## API Routes And Deploy
37
38
 
@@ -0,0 +1,206 @@
1
+ # Stripe Webhook Replay Cookbook
2
+
3
+ Use this cookbook after `ai-saas-guard check-stripe` flags missing signature checks, missing idempotency, missing lifecycle handlers, or unclear entitlement updates.
4
+
5
+ It is a local test workflow for reviewers. It does not prove the production integration is secure, and it does not replace Stripe Dashboard checks, production endpoint configuration review, or two-account authorization tests.
6
+
7
+ ## Preconditions
8
+
9
+ - Run against a sandbox or test-mode Stripe account.
10
+ - Start the app locally with the same webhook route code that will be deployed.
11
+ - Use fake local users and test subscriptions only.
12
+ - Do not paste real API keys, signing secrets, customer data, or production URLs into issue comments or logs.
13
+ - Make sure your handler verifies the `Stripe-Signature` header with Stripe's raw request body before changing billing state.
14
+
15
+ Start local forwarding in one terminal:
16
+
17
+ ```bash
18
+ stripe listen --forward-to localhost:3000/api/stripe/webhook
19
+ ```
20
+
21
+ Copy the signing secret printed by `stripe listen` into your local server-only environment variable. Keep it out of client bundles and public logs.
22
+
23
+ In another terminal, run the scanner first:
24
+
25
+ ```bash
26
+ npx ai-saas-guard@latest check-stripe --root .
27
+ ```
28
+
29
+ Use the findings as the review queue. Each finding should map to at least one replay below.
30
+
31
+ ## What To Observe
32
+
33
+ For every replay, record these four facts:
34
+
35
+ | Question | Expected evidence |
36
+ | --- | --- |
37
+ | Did the webhook return `2xx` only after verification and reconciliation? | Server log or test assertion |
38
+ | Was the Stripe event ID stored or deduped? | `event.id` row, unique key, or idempotency log |
39
+ | Did app entitlement state change correctly? | Plan, access, seat, credit, or subscription status row |
40
+ | Is the user-facing state consistent after refresh? | Dashboard, gated route, API response, or account page |
41
+
42
+ Entitlement reconciliation means the app derives access from Stripe's current billing truth, then writes one durable local state. Typical examples are `plan`, `subscription_status`, `current_period_end`, `cancel_at_period_end`, active seats, credit balance, or an access table keyed by user, organization, customer, or subscription.
43
+
44
+ ## Replay Matrix
45
+
46
+ Run the commands one at a time. Watch both the `stripe listen` terminal and your app server logs.
47
+
48
+ | Scenario | Command | What to verify |
49
+ | --- | --- | --- |
50
+ | Checkout success | `stripe trigger checkout.session.completed` | The app maps the Checkout Session to the correct user or tenant and grants access only after signature verification. |
51
+ | Failed renewal | `stripe trigger invoice.payment_failed` | The app marks the subscription past-due, starts a grace path if intended, and does not leave unrestricted paid access forever. |
52
+ | Subscription update | `stripe trigger customer.subscription.updated` | Plan, quantity, cancel-at-period-end, period end, and status changes reconcile into local entitlement state. |
53
+ | Cancellation | `stripe trigger customer.subscription.deleted` | Access is revoked or downgraded deterministically for the correct customer or tenant. |
54
+ | Refund | `stripe trigger charge.refunded` | Refund handling does not accidentally grant access, double-credit an account, or ignore a required downgrade workflow. |
55
+
56
+ If a command is not available in your installed Stripe CLI, run `stripe trigger --help` or the event category help such as `stripe trigger customer.subscription --help`, then use the closest supported test event for the same billing state transition.
57
+
58
+ ## Checkout Success
59
+
60
+ ```bash
61
+ stripe trigger checkout.session.completed
62
+ ```
63
+
64
+ Review checklist:
65
+
66
+ - The handler rejects unsigned requests before any database write.
67
+ - The handler stores or checks the Stripe `event.id`.
68
+ - The session is linked to a local user, organization, or tenant through metadata, customer ID, or subscription ID.
69
+ - The app does not grant access only from the success redirect page.
70
+ - Refreshing the app shows access from database state, not from a one-time URL parameter.
71
+
72
+ `ai-saas-guard` findings this can validate:
73
+
74
+ - `stripe.webhook.missing-signature`
75
+ - `stripe.webhook.no-entitlement-path`
76
+ - `stripe.webhook.missing-idempotency`
77
+
78
+ ## Failed Invoice
79
+
80
+ ```bash
81
+ stripe trigger invoice.payment_failed
82
+ ```
83
+
84
+ Review checklist:
85
+
86
+ - The local subscription is not left as fully active without a documented grace policy.
87
+ - The app records failure state in the same entitlement system used by normal access checks.
88
+ - Customer notification or billing portal recovery is queued if that is part of the product flow.
89
+ - A later recovery event can move the account back to the intended state without manual database edits.
90
+
91
+ `ai-saas-guard` findings this can validate:
92
+
93
+ - `stripe.webhook.missing-critical-event`
94
+ - `stripe.webhook.no-entitlement-path`
95
+
96
+ ## Subscription Update
97
+
98
+ ```bash
99
+ stripe trigger customer.subscription.updated
100
+ ```
101
+
102
+ Review checklist:
103
+
104
+ - Plan upgrades and downgrades update the local plan or entitlement rows.
105
+ - Seat quantity changes do not leave stale access.
106
+ - `cancel_at_period_end` and period boundaries are persisted if the app uses them.
107
+ - The handler reconciles from Stripe identifiers instead of trusting a client-provided user ID.
108
+
109
+ `ai-saas-guard` findings this can validate:
110
+
111
+ - `stripe.webhook.missing-critical-event`
112
+ - `stripe.webhook.no-entitlement-path`
113
+
114
+ ## Cancellation
115
+
116
+ ```bash
117
+ stripe trigger customer.subscription.deleted
118
+ ```
119
+
120
+ Review checklist:
121
+
122
+ - The correct user, organization, or tenant loses paid access.
123
+ - Shared team access is downgraded consistently, not only the account owner.
124
+ - The app handles repeated cancellation events without throwing or creating inconsistent rows.
125
+ - Historical records remain available only according to product policy.
126
+
127
+ `ai-saas-guard` findings this can validate:
128
+
129
+ - `stripe.webhook.missing-critical-event`
130
+ - `stripe.webhook.missing-idempotency`
131
+
132
+ ## Refund
133
+
134
+ ```bash
135
+ stripe trigger charge.refunded
136
+ ```
137
+
138
+ Review checklist:
139
+
140
+ - Refund handling is explicit, even if the intended action is "record and review manually."
141
+ - Credits, invoices, or access extensions are not applied twice.
142
+ - Refund events do not bypass subscription status checks.
143
+ - Support/admin workflows have enough evidence to understand which customer, invoice, and subscription were involved.
144
+
145
+ `ai-saas-guard` findings this can validate:
146
+
147
+ - `stripe.webhook.missing-critical-event`
148
+ - `stripe.webhook.no-entitlement-path`
149
+
150
+ ## Duplicate Event Replay
151
+
152
+ Stripe can retry events, and the same event can reach your handler more than once. Your handler should be idempotent around the Stripe event ID and the domain object it mutates.
153
+
154
+ Practical replay:
155
+
156
+ ```bash
157
+ stripe trigger checkout.session.completed
158
+ ```
159
+
160
+ Then replay the same captured event through your own test harness, fixture, or integration test. The important assertion is not that the Stripe CLI creates the same event twice; it is that your app has a test path where the same `event.id` is processed twice and the second attempt is a no-op.
161
+
162
+ Review checklist:
163
+
164
+ - `event.id` is stored with a unique constraint or equivalent dedupe guard.
165
+ - The second delivery returns success without repeating fulfillment.
166
+ - Access grants, invoices, credits, seats, and emails are not duplicated.
167
+ - The handler is safe if the first attempt partially wrote state and then crashed.
168
+
169
+ `ai-saas-guard` findings this can validate:
170
+
171
+ - `stripe.webhook.missing-idempotency`
172
+
173
+ ## Out-of-Order Event Questions
174
+
175
+ Stripe events should be treated as signals to reconcile state, not as a guarantee that the app saw every prior transition in the expected order.
176
+
177
+ Use these questions during review:
178
+
179
+ - What happens if `customer.subscription.updated` arrives before the app processed `checkout.session.completed`?
180
+ - What happens if `invoice.payment_failed` arrives after a manual admin upgrade?
181
+ - What happens if `customer.subscription.deleted` arrives after a refund workflow already changed local access?
182
+ - Does the handler fetch or derive current subscription/customer state before writing final entitlement state?
183
+ - Is the local write guarded by Stripe customer, subscription, and tenant ownership, not just by event type?
184
+
185
+ When possible, add tests that call the entitlement reconciliation function directly with events in a different order. The durable result should match Stripe's current billing truth and the product's explicit grace/cancellation policy.
186
+
187
+ ## Minimal Acceptance Checklist
188
+
189
+ Before launch or merge, a reviewer should be able to answer yes to each item:
190
+
191
+ - Unsigned webhook requests are rejected before database writes.
192
+ - Raw request body is used for signature verification.
193
+ - Every handled event stores or dedupes `event.id`.
194
+ - `checkout.session.completed` grants access through webhook reconciliation, not only redirect success.
195
+ - `invoice.payment_failed` has an explicit past-due or grace behavior.
196
+ - `customer.subscription.updated` updates plan, quantity, period, cancellation, and status fields used by access checks.
197
+ - `customer.subscription.deleted` revokes or downgrades access for the correct user or tenant.
198
+ - `charge.refunded` has an explicit record, downgrade, or manual review path.
199
+ - Duplicate deliveries are no-ops after the first successful reconciliation.
200
+ - Out-of-order events reconcile to one durable local entitlement state.
201
+
202
+ ## Source Links
203
+
204
+ - Stripe CLI trigger docs: https://docs.stripe.com/stripe-cli/triggers
205
+ - Stripe CLI forwarding docs: https://docs.stripe.com/stripe-cli/use-cli
206
+ - Stripe webhook signature docs: https://docs.stripe.com/webhooks/signature
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-saas-guard",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Repo-local launch-readiness scanner for AI-built SaaS apps.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/zr9959/ai-saas-guard#readme",