@ssheleg/sheleg-dev 0.13.2 → 0.13.4

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/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## v0.13.4 — FW03 credential boundaries
2
+
3
+ - Supply a copyable, locally tested Stripe mode assertion before client construction.
4
+ - Refuse explicit Google service-account JSON and OAuth client-secret assignments in
5
+ non-production or undeclared runs, while preserving public metadata assignments.
6
+ - Correct Google Testing advice for basic identity scopes and Workspace controls.
7
+ - Replace a Google access-token log with a value-free event. Document ambient ADC,
8
+ skills-only hooks and CAPI coverage limits. No provider operation was performed.
9
+ - Validate explicit repository paths inside skill Markdown without treating consumer
10
+ project paths as repository claims.
11
+
12
+ ## v0.13.3 — a lightweight tag cannot publish a release
13
+
14
+ - **The release refuses a lightweight tag.** `release.yml` gains one step, right after
15
+ checkout and before any gate: `git cat-file -t` on the tag must print `tag`; a `commit`
16
+ fails the job with the remedy (`git tag -a <tag> -m '<release>'` at the same commit)
17
+ before anything is published. `git describe` and `git submodule status` see annotated
18
+ tags only, and the 2026-10-09 family wave cut four lightweight member tags that the
19
+ umbrella then read as each member's previous release. The step is identical across the
20
+ family (umbrella plan 2026-10-10, T5 / REQ-6). Published tags are not re-cut.
21
+ - No skill, reference or installer change.
22
+
1
23
  ## v0.13.2 — setup follows host capabilities
2
24
 
3
25
  Error tracking no longer assumes every non-Claude host lacks MCP or that setup
package/README.md CHANGED
@@ -159,7 +159,7 @@ person has signed the category off **for this session**:
159
159
  | Category | What it stops |
160
160
  |---|---|
161
161
  | `live-key` | a live-shaped `sk_live_…` / `rk_live_…` reaching a command |
162
- | `credential` | exporting a credential whose provider issues no test value — `HELEKET_API_KEY` — in a run that declares `test`, or declares nothing |
162
+ | `credential` | explicitly assigning `HELEKET_API_KEY`, `GOOGLE_CREDENTIALS` or `GOOGLE_CLIENT_SECRET` in a run that declares `test`, or declares nothing |
163
163
  | `refund` | `stripe refunds create`, a POST to a `…/v1/refunds` URL, or a `create_refund` tool |
164
164
  | `payout` | `stripe payouts\|transfers create`, a POST to `…/v1/payouts`, `…/v1/transfers` or Heleket's `…/v1/payout` |
165
165
  | `dispute` | `stripe disputes close`, or `…/v1/disputes/…/close` — closing one accepts the loss |
@@ -167,6 +167,18 @@ person has signed the category off **for this session**:
167
167
  | `self-authorisation` | a command that sets this gate's own switch. Never authorisable |
168
168
  | `skip-billing` | `SKIP_BILLING=true` in a run declaring production — the free-money path. Never authorisable |
169
169
 
170
+ The Google names identify service-account JSON and an OAuth client secret. A Google
171
+ OAuth app in Testing still uses real credentials and can access permitted real data.
172
+ Public client IDs, project IDs, pixel IDs and ADC configuration paths are not treated
173
+ as secrets by name. This command-text guard does not inspect inherited credentials,
174
+ files or ambient ADC; skills-only installations do not activate it.
175
+
176
+ Stripe clients can copy the pure boot assertion from
177
+ [`assert-stripe-env.mjs`](plugins/sheleg-dev/skills/stripe-billing/scripts/assert-stripe-env.mjs).
178
+ It compares `STRIPE_PRODUCT_MODE` with a secret/restricted key prefix before client
179
+ construction and fails on unknown formats. It does not prove credential validity
180
+ or grant permission for a provider operation.
181
+
170
182
  **Authorising one.** The authorised person exports the category — or `all` — in the
171
183
  shell that starts the session, and starts a new one:
172
184
 
package/SECURITY.md CHANGED
@@ -17,12 +17,11 @@ documents name that does not exist here.
17
17
 
18
18
  ## What ships, and what of it executes
19
19
 
20
- `git ls-files plugins` returns **61 files: 35 markdown, one plugin manifest, three
21
- files of the manual gate, and 22 files of money fixtures.** Two of the seven skills now
22
- ship code you can run — the assertion packs under `fixtures/` — and that is a change
23
- from every release before v0.7.0, so it is stated here rather than left to be noticed.
24
- They run only when you invoke them, read only the JSON fixtures beside them, and open
25
- no socket; the greps below prove all three.
20
+ `git ls-files plugins` returns **62 files: 35 markdown, one plugin manifest, three
21
+ files of the manual gate, 22 files of money fixtures, and one Stripe mode assertion.**
22
+ The fixture runners read their adjacent JSON only when invoked. The new Stripe
23
+ assertion is a pure function: caller-supplied mode and key in, mode or error code out;
24
+ no I/O, network or logging. Neither runs during installation.
26
25
 
27
26
  | Component | Count | Runtime behavior |
28
27
  |---|---|---|
@@ -32,6 +31,7 @@ no socket; the greps below prove all three.
32
31
  | `plugins/sheleg-dev/hooks/hooks.json` | 1 | Hook manifest read by Claude Code. Declares one `PreToolUse` hook and no `if` filter. |
33
32
  | `plugins/sheleg-dev/hooks/money-gate.js` | 1 | Runs on a tool call, when the plugin is enabled. Requires `path` and its own decision module — nothing else. Reads stdin, writes one JSON line, exits 0 on every path. |
34
33
  | `plugins/sheleg-dev/hooks/lib/moneygate.js` | 1 | Pure. Payload and environment in, verdict out. No `require` at all, no filesystem, no clock. |
34
+ | `plugins/sheleg-dev/skills/stripe-billing/scripts/assert-stripe-env.mjs` | 1 | Copyable boot assertion. Compares the declared mode with a secret/restricted key prefix. No I/O or logging; does not authenticate a credential. |
35
35
  | `bin/sheleg-dev.js` — the npm installer | 1 | Runs only when you invoke it. Node built-ins only: `fs`, `path`, `os`. |
36
36
  | `install.sh` — the shell installer | 1 | **Not in the tarball** — `files` ships `bin/` and `plugins/`, so this one reaches you only through a clone. Runs when you invoke it. Shell tools only: `cd`, `pwd`, `dirname`, `basename`, `mkdir`, `rm`, `cp`, `echo`, plus `sed` and `head` to read the plugin registry it refuses to shadow. It is the destructive channel: `rm -rf` per skill, then `cp -R`. |
37
37
  | `plugins/sheleg-dev/skills/*/fixtures/*.json` — webhook payloads | 16 | Data. Placeholder ids only; no key, token, signing secret or real customer id. |
@@ -39,7 +39,7 @@ no socket; the greps below prove all three.
39
39
  | `plugins/sheleg-dev/skills/*/fixtures/manifest.json` and its `README.md` | 4 | Text. The invariant-to-fixture-to-document map, which `test/validate.py` reads. |
40
40
 
41
41
  The published tarball is `bin/` and `plugins/` plus `README.md`, `CHANGELOG.md`,
42
- `SECURITY.md`, `LICENSE` and `package.json` — 67 files, listed by
42
+ `SECURITY.md`, `LICENSE` and `package.json` — 68 files, listed by
43
43
  `npm pack --dry-run`. `install.sh`, `test/`, `docs/` and `CONTRIBUTING.md` are **not**
44
44
  in it; `test/validate.py` now refuses a path these shipped documents name that the
45
45
  tarball does not carry, because resolving in a clone is the wrong question for a
@@ -54,8 +54,7 @@ in it can do is print nothing. It reads `process.env` for one variable pair
54
54
  declaration a run already carries, and it never writes to any file. `README.md` →
55
55
  *The manual gate* is its single home.
56
56
 
57
- **There are no npm lifecycle scripts.** `package.json` declares exactly one
58
- script, `test`. Installing this package, or reaching it through `npx`, runs
57
+ **There are no npm lifecycle scripts.** `package.json` declares development and test scripts only; none are install lifecycle hooks. Installing this package, or reaching it through `npx`, runs
59
58
  nothing but the CLI you typed.
60
59
 
61
60
  ## What the installers read and write
@@ -92,8 +91,10 @@ installer deletes your edits and the node installer does not.
92
91
  ## Credentials
93
92
 
94
93
  **This pack handles payment credentials as a subject and never as a value.** It
95
- reads no credential, stores none, transmits none, logs none. It is markdown; it
96
- could not.
94
+ stores and transmits no credential and logs no credential value. Its hook examines
95
+ command text, including explicit credential assignments; the copyable Stripe
96
+ assertion accepts a caller-supplied key to check its mode prefix. Neither reads a
97
+ secret store. Knowledge examples describe credentials without shipping real ones.
97
98
 
98
99
  What it contains is credential *names*, inside example code you adapt —
99
100
  `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `HELEKET_API_KEY`,
@@ -177,11 +178,11 @@ git clone https://github.com/ssheleg/sheleg-dev && cd sheleg-dev
177
178
  # self-describing documents name -- this file included.
178
179
  python3 test/validate.py
179
180
 
180
- # The shipped payload: 61 files.
181
+ # The shipped payload: 62 files.
181
182
  git ls-files plugins
182
183
 
183
- # 26 lines: the plugin manifest, the three files of the manual gate, and the 22
184
- # files of money fixtures. Everything else in the payload is markdown.
184
+ # 27 lines: the plugin manifest, the three files of the manual gate, and the 22
185
+ # files of money fixtures, plus the Stripe mode assertion. Everything else is markdown.
185
186
  git ls-files plugins | grep -v '\.md$'
186
187
 
187
188
  # Every require the gate makes: two lines, both in the hook -- `path`, and its own
@@ -212,7 +213,7 @@ node test/installer_test.js
212
213
  grep -rnE "child_process|\bfetch\(|require\('(http|https|net|dns|tls)'\)|spawn\(|execFile|writeFileSync|appendFileSync|unlinkSync|process\.env\." \
213
214
  plugins/sheleg-dev/skills/*/fixtures/*.mjs
214
215
 
215
- # The entire I/O surface of the only two executable files: twelve lines. Three
216
+ # The I/O surface of the two installers: twelve lines. Three
216
217
  # built-in requires, the copy/mkdir/remove calls, homedir, install.sh's
217
218
  # rm -rf + cp -R, and one comment naming rm -rf. No process, no socket, no
218
219
  # network — the plugin-registry read that decides the shadow refusal is
@@ -222,6 +223,6 @@ grep -nE "require|child_process|exec|spawn|fetch|socket|rm -rf|cp -R|copyFileSyn
222
223
  # Live-key shapes across the skill payload: one line, and it is a placeholder.
223
224
  grep -rnE "sk_live_[A-Za-z0-9]|rk_live_[A-Za-z0-9]|whsec_[A-Za-z0-9]{8}|BEGIN [A-Z ]*PRIVATE KEY" plugins
224
225
 
225
- # What npm publishes, and nothing else: 67 files.
226
+ # What npm publishes, and nothing else: 68 files.
226
227
  npm pack --dry-run
227
228
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/sheleg-dev",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "description": "Seven integration skills for the layer a product reaches once it has users: Stripe subscription billing and cancellation retention, crypto payments, ad and conversion tracking under Consent Mode v2, Google sign-in and the wider Google auth surface, error tracking, and Core Web Vitals. This package is the installer CLI.",
5
5
  "bin": {
6
6
  "sheleg-dev": "bin/sheleg-dev.js"
@@ -3,7 +3,7 @@
3
3
  "name": "sheleg-dev",
4
4
  "displayName": "Sheleg Dev",
5
5
  "description": "The integration layer a product needs once it has users: Stripe subscription billing reconciled into your own database, crypto payments that survive over-payment and duplicate webhooks, ad and conversion tracking under Consent Mode v2, Google sign-in with the account-linking guards, the wider Google auth surface, and Core Web Vitals work that moves the score, and Sentry error tracking wired so it does not forward your own credentials to a third party and so a stack trace names the commit that caused it.",
6
- "version": "0.13.2",
6
+ "version": "0.13.4",
7
7
  "author": {
8
8
  "name": "ssheleg",
9
9
  "url": "https://x.com/sshlg93"
@@ -267,7 +267,18 @@ const READERS = new Set([
267
267
  * environment into the key, so `STRIPE_SECRET_KEY=sk_test_…` is correct and common. The
268
268
  * live half is caught by its shape (`LIVE_KEY`), not by the variable's name.
269
269
  */
270
+ // These named Google values are secrets, not client IDs or ADC configuration paths.
271
+ // Their authority comes from scopes/IAM and grants, never a test/live key prefix.
272
+ // Source and bounds: google-auth/references/adc-and-service-accounts.md and
273
+ // oauth2-web-server.md, Credential boundary sections (FW03).
270
274
  const NO_TEST_VARIANT = new Map([
275
+ ['GOOGLE_CREDENTIALS',
276
+ 'Google service-account credential JSON carries authority from IAM, not a test-key ' +
277
+ 'prefix. Use a credential-free mock for offline tests; an isolated test project is ' +
278
+ 'still a real provider operation, with separately restricted principal and resources.'],
279
+ ['GOOGLE_CLIENT_SECRET',
280
+ 'Google OAuth client secrets have no declared test/live key prefix. OAuth Testing ' +
281
+ 'status limits app access; it is not a fake credential or proof that user data is synthetic.'],
271
282
  ['HELEKET_API_KEY',
272
283
  'Heleket issues one key per merchant, it is also the webhook signing secret, and it ' +
273
284
  'carries no test/live marker — so there is no test value this can hold ' +
@@ -487,13 +498,12 @@ function decide(payload, env) {
487
498
  if (allowedFor('credential', e, runEnv)) break;
488
499
  if (runEnv === 'production') {
489
500
  return deny('credential',
490
- `this command exports ${name} — a live merchant credential`, why, runEnv);
501
+ `this command exports ${name} — a provider credential`, why, runEnv);
491
502
  }
492
503
  return deny('credential',
493
504
  `this command exports ${name} in a run declaring ${runEnv || 'nothing at all'}`,
494
- `${why} SD-02's boot assertion refuses the same pair as ` +
495
- 'HELEKET_ENV_TEST_HOLDS_LIVE_CREDENTIAL, but it runs inside the application — a shell ' +
496
- 'that merely holds the key never reaches it.', runEnv);
505
+ `${why} This shell assignment is checked before the application starts; ` +
506
+ 'the gate does not inspect credential values or infer a safe provider environment.', runEnv);
497
507
  }
498
508
  }
499
509
 
@@ -264,3 +264,20 @@ or use `lintrk` from the code path that actually completed the action.
264
264
 
265
265
  ---
266
266
 
267
+
268
+ ## Credential boundary for the server adapter
269
+
270
+ The shipped `sendCapi` example and dedup fixtures specify event payloads; they do not
271
+ provision an access token or implement network authentication. Public pixel IDs belong
272
+ in browser configuration; provider access tokens belong only in the server's named
273
+ secret store. Before supplying a real token, bind the adapter to a separately identified
274
+ account/dataset and approved operation. Offline fixtures use the supplied emitter and
275
+ no provider token. A payload fixture passing is not credential isolation or successful
276
+ Meta delivery. This pack currently has no CAPI token-name rule in its shell gate.
277
+
278
+ Meta's [official Node SDK](https://github.com/facebook/facebook-nodejs-business-sdk/blob/main/README.md)
279
+ describes access tokens as opaque and requires one for the server client (checked
280
+ 2026-10-13). Do not infer test/live authority from a guessed prefix. The current Meta
281
+ Test Events documentation could not be retrieved in this pass (HTTP 429); the exact
282
+ provider test-token/permission contract remains unverified, so no claim that a test-event
283
+ marker restricts the token is made here.
@@ -235,7 +235,8 @@ client.on('tokens', (tokens) => {
235
235
  if (tokens.refresh_token) {
236
236
  // Store refresh_token — only sent on first auth
237
237
  }
238
- console.log(tokens.access_token);
238
+ // Record an event name only; token values never enter logs.
239
+ console.info("google_auth_tokens_refreshed");
239
240
  });
240
241
  ```
241
242
 
@@ -459,3 +459,21 @@ decoded = id_token.verify_token(
459
459
  | `GOOGLE_CLOUD_PROJECT` | Default project ID |
460
460
  | `GCLOUD_PROJECT` | Alias for project ID |
461
461
  | `HTTPS_PROXY` / `https_proxy` | Proxy for HTTPS requests |
462
+
463
+ ## Credential boundary: real authority versus offline mocks
464
+
465
+ Checked 2026-10-13 against [Google's ADC documentation](https://docs.cloud.google.com/docs/authentication/application-default-credentials).
466
+ `GOOGLE_CREDENTIALS` in the JSON-loading examples carries a service-account private key.
467
+ It has no sandbox marker comparable to Stripe's mode prefix. A local or staging process
468
+ can still access real resources through its IAM grants: isolate the project, principal
469
+ and permissions before a real-provider test. Offline fixtures should inject an in-memory
470
+ auth adapter and no credentials.
471
+
472
+ The pack's Claude plugin gate refuses explicit shell assignments to `GOOGLE_CREDENTIALS`
473
+ in a test/undeclared run; a real-provider run uses the existing operator-authorised
474
+ credential category. A name in a grep pattern and `GOOGLE_CLOUD_PROJECT` are metadata,
475
+ not secret assignments. `GOOGLE_APPLICATION_CREDENTIALS` is deliberately not classified
476
+ as a secret by its name: it can point to either a key or keyless federation configuration.
477
+ Allowing that path does not prove its target safe. Verify the resolved principal and
478
+ source without reading a secret into logs; ambient ADC, metadata identities, files and
479
+ inherited environment are outside this lexical assignment check.
@@ -684,3 +684,19 @@ Common scopes:
684
684
  - `https://www.googleapis.com/auth/drive.readonly` — Read-only Drive
685
685
  - `https://www.googleapis.com/auth/calendar.readonly` — Read-only Calendar
686
686
  - `https://www.googleapis.com/auth/cloud-platform` — Full GCP access
687
+
688
+ ## Credential boundary: OAuth Testing is not a fake credential
689
+
690
+ Checked 2026-10-13 against [Google's OAuth app-state documentation](https://developers.google.com/identity/protocols/oauth2/production-readiness/overview).
691
+ The consent screen's Testing state governs access and token lifetime; it does not turn
692
+ a user's data or a client secret into synthetic fixtures. Use separate clients, test
693
+ accounts and least scopes for provider tests; an offline mock needs no Google secret.
694
+ The plugin gate refuses explicit `GOOGLE_CLIENT_SECRET` shell assignments in a test or
695
+ undeclared run. Existing operator authorisation is required for its real-provider path.
696
+ This does not classify inherited credentials, files or refresh/access tokens; those
697
+ still require the application's credential boundary. Errors and logs carry event names
698
+ or stable codes only, never a token value.
699
+
700
+ `GOOGLE_CLIENT_ID` is public metadata. The `google-signin` GIS ID-token flow does not use
701
+ a client secret at all; do not add one to make a test pass. See the `google-signin` skill
702
+ for its nonce, audience, origin and session checks.
@@ -110,8 +110,11 @@ attacker's account.
110
110
 
111
111
  ## Setup (GCP)
112
112
 
113
- 1. Cloud Console → APIs & Services → OAuth consent screen (External;
114
- publish it — in "Testing" only allowlisted users can sign in).
113
+ 1. Cloud Console → APIs & Services → OAuth consent screen: choose the audience.
114
+ External Testing normally limits access to test users, but basic identity
115
+ scopes (`openid`, `email`, `profile`) are exempt. Workspace policy can still
116
+ block access; publishing alone is not the remedy. See the dated setup note
117
+ in [the full guide](references/full-guide.md).
115
118
  2. Credentials → Create OAuth client ID → **Web application** →
116
119
  **Authorized JavaScript origins** = every origin the button renders on,
117
120
  incl. `http://localhost:<port>` for dev (scheme+host+port, no path, no
@@ -148,7 +151,7 @@ attacker's account.
148
151
  | Button never renders | GSI async race (add retry loop), empty client ID, or CSP blocks `accounts.google.com` |
149
152
  | "Token used too early/expired" | Server clock skew → sync NTP |
150
153
  | Audience mismatch | Frontend/backend client IDs differ → single env source of truth |
151
- | `access_denied` on consent | Consent screen still in Testing → publish the app |
154
+ | `access_denied` on consent | Check requested scopes, audience and Workspace policy. Testing allowlisting applies beyond basic identity scopes; investigate before changing publishing status |
152
155
  | Works locally, breaks in prod | Prod origin missing in GCP, or `Secure` cookie served over plain HTTP |
153
156
  | One Tap missing / FedCM console warnings | FedCM is mandatory since Aug 2025 and the opt-out is gone (checked 2026-08-06). Load `gsi/client` from Google and keep it current — a vendored or pinned copy is the real break. The rendered button flow is unaffected; code branching on the old `isNotDisplayed()` moment callbacks is not |
154
157
 
@@ -219,8 +219,12 @@ cookie** — out of reach of JS/XSS, never sent cross-site.
219
219
  1. <https://console.cloud.google.com/> → create/pick a project.
220
220
  2. **APIs & Services → OAuth consent screen**: user type **External**, fill
221
221
  app name / support email / developer email. Scopes: none needed beyond the
222
- defaults (GIS only uses `openid email profile`). Publish the app (while
223
- "Testing", only allowlisted test users can sign in).
222
+ defaults (GIS only uses `openid email profile`). External apps in Testing
223
+ normally require allowlisted users, but apps requesting only these basic
224
+ identity scopes are exempt. Workspace administrators can still block the app.
225
+ Choose publishing and verification status for the intended audience and actual
226
+ scopes, not as a blanket fix for a sign-in error. Checked 2026-10-13 against
227
+ [Google OAuth app states](https://developers.google.com/identity/protocols/oauth2/production-readiness/overview).
224
228
  3. **APIs & Services → Credentials → Create credentials → OAuth client ID**:
225
229
  - Application type: **Web application**.
226
230
  - **Authorized JavaScript origins**: every origin the button renders on —
@@ -373,7 +377,7 @@ shape that flow never sends.
373
377
  | `[GSI_LOGGER] The given origin is not allowed for the given client ID` | Current origin missing from **Authorized JavaScript origins** (check port and `www.` exactly). Changes take minutes to propagate |
374
378
  | `Invalid Google token: Token used too early/expired` | Server clock skew → sync NTP |
375
379
  | `Wrong recipient / audience mismatch` | Frontend and backend use different client IDs — single env source of truth |
376
- | 403 `access_denied` at consent | OAuth consent screen still in **Testing** and the user isn't a test user → publish the app |
380
+ | 403 `access_denied` at consent | Check requested scopes, user type and Workspace controls. For an External Testing app requesting more than basic identity scopes, confirm the user is allowlisted. Publishing does not override administrator policy |
377
381
  | Works locally, fails in prod | Prod origin not in Authorized origins; or cookie has `Secure` on plain HTTP (Prowl dev: `PROWL_INSECURE_COOKIES=1`) |
378
382
  | One Tap doesn't show / FedCM warnings | Third-party-cookie phase-out: GIS auto-migrates to FedCM; keep `gsi/client` unmodified and current — the rendered button flow is unaffected |
379
383
  | User "lost password" but signed up via Google | Expected: Google-only accounts have empty password hash. Recovery = sign in with Google using the same email |
@@ -85,12 +85,14 @@ customers. See [`references/price-integrity.md`](references/price-integrity.md).
85
85
  ## The client
86
86
 
87
87
  ```ts
88
+ // Copy the linked assert-stripe-env.mjs beside this client (see price-integrity.md).
89
+ import { assertStripeEnv } from "./assert-stripe-env.mjs";
88
90
  let client: Stripe | null = null;
89
91
 
90
92
  export function getStripe(): Stripe {
91
93
  if (!client) {
92
94
  const key = process.env.STRIPE_SECRET_KEY;
93
- if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
95
+ assertStripeEnv(process.env.STRIPE_PRODUCT_MODE, key);
94
96
  // Pin checked 2026-08-30 — 2026-08-26.dahlia was already newer. Confirm
95
97
  // against Stripe's changelog before pinning; this line goes stale monthly.
96
98
  client = new Stripe(key, { apiVersion: "2026-07-29.dahlia", maxNetworkRetries: 2 });
@@ -78,6 +78,31 @@ throw new Error(
78
78
  Left raw, that error reads as "somebody deleted the product" and sends people to
79
79
  the Dashboard for an hour.
80
80
 
81
+ ### Assert the credential mode before constructing the client
82
+
83
+ Copy [assert-stripe-env.mjs](../scripts/assert-stripe-env.mjs) beside your client,
84
+ and call `assertStripeEnv(process.env.STRIPE_PRODUCT_MODE, process.env.STRIPE_SECRET_KEY)`
85
+ before `new Stripe`. The function is shipped executable code, not a second prose
86
+ implementation. Supply the secret through your project's secret runner; the following
87
+ values describe the configuration contract, not credentials to paste:
88
+
89
+ ```dotenv
90
+ STRIPE_PRODUCT_MODE=test
91
+ STRIPE_SECRET_KEY="<injected-at-runtime>"
92
+ ```
93
+
94
+ `STRIPE_ENV_TEST_HOLDS_LIVE_CREDENTIAL` and
95
+ `STRIPE_ENV_LIVE_HOLDS_TEST_CREDENTIAL` refuse opposite mode mismatches. Missing or
96
+ unknown modes/keys fail closed. This bounded assertion recognises account secret and
97
+ restricted keys (`sk_`/`rk_` with test/live mode); organization and other unrecognised
98
+ keys need an explicit mode contract, not inferred safety. It never logs or returns the
99
+ key. Agreement proves neither authenticity, account ownership nor permissions.
100
+ Webhook signing secrets have a separate endpoint binding and are not classified here.
101
+
102
+ Source checked 2026-10-13: [Stripe API keys](https://docs.stripe.com/keys), key types
103
+ and sandbox versus live mode. The executable regression covers both supported key
104
+ classes, both mismatches, absent mode/key and unsupported shapes without provider calls.
105
+
81
106
  ## One source for every displayed number
82
107
 
83
108
  Every surface that shows money reads from **one** module: the pricing page, the
@@ -244,7 +244,7 @@ Before the first real charge, on staging, with a real key in test mode:
244
244
  - [ ] webhook endpoint registered for live mode, subscribed to exactly the events you handle
245
245
  - [ ] `STRIPE_WEBHOOK_SECRET` is the **live endpoint's** secret, not `stripe listen`'s
246
246
  - [ ] the live key is a restricted key with only the permissions the app uses
247
- - [ ] `STRIPE_PRODUCT_MODE` and the key are both live, and something asserts they agree
247
+ - [ ] `STRIPE_PRODUCT_MODE` and the key are both live; call `assertStripeEnv` from [price-integrity.md](price-integrity.md#assert-the-credential-mode-before-constructing-the-client) before constructing the client
248
248
  - [ ] the price conformance job is scheduled and its findings reach a human
249
249
  - [ ] the reconciliation job is scheduled
250
250
  - [ ] alerts exist for: signature failures, handler 5xx, endpoint disabled by Stripe
@@ -0,0 +1,17 @@
1
+ /** Copy beside the Stripe client and call before constructing it. No I/O or logging.
2
+ * Prefix agreement is not credential authenticity or permission validation.
3
+ */
4
+ export function assertStripeEnv(mode, key) {
5
+ if (!['test', 'production'].includes(mode)) throw new Error('STRIPE_ENV_INVALID');
6
+ if (typeof key !== 'string' || !key) throw new Error('STRIPE_KEY_MISSING');
7
+ const match = /^(?:sk|rk)_(test|live)_[A-Za-z0-9]+$/.exec(key);
8
+ // Organization/unknown keys do not encode a mode this assertion can prove.
9
+ if (!match) throw new Error('STRIPE_KEY_MODE_UNVERIFIED');
10
+ if (mode === 'test' && match[1] === 'live') {
11
+ throw new Error('STRIPE_ENV_TEST_HOLDS_LIVE_CREDENTIAL');
12
+ }
13
+ if (mode === 'production' && match[1] === 'test') {
14
+ throw new Error('STRIPE_ENV_LIVE_HOLDS_TEST_CREDENTIAL');
15
+ }
16
+ return mode;
17
+ }