@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 +16 -0
- package/README.md +8 -2
- package/docs/pay.md +118 -0
- package/package.json +46 -4
- package/plugin.d.mts +5 -0
- package/plugin.mjs +50 -0
- package/src/checks.mjs +230 -0
- package/src/drizzle.ts +38 -0
- package/src/index.ts +181 -0
- package/src/server.ts +328 -0
- package/src/setup.mjs +99 -0
- package/src/stripe.ts +198 -0
- package/src/test.ts +169 -0
- package/stripe-plugin.d.mts +5 -0
- package/stripe-plugin.mjs +19 -0
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
|
-
#
|
|
1
|
+
# @weft-js/pay
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|