@weft-js/pay 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ravonus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @weft-js/pay
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Early release (0.1.1).** APIs will change between minor versions.
4
+
5
+ Products, one ledger for every rail, and entitlements. weft's reference plugin: read it to build your own.
6
+
7
+ ```bash
8
+ npm install @weft-js/pay
9
+ ```
package/docs/pay.md ADDED
@@ -0,0 +1,118 @@
1
+ # Payments
2
+
3
+ This is a plugin, `@weft-js/pay`, and nothing in weft names it. `weft docs plugins` walks through how it is built.
4
+
5
+ `weft add pay` needs a database and auth. It adds the Stripe rail to `plugins` and writes `src/pay/catalog.ts`,
6
+ `src/pay.server.ts` and `src/server/pay.ts`, plus `src/db/pay.schema.ts` and placeholder keys. The keys go in `.env`
7
+ with real test values before you run it. Nothing is called until a user checks out.
8
+
9
+ App code names products, never a rail:
10
+
11
+ ```ts
12
+ // src/pay/catalog.ts: reaches the client, so names and amounts only
13
+ import { product } from '@weft-js/pay'
14
+
15
+ export const pro = product({ id: 'pro', kind: 'subscription', use: 'digital',
16
+ plans: { month: { usd: 999 }, year: { usd: 7999, trialDays: 7 } }, grants: ['pro'] })
17
+ ```
18
+
19
+ <!-- example: skip needs what `weft add pay` writes -->
20
+ ```tsx
21
+ // a page
22
+ import { price } from '@weft-js/pay'
23
+ import { pro } from '#/pay/catalog'
24
+ import { checkout, entitlements, portal } from '#/server/pay'
25
+
26
+ export default function Pricing() {
27
+ const mine = entitlements.live()
28
+ return (
29
+ <section>
30
+ <button type="button" onClick={() => checkout({ product: 'pro', plan: 'year' })}>Go Pro · {price(pro, 'year').display}</button>
31
+ <button type="button" onClick={() => portal()}>Manage</button>
32
+ {mine.value?.includes('pro') && <p>You have Pro.</p>}
33
+ </section>
34
+ )
35
+ }
36
+ ```
37
+
38
+ `kind` is `subscription` (with `plans`), `one-time` or `consumable` (with `price`). Amounts are whole minor units with a
39
+ currency (PAY008). `price(p, plan)` formats by the currency's exponent, for the reader's locale.
40
+
41
+ **A grant comes only from a verified event.** `checkout` goes to the rail's hosted page and grants nothing (PAY004). The
42
+ rail then posts `/_weft/x/pay/<rail>`, which is the URL to give it, and `/_weft/x/pay/stripe` for Stripe. Each delivery is
43
+ verified on the exact bytes, applied to `purchases`, and pokes `entitlements:<user>`, so a `.live()` reader updates.
44
+ Delivery is at least once and in any order: a repeat is a no-op, and an old event changes nothing, by the rail's own clock.
45
+ A bad signature answers 400; a failure answers 500, so the rail delivers it again.
46
+
47
+ **Enforce on the server.** What the client shows is decoration (PAY013):
48
+
49
+ <!-- example: skip fragments of a server module -->
50
+ ```ts
51
+ export const report = query({ auth: 'user', requires: ['pro'], run: ({ ctx }) => buildReport(ctx) }) // a typed 403: ENTITLEMENT_REQUIRED
52
+ if (!(await ctx.entitlements.has('pro'))) redirect('/pricing') // await it (PAY015)
53
+ ```
54
+
55
+ `ctx.entitlements` has `has(id)` and `list()`, and works in any `query`, `mutation` or `load`. A query with `cache: { life }`
56
+ needs `scope: 'user'` to read it. A query's `tags` can take `ctx`, which is how one user's tag is written.
57
+
58
+ States are the same on every rail: `active`, `trial` and `grace` give access; `pending`, `billing-retry`, `paused`,
59
+ `expired`, `refunded` and `revoked` don't. Access also ends `leeway` (3 days) after a paid period, if no renewal came.
60
+
61
+ `definePay({ catalog, store, urls, user?, rail?, origin?, leeway? })`. `urls` are where a buyer returns, never from the
62
+ client. `pay.checkout(ctx, input)`, `pay.portal(ctx)`, `pay.entitlements(userId)`, `pay.purchases(userId)` and
63
+ `pay.refund(purchaseId)` are server calls. `store` is `drizzlePayStore(db, { purchases, payEvents })` from
64
+ `@weft-js/pay/drizzle`, or five methods of your own (`PayStore`: `seen`, `mark`, `purchase`, `save`, `byUser`). `memoryPayStore()` is for tests: a production server
65
+ refuses it (PAY016).
66
+
67
+ **Another provider is one setting.** Replace `stripe()` in `plugins` with any plugin for the `pay.rail` slot; nothing else
68
+ changes. A rail implements `PayRail` from `@weft-js/pay`: `checkout`, `portal`, `verify`, optionally `refund`, declared for the
69
+ `server` side, and passes `payRailSuite` from `@weft-js/pay/test`. `verify` gets the exact body and throws
70
+ `PayError('PAY011', …)` for anything it can't prove. An app with no `src/pay.server.ts` has no route and no code from any
71
+ of this.
72
+
73
+ `stripe({ prices?, api?, apiVersion?, tolerance?, shippingCountries? })` from `@weft-js/pay/stripe/plugin`. It reads
74
+ `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` from the server env. Don't put a key in its options: they land in a
75
+ generated file (PAY003).
76
+ `prices` maps `'pro.month'` to a Stripe Price id; without it, amounts come from the catalog. Subscribe the webhook to
77
+ `customer.subscription.*`, `checkout.session.*`, `charge.refunded` and `charge.dispute.*`. No page loads Stripe.js.
78
+
79
+ ## Testing
80
+
81
+ `fakePay` from `@weft-js/pay/test` is a rail with no key and no network. Its events are signed and go through your own webhook and
82
+ ledger, so a test sees what a user would:
83
+
84
+ ```tsx
85
+ const fake = fakePay(pay) // your `definePay`, or fakePay({ catalog })
86
+ net.context({ user: { id: 'u1' } })
87
+ render(Paywall)
88
+ await user.click(screen.getByRole('button', { name: /Go Pro/ }))
89
+ await fake.complete() // the buyer finishes the hosted page
90
+ expect(screen.getByLabelText('plan').textContent).toBe('Pro')
91
+ await fake.simulate('renewal-failed') // renewal, recovered, grace-expired, cancel, pause, resume, refund, revoke
92
+ ```
93
+
94
+ `fake.buy(user, product, plan)` skips the page. `renewal` and `renewal-failed` move `clock` to the period's end when it's
95
+ installed. `fake.redeliver()`, `fake.deliver(event, { forged: true })`, `fake.deliveries`, `fake.sessions`,
96
+ `fake.entitlements(user)`.
97
+
98
+ ## Diagnostics
99
+
100
+ | Code | Catches |
101
+ |---|---|
102
+ | PAY003 | A key in the source, or a server secret in client code |
103
+ | PAY004 | Paid state written on the client after `checkout()` |
104
+ | PAY005 | A price typed into a page that sells something (warning) |
105
+ | PAY008 | A float or a currency-less amount in `product()` |
106
+ | PAY010 | A checkout mutation without `auth: 'user'` |
107
+ | PAY011 | A webhook left unverified, or no `STRIPE_WEBHOOK_SECRET` |
108
+ | PAY012 | A rail's price or product id in client code |
109
+ | PAY013 | An entitlement checked only on the client |
110
+ | PAY014 | A rail bound with no `src/pay.server.ts` |
111
+ | PAY015 | `ctx.entitlements.has()` tested without `await` |
112
+ | PAY016 | `memoryPayStore()` in `src/pay.server.ts` (warning) |
113
+ | PAY017 | `@weft-js/pay/server` or `/drizzle` imported in client code |
114
+ | PAY018 | `src/pay.server.ts` with no `pay.rail` provider |
115
+ | PAY031 | A live key in a test |
116
+
117
+ At run time: PAY021 (not in the catalog), PAY022 (no rail chosen), PAY023 (already subscribed, 409), PAY024 (nothing to
118
+ manage or refund), PAY030 (the rail refused). Native rails, store policy and `weft pay sync` aren't built.
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@weft-js/pay",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "@weft-js/pay: products, one ledger for every rail, and entitlements. weft's reference plugin: read it to build your own",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./server": "./src/server.ts",
10
+ "./drizzle": "./src/drizzle.ts",
11
+ "./test": "./src/test.ts",
12
+ "./plugin": "./plugin.mjs",
13
+ "./stripe": "./src/stripe.ts",
14
+ "./stripe/plugin": "./stripe-plugin.mjs"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "docs",
19
+ "plugin.mjs",
20
+ "plugin.d.mts",
21
+ "stripe-plugin.mjs",
22
+ "stripe-plugin.d.mts",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "dependencies": {
27
+ "typescript": "6.0.3"
28
+ },
29
+ "peerDependencies": {
30
+ "@weft-js/start": "^0.1.1",
31
+ "drizzle-orm": "^0.45.0",
32
+ "@weft-js/core": "^0.1.1"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "drizzle-orm": {
36
+ "optional": true
37
+ }
38
+ },
39
+ "engines": {
40
+ "node": ">=26"
41
+ },
42
+ "keywords": [
43
+ "weft"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
package/plugin.d.mts ADDED
@@ -0,0 +1,5 @@
1
+ import type { PluginInstance } from '@weft-js/core/plugin'
2
+
3
+ declare const plugin: (options?: Record<string, never>) => PluginInstance<unknown>
4
+ /** `pay()` for `weft.config.ts`: the `pay.rail` slot, the PAY checks and the ledger's route. Rails are plugins of their own. */
5
+ export default plugin
package/plugin.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // pay() for weft.config.ts: start here to see how a plugin is put together (`weft docs plugins` walks through it). Everything
2
+ // a plugin gives weft is in this one pure manifest: the server part as a slot provider, and its checks, docs, codes and
3
+ // `weft add` steps as data. Heavy code stays behind `import()` and the slot's lazy `load()`, so a config load costs little
4
+ // and a page costs nothing. A payment provider is a separate plugin: this one defines the ledger, and `pay.rail` is the seam.
5
+ import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
6
+ import { setup } from './src/setup.mjs'
7
+
8
+ const extension = defineSlot({ id: 'server.extension', version: 1, cardinality: 'keyed', sides: ['server'] })
9
+
10
+ const diagnostics = [
11
+ ['PAY003', 'error', 'A Stripe secret, restricted or webhook key in the source, a server secret read in client code, or a key in plugin options', 'Move it to the server env (`src/env.server.ts`), and roll any real key that was committed'],
12
+ ['PAY004', 'error', "Paid state written on the client after `checkout()`, which only opens the rail's page", 'Read `entitlements` instead: a grant arrives from a verified event'],
13
+ ['PAY005', 'warning', 'A price typed into a page that sells something', '`price(product, plan).display`'],
14
+ ['PAY008', 'error', 'Money as a float, or with no currency, in `product()`', 'Whole minor units with a currency: `{ usd: 999 }`'],
15
+ ['PAY010', 'error', "A mutation that opens a checkout without `auth: 'user'`", "Add `auth: 'user'`: a purchase has to belong to someone"],
16
+ ['PAY011', 'error', 'A webhook left unverified: a handler of payment events with no signature check, a `PayRail` whose `verify` ignores `headers`, or no `STRIPE_WEBHOOK_SECRET`', 'Delete the handler: `/_weft/x/pay/<rail>` verifies. Give Stripe that URL, and set `STRIPE_WEBHOOK_SECRET`'],
17
+ ['PAY012', 'error', "A rail's price or product id in client code", "Name the catalog: `checkout({ product: 'pro', plan: 'month' })`. Map ids on the server: `stripe({ prices: { 'pro.month': … } })`"],
18
+ ['PAY013', 'error', 'An entitlement checked only on the client: nothing on the server enforces it', "`requires: ['pro']` on the query or mutation, or `await ctx.entitlements.has('pro')` in a `load`"],
19
+ ['PAY014', 'error', 'The plugin with no `src/pay.server.ts`: no webhook is mounted, so payments grant nothing', 'Default-export `definePay({ … })` from `src/pay.server.ts`'],
20
+ ['PAY015', 'error', '`ctx.entitlements.has()` tested without `await`: a promise is truthy, so everyone passes', 'Await it (`weft fix` does, in an `async` function)'],
21
+ ['PAY016', 'warning', '`memoryPayStore()` in `src/pay.server.ts`: a production server refuses to start with it', '`drizzlePayStore(db, { purchases, payEvents })`, or your own `PayStore`'],
22
+ ['PAY017', 'error', '`@weft-js/pay/server` or `/drizzle` imported in client code', 'Import them in a server module; the client gets `@weft-js/pay`'],
23
+ ['PAY018', 'error', '`src/pay.server.ts` with no `pay.rail` provider', 'Add a rail to `plugins`: `weft add pay-stripe`'],
24
+ ['PAY031', 'error', 'A live key in a test', '`fakePay` from `@weft-js/pay/test`'],
25
+ ].map(([code, severity, catches, fix]) => ({ code, severity, catches, fix }))
26
+
27
+ export const pay = definePlugin({
28
+ name: '@weft-js/pay',
29
+ version: '0.1.0',
30
+ weft: '*',
31
+ namespace: 'PAY',
32
+ permissions: {},
33
+ contribute: () => ({
34
+ provides: [provide(extension, { key: 'pay', sides: { server: { module: '@weft-js/pay/server', export: 'extension' } } })],
35
+ diagnostics,
36
+ check: { include: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}', 'test/**/*.{ts,tsx,js,mjs}', 'weft.config.*'], run: ctx => import('./src/checks.mjs').then(c => c.run(ctx)) },
37
+ docs: { topic: 'pay', summary: 'Payments: products, checkout, entitlements, webhooks, fakePay, another provider; PAY-codes', file: new URL('./docs/pay.md', import.meta.url).pathname },
38
+ setup,
39
+ agents: { title: 'Payments', text: 'Products are in `src/pay/catalog.ts`, in minor units. `checkout` and `entitlements` are in `src/server/pay.ts`. App code never names a rail or a price id, and `checkout` grants nothing: a verified webhook does. Enforce on the server, with `requires: [...]` or `await ctx.entitlements.has(...)`: a check only on the client protects nothing. Test with `fakePay` from `@weft-js/pay/test`. `weft docs pay`.' },
40
+ catalog: [
41
+ { name: 'product', import: '@weft-js/pay', summary: 'One thing you sell, in integer minor units' },
42
+ { name: 'price', import: '@weft-js/pay', summary: "A product's price, formatted for the reader" },
43
+ { name: 'definePay', import: '@weft-js/pay/server', summary: 'The ledger: src/pay.server.ts default-exports it' },
44
+ { name: 'fakePay', import: '@weft-js/pay/test', summary: 'A rail for tests: renewals, refunds and disputes on the clock' },
45
+ ],
46
+ map: [{ want: 'Sell something', call: 'add it to `src/pay/catalog.ts`, then `checkout({ product, plan })`', have: '`weft docs pay`' }],
47
+ }),
48
+ })
49
+
50
+ export default pay
package/src/checks.mjs ADDED
@@ -0,0 +1,230 @@
1
+ // The PAY-codes (research 10 §7, 37): what `weft check` reports for this plugin, through the `check` hook any plugin has.
2
+ // `run` gets the project through `ctx` and returns findings, and weft does the rest: spans, fixes, `weft fix`, `--json`.
3
+ // TypeScript is loaded here, not in plugin.mjs, so a config load doesn't pay for it. A regex decides which files parse.
4
+ //
5
+ // The client is everything in `src` that isn't a server module. `src/pay/catalog.ts` is client code on purpose: it holds
6
+ // names and amounts, which is why a rail's ids don't go there (PAY012).
7
+
8
+ import ts from 'typescript'
9
+
10
+ const isServer = rel => rel.startsWith('src/server/') || /\.server\.[cm]?[jt]sx?$/.test(rel)
11
+
12
+ const KEY = /\b(?:(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{6,}|whsec_[A-Za-z0-9]{6,})\b/g
13
+ const PLACEHOLDER = /placeholder|example|your|xxxx/i
14
+ const SERVER_ENV = /\b(?:process\.env|import\.meta\.env)\.\w*STRIPE_(?:SECRET_KEY|WEBHOOK_SECRET|RESTRICTED_KEY)\b/g
15
+ const RAIL_ID = /^(?:price|prod|plink)_[A-Za-z0-9]{8,}$/
16
+ const STRIPE_EVENT = /['"`](?:checkout\.session\.completed|customer\.subscription\.\w+|invoice\.paid|invoice\.payment_\w+|payment_intent\.succeeded|charge\.refunded)['"`]/
17
+ const VERIFIED = /constructEvent|verifyWebhook|\.webhook\(|\.verify\(|stripe-signature/i
18
+ const SECRET_OPTION = /\bstripe\(\s*\{[^}]*\b(?:secretKey|webhookSecret)\b/
19
+ const PAID_NAME = /pro\b|paid|premium|entitle|purchas|subscri|unlock|grant|isPro|hasAccess/i
20
+ const MONEY = /[$€£¥]\s?\d/
21
+
22
+ const parse = (rel, text) => ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
23
+
24
+ function walk(node, visit) {
25
+ visit(node)
26
+ ts.forEachChild(node, c => walk(c, visit))
27
+ }
28
+
29
+ const at = (sf, rel, node) => ({ file: rel, start: node.getStart(sf), end: node.getEnd() })
30
+ const spanOf = (rel, index, length) => ({ file: rel, start: index, end: index + length })
31
+ const isTest = rel => /\.(test|spec)\.[cm]?[jt]sx?$/.test(rel) || rel.startsWith('test/')
32
+ const prop = (object, name) => object.properties.find(p => p.name?.getText() === name)
33
+ const callee = node => (ts.isCallExpression(node) ? node.expression.getText() : '')
34
+ const isFunction = n => ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n)
35
+
36
+ function enclosing(node) {
37
+ for (let n = node.parent; n; n = n.parent) if (isFunction(n)) return n
38
+ return undefined
39
+ }
40
+
41
+ /** PAY003: a key in the source, or a server secret read where the client can reach it. */
42
+ function secrets(rel, text, server, out) {
43
+ for (const m of text.matchAll(KEY)) {
44
+ if (PLACEHOLDER.test(m[0])) continue
45
+ out.push(({ code: 'PAY003', ...spanOf(rel, m.index, m[0].length), why: `A key is in the source: \`${m[0].slice(0, 8)}…\`. Anyone who can read this can charge or refund as you, and it stays in git after you delete it.` }))
46
+ }
47
+ if (!server) for (const m of text.matchAll(SERVER_ENV)) out.push(({ code: 'PAY003', ...spanOf(rel, m.index, m[0].length), why: `\`${m[0]}\` is read in client code: a build inlines it, or it is undefined there.` }))
48
+ }
49
+
50
+ /** PAY012: a rail's own id where the client bundle gets it. */
51
+ function railIds(rel, sf, out) {
52
+ walk(sf, n => {
53
+ if ((ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) && RAIL_ID.test(n.text)) out.push(({ code: 'PAY012', ...at(sf, rel, n), why: `\`${n.text}\` is a rail's id in client code: the page now names a rail, and a client that sends a price picks its own.` }))
54
+ })
55
+ }
56
+
57
+ /** PAY008: a float or a bare number in `product({ … })`, with the integer as an edit for two-decimal currencies. */
58
+ function money(rel, sf, out) {
59
+ const amounts = (object, path) => {
60
+ for (const p of object.properties) {
61
+ if (!ts.isPropertyAssignment(p) || !/^[a-z]{3}$/.test(p.name.getText())) continue
62
+ const v = p.initializer
63
+ if (!ts.isNumericLiteral(v) || Number.isInteger(Number(v.text))) continue
64
+ const fixed = String(Math.round(Number(v.text) * 100))
65
+ const place = at(sf, rel, v)
66
+ out.push(({ code: 'PAY008', ...place, why: `\`${path}.${p.name.getText()}\` is ${v.text}: money is whole minor units, and a float rounds differently on each rail.`, fix: { description: `Write ${fixed}, assuming a currency with two decimals`, safe: false, edits: [{ start: place.start, end: place.end, text: fixed }] } }))
67
+ }
68
+ }
69
+ walk(sf, n => {
70
+ if (callee(n) !== 'product' || !n.arguments[0] || !ts.isObjectLiteralExpression(n.arguments[0])) return
71
+ const def = n.arguments[0]
72
+ const id = prop(def, 'id')?.initializer?.text ?? 'product'
73
+ const price = prop(def, 'price')?.initializer
74
+ if (price && ts.isNumericLiteral(price)) out.push(({ code: 'PAY008', ...at(sf, rel, price), why: `\`${id}\`'s price has no currency: ${price.text} of what?` }))
75
+ if (price && ts.isObjectLiteralExpression(price)) amounts(price, `${id}.price`)
76
+ const plans = prop(def, 'plans')?.initializer
77
+ if (plans && ts.isObjectLiteralExpression(plans)) for (const p of plans.properties) if (ts.isPropertyAssignment(p) && ts.isObjectLiteralExpression(p.initializer)) amounts(p.initializer, `${id}.${p.name.getText()}`)
78
+ })
79
+ }
80
+
81
+ /** PAY004: paid state written on the client on the strength of `checkout()` returning. */
82
+ function clientGrants(rel, sf, out) {
83
+ const writes = (scope, from) => {
84
+ walk(scope, n => {
85
+ if (n.getStart(sf) < from) return
86
+ const target = ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken ? n.left.getText() : ts.isCallExpression(n) && /(^|\.)(setItem|set)$/.test(callee(n)) ? `${callee(n)} ${n.arguments[0]?.getText() ?? ''}` : undefined
87
+ if (target && PAID_NAME.test(target)) out.push(({ code: 'PAY004', ...at(sf, rel, n), why: `\`${n.getText().slice(0, 60)}\` follows \`checkout()\`, which only opens the rail's page. Nobody has paid yet, and a refund would never undo this.` }))
88
+ })
89
+ }
90
+ walk(sf, n => {
91
+ if (callee(n) !== 'checkout') return
92
+ const up = n.parent
93
+ if (ts.isAwaitExpression(up)) {
94
+ const fn = enclosing(n)
95
+ if (fn?.body) writes(fn.body, n.getEnd())
96
+ }
97
+ if (ts.isPropertyAccessExpression(up) && up.name.text === 'then' && ts.isCallExpression(up.parent) && up.parent.arguments[0]) writes(up.parent.arguments[0], 0)
98
+ })
99
+ }
100
+
101
+ /** PAY005: a price typed into the page beside something sold. */
102
+ function hardcoded(rel, sf, out) {
103
+ walk(sf, n => {
104
+ if (ts.isJsxText(n) && MONEY.test(n.text)) out.push(({ code: 'PAY005', ...at(sf, rel, n), why: `\`${n.text.trim().slice(0, 40)}\` is a price typed by hand: it drifts from what \`checkout\` charges.` }))
105
+ })
106
+ }
107
+
108
+ /** PAY015: `ctx.entitlements.has()` used as if it were a boolean. A promise is truthy, so this lets everyone in. */
109
+ function unawaited(rel, sf, out) {
110
+ walk(sf, n => {
111
+ if (!/(^|\.)entitlements\.has$/.test(callee(n))) return
112
+ const up = n.parent
113
+ const tested = (ts.isIfStatement(up) && up.expression === n) || (ts.isConditionalExpression(up) && up.condition === n) || (ts.isPrefixUnaryExpression(up) && up.operator === ts.SyntaxKind.ExclamationToken) || (ts.isBinaryExpression(up) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(up.operatorToken.kind))
114
+ if (!tested) return
115
+ const place = at(sf, rel, n)
116
+ const fn = enclosing(n)
117
+ const async = fn?.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
118
+ const edit = async && { fix: { description: 'Await it', safe: true, edits: [{ start: place.start, end: place.start, text: 'await ' }] } }
119
+ out.push(({ code: 'PAY015', ...place, why: `\`${n.getText()}\` isn't awaited: it is a promise, which is always truthy, so the check passes for everyone.`, ...edit }))
120
+ })
121
+ }
122
+
123
+ /** PAY010: a buyer no one has signed in. */
124
+ function anonymous(rel, sf, out) {
125
+ walk(sf, n => {
126
+ if (callee(n) !== 'mutation' || !n.arguments[0] || !ts.isObjectLiteralExpression(n.arguments[0])) return
127
+ const def = n.arguments[0]
128
+ if (/\.checkout\(\s*ctx\b/.test(def.getText()) && !prop(def, 'auth')) out.push(({ code: 'PAY010', ...at(sf, rel, n), why: 'This mutation opens a checkout with no `auth: \'user\'`: a purchase with no owner can\'t be restored or granted.' }))
129
+ })
130
+ }
131
+
132
+ /** PAY011: a webhook that believes what it's sent. */
133
+ function webhooks(rel, text, sf, out) {
134
+ if (STRIPE_EVENT.test(text) && /\.json\(\)|JSON\.parse/.test(text) && !VERIFIED.test(text)) {
135
+ const m = STRIPE_EVENT.exec(text)
136
+ out.push(({ code: 'PAY011', ...spanOf(rel, m.index, m[0].length), why: `This handles ${m[0]} without checking a signature: anyone who finds the URL can POST themselves a purchase.` }))
137
+ }
138
+ if (!/\bPayRail\b/.test(text)) return
139
+ walk(sf, n => {
140
+ if (!((ts.isMethodDeclaration(n) || ts.isPropertyAssignment(n)) && n.name.getText() === 'verify')) return
141
+ const fn = ts.isPropertyAssignment(n) ? n.initializer : n
142
+ if (!/\bheaders\b/.test(fn.getText())) out.push(({ code: 'PAY011', ...at(sf, rel, n.name), why: "This rail's `verify` never reads `headers`, so it can't be checking a signature." }))
143
+ })
144
+ }
145
+
146
+ /** Every id the server really enforces: `requires` and awaited `entitlements.has`, in server modules and route `load`s. */
147
+ function enforced(rel, sf, ids) {
148
+ walk(sf, n => {
149
+ if (/(^|\.)entitlements\.has$/.test(callee(n)) && n.arguments[0]?.text) ids.add(n.arguments[0].text)
150
+ if (ts.isPropertyAssignment(n) && n.name.getText() === 'requires' && ts.isArrayLiteralExpression(n.initializer)) for (const e of n.initializer.elements) if (e.text) ids.add(e.text)
151
+ })
152
+ }
153
+
154
+ /** Client reads of `entitlements`: `mine.value?.includes('pro')`, wherever the file got that list from. */
155
+ function clientReads(rel, sf, reads) {
156
+ if (!/\bentitlements\b/.test(sf.text)) return
157
+ walk(sf, n => {
158
+ if (!ts.isCallExpression(n) || !/\.(includes|has)$/.test(n.expression.getText()) || !n.arguments[0]?.text) return
159
+ reads.push({ rel, sf, node: n, id: n.arguments[0].text })
160
+ })
161
+ }
162
+
163
+ /** A server entry in the client graph: it would carry the ledger, and it can't run there. */
164
+ function serverImports(rel, text, out) {
165
+ for (const m of text.matchAll(/from\s+['"](@weft-js\/pay\/(?:server|drizzle))['"]/g)) out.push({ code: 'PAY017', ...spanOf(rel, m.index, m[0].length), why: `\`${m[1]}\` is imported in client code: the ledger runs on the server only.` })
166
+ }
167
+
168
+ /** @param {import('@weft-js/core/plugin').CheckContext} ctx */
169
+ export function run(ctx) {
170
+ const out = []
171
+ const rels = ctx.files.filter(r => r.startsWith('src/') && !isTest(r) && !r.endsWith('.d.ts'))
172
+ const rails = Object.entries(ctx.slots['pay.rail'] ?? {}).filter(([, name]) => name)
173
+ const payFile = ['ts', 'mts', 'js', 'mjs'].map(x => `src/pay.server.${x}`).find(rel => ctx.exists(rel))
174
+ const known = new Set()
175
+ const reads = []
176
+
177
+ for (const rel of rels) {
178
+ const text = ctx.read(rel)
179
+ const server = isServer(rel)
180
+ secrets(rel, text, server, out)
181
+ if (!server) serverImports(rel, text, out)
182
+ if (!(/@weft-js\/pay|entitlements|requires:|\bcheckout\b|\bproduct\(|PayRail|price_|prod_|plink_/.test(text) || STRIPE_EVENT.test(text))) continue
183
+ const sf = parse(rel, text)
184
+ webhooks(rel, text, sf, out)
185
+ money(rel, sf, out)
186
+ unawaited(rel, sf, out)
187
+ enforced(rel, sf, known)
188
+ if (server) anonymous(rel, sf, out)
189
+ else {
190
+ railIds(rel, sf, out)
191
+ if (/\bcheckout\b/.test(text)) {
192
+ clientGrants(rel, sf, out)
193
+ hardcoded(rel, sf, out)
194
+ }
195
+ clientReads(rel, sf, reads)
196
+ }
197
+ }
198
+
199
+ for (const r of reads) {
200
+ if (known.has(r.id)) continue
201
+ out.push({ code: 'PAY013', ...at(r.sf, r.rel, r.node), why: `\`${r.id}\` is checked only on the client: no \`requires\` or \`ctx.entitlements.has\` on the server names it, so a request made by hand gets the same thing as a paying user.`, help: `Add \`requires: ['${r.id}']\` to the query or mutation it protects, or check \`await ctx.entitlements.has('${r.id}')\` in the route's \`load\`` })
202
+ }
203
+
204
+ for (const rel of ctx.files.filter(r => r.startsWith('weft.config.'))) {
205
+ const text = ctx.read(rel)
206
+ secrets(rel, text, true, out)
207
+ const m = SECRET_OPTION.exec(text)
208
+ if (m) out.push({ code: 'PAY003', ...spanOf(rel, m.index, m[0].length), why: 'Plugin options are written into `.weft/slots/`, so a key given here sits in a generated file.' })
209
+ }
210
+
211
+ for (const rel of ctx.files.filter(isTest)) {
212
+ const text = ctx.read(rel)
213
+ for (const m of text.matchAll(/\b(?:sk|rk)_live_\w+/g)) out.push({ code: 'PAY031', ...spanOf(rel, m.index, m[0].length), why: 'A live key in a test: it charges real cards, and it is now in git.' })
214
+ }
215
+
216
+ const config = ctx.files.find(r => r.startsWith('weft.config.')) ?? 'weft.config.ts'
217
+ if (!payFile) {
218
+ out.push({ code: 'PAY014', file: config, why: 'This plugin is in `plugins`, but there is no `src/pay.server.ts`: no webhook is mounted, so a payment would grant nothing.' })
219
+ return out
220
+ }
221
+ if (!rails.length) out.push({ code: 'PAY018', file: payFile, why: '`src/pay.server.ts` needs a `pay.rail` provider, and nothing provides it: every checkout fails.' })
222
+ const text = ctx.read(payFile)
223
+ const m = /memoryPayStore\(/.exec(text)
224
+ if (m) out.push({ code: 'PAY016', ...spanOf(payFile, m.index, m[0].length - 1), why: 'The ledger is in memory: purchases are gone on restart, and a production server refuses to start with it.' })
225
+ if (rails.some(([key]) => key === 'stripe')) {
226
+ const env = ['.env.example', 'src/env.server.ts'].map(rel => ctx.read(rel)).join('\n')
227
+ if (!env.includes('STRIPE_WEBHOOK_SECRET')) out.push({ code: 'PAY011', file: payFile, why: 'Nothing declares `STRIPE_WEBHOOK_SECRET`. Without it the rail refuses every webhook, so no purchase ever lands.', help: 'Add `STRIPE_WEBHOOK_SECRET` to `.env.example` and `src/env.server.ts`' })
228
+ }
229
+ return out
230
+ }
package/src/drizzle.ts ADDED
@@ -0,0 +1,38 @@
1
+ // The ledger on a Drizzle client (research 10 §3): `purchases` and `pay_events`, in the app's schema so they're
2
+ // inspectable and joinable like any other table. An entry of its own, so an app on another data layer needs no drizzle-orm.
3
+
4
+ import { eq, getTableName, type AnyColumn, type SQL, type Table } from 'drizzle-orm'
5
+ import type { DbHandle } from '@weft-js/start/db/core'
6
+ import type { PayStore, Purchase } from './server'
7
+
8
+ /** A Drizzle client as far as the ledger uses one: the dialects' builders share no type, so this names what they have in common. */
9
+ interface LedgerClient {
10
+ select(fields?: Record<string, AnyColumn>): { from(table: Table): { where(on: SQL | undefined): Promise<Purchase[]> & { limit(n: number): Promise<Purchase[]> } } }
11
+ insert(table: Table): { values(row: object): Promise<unknown> & {
12
+ onDuplicateKeyUpdate(update: { set: object }): Promise<unknown>
13
+ onConflictDoNothing(): Promise<unknown>
14
+ onConflictDoUpdate(update: { target: AnyColumn; set: object }): Promise<unknown>
15
+ } }
16
+ }
17
+
18
+ /** A `PayStore` on the app's Drizzle tables, on Postgres, SQLite or MySQL. */
19
+ export function drizzlePayStore(db: { $weft: DbHandle }, tables: { purchases: Table & { id: AnyColumn; user: AnyColumn }; payEvents: Table & { id: AnyColumn } }): PayStore {
20
+ const { purchases, payEvents } = tables
21
+ const client = db as unknown as LedgerClient
22
+ const mysql = db.$weft.dialect === 'mysql'
23
+ db.$weft.exempt.add(getTableName(purchases)).add(getTableName(payEvents)) // written by a webhook, not by a server function
24
+ return {
25
+ seen: async id => (await client.select({ id: payEvents.id }).from(payEvents).where(eq(payEvents.id, id)).limit(1)).length > 0,
26
+ async mark(id, at) {
27
+ const insert = client.insert(payEvents).values({ id, at })
28
+ await (mysql ? insert.onDuplicateKeyUpdate({ set: { id } }) : insert.onConflictDoNothing())
29
+ },
30
+ purchase: async id => (await client.select().from(purchases).where(eq(purchases.id, id)).limit(1))[0] ?? null,
31
+ async save(row: Purchase) {
32
+ const { id: _id, ...set } = row
33
+ const insert = client.insert(purchases).values(row)
34
+ await (mysql ? insert.onDuplicateKeyUpdate({ set }) : insert.onConflictDoUpdate({ target: purchases.id, set }))
35
+ },
36
+ byUser: user => client.select().from(purchases).where(eq(purchases.user, user)),
37
+ }
38
+ }