@weft-js/pay 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +658 -3
- package/docs/pay.md +28 -9
- package/package.json +13 -4
- package/plugin.mjs +131 -23
- package/src/checks/config.mjs +24 -0
- package/src/checks.mjs +219 -70
- package/src/drizzle.ts +33 -14
- package/src/index.ts +80 -25
- package/src/server/catalog.ts +80 -0
- package/src/server/ledger.ts +69 -0
- package/src/server/operations.ts +142 -0
- package/src/server/rails.ts +45 -0
- package/src/server.ts +74 -171
- package/src/setup.mjs +26 -9
- package/src/stripe.ts +168 -55
- package/src/test.ts +195 -63
- package/stripe-plugin.d.mts +6 -1
- package/stripe-plugin.mjs +24 -5
package/src/checks.mjs
CHANGED
|
@@ -1,37 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
// `run` gets the project through `ctx` and returns findings, and weft does the rest: spans, fixes, `weft fix`, `--json`.
|
|
1
|
+
import { configChecks } from './checks/config.mjs'
|
|
3
2
|
// 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
3
|
// The client is everything in `src` that isn't a server module. `src/pay/catalog.ts` is client code on purpose: it holds
|
|
6
4
|
// names and amounts, which is why a rail's ids don't go there (PAY012).
|
|
7
5
|
|
|
8
6
|
import ts from 'typescript'
|
|
9
7
|
|
|
10
|
-
const isServer = rel => rel.startsWith('src/server/') || /\.server\.[cm]?[jt]sx?$/.test(rel)
|
|
8
|
+
const isServer = (rel) => rel.startsWith('src/server/') || /\.server\.[cm]?[jt]sx?$/.test(rel)
|
|
11
9
|
|
|
12
10
|
const KEY = /\b(?:(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{6,}|whsec_[A-Za-z0-9]{6,})\b/g
|
|
13
11
|
const PLACEHOLDER = /placeholder|example|your|xxxx/i
|
|
14
|
-
const SERVER_ENV =
|
|
12
|
+
const SERVER_ENV =
|
|
13
|
+
/\b(?:process\.env|import\.meta\.env)\.\w*STRIPE_(?:SECRET_KEY|WEBHOOK_SECRET|RESTRICTED_KEY)\b/g
|
|
15
14
|
const RAIL_ID = /^(?:price|prod|plink)_[A-Za-z0-9]{8,}$/
|
|
16
|
-
const STRIPE_EVENT =
|
|
15
|
+
const STRIPE_EVENT =
|
|
16
|
+
/['"`](?:checkout\.session\.completed|customer\.subscription\.\w+|invoice\.paid|invoice\.payment_\w+|payment_intent\.succeeded|charge\.refunded)['"`]/
|
|
17
17
|
const VERIFIED = /constructEvent|verifyWebhook|\.webhook\(|\.verify\(|stripe-signature/i
|
|
18
18
|
const SECRET_OPTION = /\bstripe\(\s*\{[^}]*\b(?:secretKey|webhookSecret)\b/
|
|
19
19
|
const PAID_NAME = /pro\b|paid|premium|entitle|purchas|subscri|unlock|grant|isPro|hasAccess/i
|
|
20
20
|
const MONEY = /[$€£¥]\s?\d/
|
|
21
21
|
|
|
22
|
-
const parse = (rel, text) =>
|
|
22
|
+
const parse = (rel, text) =>
|
|
23
|
+
ts.createSourceFile(
|
|
24
|
+
rel,
|
|
25
|
+
text,
|
|
26
|
+
ts.ScriptTarget.Latest,
|
|
27
|
+
true,
|
|
28
|
+
/\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
29
|
+
)
|
|
23
30
|
|
|
24
31
|
function walk(node, visit) {
|
|
25
32
|
visit(node)
|
|
26
|
-
ts.forEachChild(node, c => walk(c, visit))
|
|
33
|
+
ts.forEachChild(node, (c) => walk(c, visit))
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
const at = (sf, rel, node) => ({ file: rel, start: node.getStart(sf), end: node.getEnd() })
|
|
30
37
|
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 =
|
|
38
|
+
const isTest = (rel) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(rel) || rel.startsWith('test/')
|
|
39
|
+
const prop = (object, name) => object.properties.find((p) => p.name?.getText() === name)
|
|
40
|
+
const callee = (node) => (ts.isCallExpression(node) ? node.expression.getText() : '')
|
|
41
|
+
const isFunction = (n) =>
|
|
42
|
+
ts.isArrowFunction(n) ||
|
|
43
|
+
ts.isFunctionExpression(n) ||
|
|
44
|
+
ts.isFunctionDeclaration(n) ||
|
|
45
|
+
ts.isMethodDeclaration(n)
|
|
35
46
|
|
|
36
47
|
function enclosing(node) {
|
|
37
48
|
for (let n = node.parent; n; n = n.parent) if (isFunction(n)) return n
|
|
@@ -42,15 +53,30 @@ function enclosing(node) {
|
|
|
42
53
|
function secrets(rel, text, server, out) {
|
|
43
54
|
for (const m of text.matchAll(KEY)) {
|
|
44
55
|
if (PLACEHOLDER.test(m[0])) continue
|
|
45
|
-
out.push(
|
|
56
|
+
out.push({
|
|
57
|
+
code: 'PAY003',
|
|
58
|
+
...spanOf(rel, m.index, m[0].length),
|
|
59
|
+
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.`,
|
|
60
|
+
})
|
|
46
61
|
}
|
|
47
|
-
if (!server)
|
|
62
|
+
if (!server)
|
|
63
|
+
for (const m of text.matchAll(SERVER_ENV))
|
|
64
|
+
out.push({
|
|
65
|
+
code: 'PAY003',
|
|
66
|
+
...spanOf(rel, m.index, m[0].length),
|
|
67
|
+
why: `\`${m[0]}\` is read in client code: a build inlines it, or it is undefined there.`,
|
|
68
|
+
})
|
|
48
69
|
}
|
|
49
70
|
|
|
50
71
|
/** PAY012: a rail's own id where the client bundle gets it. */
|
|
51
72
|
function railIds(rel, sf, out) {
|
|
52
|
-
walk(sf, n => {
|
|
53
|
-
if ((ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) && RAIL_ID.test(n.text))
|
|
73
|
+
walk(sf, (n) => {
|
|
74
|
+
if ((ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) && RAIL_ID.test(n.text))
|
|
75
|
+
out.push({
|
|
76
|
+
code: 'PAY012',
|
|
77
|
+
...at(sf, rel, n),
|
|
78
|
+
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.`,
|
|
79
|
+
})
|
|
54
80
|
})
|
|
55
81
|
}
|
|
56
82
|
|
|
@@ -63,69 +89,136 @@ function money(rel, sf, out) {
|
|
|
63
89
|
if (!ts.isNumericLiteral(v) || Number.isInteger(Number(v.text))) continue
|
|
64
90
|
const fixed = String(Math.round(Number(v.text) * 100))
|
|
65
91
|
const place = at(sf, rel, v)
|
|
66
|
-
out.push(
|
|
92
|
+
out.push({
|
|
93
|
+
code: 'PAY008',
|
|
94
|
+
...place,
|
|
95
|
+
why: `\`${path}.${p.name.getText()}\` is ${v.text}: money is whole minor units, and a float rounds differently on each rail.`,
|
|
96
|
+
fix: {
|
|
97
|
+
description: `Write ${fixed}, assuming a currency with two decimals`,
|
|
98
|
+
safe: false,
|
|
99
|
+
edits: [{ start: place.start, end: place.end, text: fixed }],
|
|
100
|
+
},
|
|
101
|
+
})
|
|
67
102
|
}
|
|
68
103
|
}
|
|
69
|
-
walk(sf, n => {
|
|
70
|
-
if (callee(n) !== 'product' || !n.arguments[0] || !ts.isObjectLiteralExpression(n.arguments[0]))
|
|
104
|
+
walk(sf, (n) => {
|
|
105
|
+
if (callee(n) !== 'product' || !n.arguments[0] || !ts.isObjectLiteralExpression(n.arguments[0]))
|
|
106
|
+
return
|
|
71
107
|
const def = n.arguments[0]
|
|
72
108
|
const id = prop(def, 'id')?.initializer?.text ?? 'product'
|
|
73
109
|
const price = prop(def, 'price')?.initializer
|
|
74
|
-
if (price && ts.isNumericLiteral(price))
|
|
110
|
+
if (price && ts.isNumericLiteral(price))
|
|
111
|
+
out.push({
|
|
112
|
+
code: 'PAY008',
|
|
113
|
+
...at(sf, rel, price),
|
|
114
|
+
why: `\`${id}\`'s price has no currency: ${price.text} of what?`,
|
|
115
|
+
})
|
|
75
116
|
if (price && ts.isObjectLiteralExpression(price)) amounts(price, `${id}.price`)
|
|
76
117
|
const plans = prop(def, 'plans')?.initializer
|
|
77
|
-
if (plans && ts.isObjectLiteralExpression(plans))
|
|
118
|
+
if (plans && ts.isObjectLiteralExpression(plans))
|
|
119
|
+
for (const p of plans.properties)
|
|
120
|
+
if (ts.isPropertyAssignment(p) && ts.isObjectLiteralExpression(p.initializer))
|
|
121
|
+
amounts(p.initializer, `${id}.${p.name.getText()}`)
|
|
78
122
|
})
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
/** PAY004: paid state written on the client on the strength of `checkout()` returning. */
|
|
82
126
|
function clientGrants(rel, sf, out) {
|
|
83
127
|
const writes = (scope, from) => {
|
|
84
|
-
walk(scope, n => {
|
|
128
|
+
walk(scope, (n) => {
|
|
85
129
|
if (n.getStart(sf) < from) return
|
|
86
|
-
const target =
|
|
87
|
-
|
|
130
|
+
const target =
|
|
131
|
+
ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
132
|
+
? n.left.getText()
|
|
133
|
+
: ts.isCallExpression(n) && /(^|\.)(setItem|set)$/.test(callee(n))
|
|
134
|
+
? `${callee(n)} ${n.arguments[0]?.getText() ?? ''}`
|
|
135
|
+
: undefined
|
|
136
|
+
if (target && PAID_NAME.test(target))
|
|
137
|
+
out.push({
|
|
138
|
+
code: 'PAY004',
|
|
139
|
+
...at(sf, rel, n),
|
|
140
|
+
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.`,
|
|
141
|
+
})
|
|
88
142
|
})
|
|
89
143
|
}
|
|
90
|
-
walk(sf, n => {
|
|
144
|
+
walk(sf, (n) => {
|
|
91
145
|
if (callee(n) !== 'checkout') return
|
|
92
146
|
const up = n.parent
|
|
93
147
|
if (ts.isAwaitExpression(up)) {
|
|
94
148
|
const fn = enclosing(n)
|
|
95
149
|
if (fn?.body) writes(fn.body, n.getEnd())
|
|
96
150
|
}
|
|
97
|
-
if (
|
|
151
|
+
if (
|
|
152
|
+
ts.isPropertyAccessExpression(up) &&
|
|
153
|
+
up.name.text === 'then' &&
|
|
154
|
+
ts.isCallExpression(up.parent) &&
|
|
155
|
+
up.parent.arguments[0]
|
|
156
|
+
)
|
|
157
|
+
writes(up.parent.arguments[0], 0)
|
|
98
158
|
})
|
|
99
159
|
}
|
|
100
160
|
|
|
101
161
|
/** PAY005: a price typed into the page beside something sold. */
|
|
102
162
|
function hardcoded(rel, sf, out) {
|
|
103
|
-
walk(sf, n => {
|
|
104
|
-
if (ts.isJsxText(n) && MONEY.test(n.text))
|
|
163
|
+
walk(sf, (n) => {
|
|
164
|
+
if (ts.isJsxText(n) && MONEY.test(n.text))
|
|
165
|
+
out.push({
|
|
166
|
+
code: 'PAY005',
|
|
167
|
+
...at(sf, rel, n),
|
|
168
|
+
why: `\`${n.text.trim().slice(0, 40)}\` is a price typed by hand: it drifts from what \`checkout\` charges.`,
|
|
169
|
+
})
|
|
105
170
|
})
|
|
106
171
|
}
|
|
107
172
|
|
|
108
173
|
/** PAY015: `ctx.entitlements.has()` used as if it were a boolean. A promise is truthy, so this lets everyone in. */
|
|
109
174
|
function unawaited(rel, sf, out) {
|
|
110
|
-
walk(sf, n => {
|
|
175
|
+
walk(sf, (n) => {
|
|
111
176
|
if (!/(^|\.)entitlements\.has$/.test(callee(n))) return
|
|
112
177
|
const up = n.parent
|
|
113
|
-
const tested =
|
|
178
|
+
const tested =
|
|
179
|
+
(ts.isIfStatement(up) && up.expression === n) ||
|
|
180
|
+
(ts.isConditionalExpression(up) && up.condition === n) ||
|
|
181
|
+
(ts.isPrefixUnaryExpression(up) && up.operator === ts.SyntaxKind.ExclamationToken) ||
|
|
182
|
+
(ts.isBinaryExpression(up) &&
|
|
183
|
+
[ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(
|
|
184
|
+
up.operatorToken.kind,
|
|
185
|
+
))
|
|
114
186
|
if (!tested) return
|
|
115
187
|
const place = at(sf, rel, n)
|
|
116
188
|
const fn = enclosing(n)
|
|
117
|
-
const async = fn?.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
118
|
-
const edit = async && {
|
|
119
|
-
|
|
189
|
+
const async = fn?.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
190
|
+
const edit = async && {
|
|
191
|
+
fix: {
|
|
192
|
+
description: 'Await it',
|
|
193
|
+
safe: true,
|
|
194
|
+
edits: [{ start: place.start, end: place.start, text: 'await ' }],
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
out.push({
|
|
198
|
+
code: 'PAY015',
|
|
199
|
+
...place,
|
|
200
|
+
why: `\`${n.getText()}\` isn't awaited: it is a promise, which is always truthy, so the check passes for everyone.`,
|
|
201
|
+
...edit,
|
|
202
|
+
})
|
|
120
203
|
})
|
|
121
204
|
}
|
|
122
205
|
|
|
123
206
|
/** PAY010: a buyer no one has signed in. */
|
|
124
207
|
function anonymous(rel, sf, out) {
|
|
125
|
-
walk(sf, n => {
|
|
126
|
-
if (
|
|
208
|
+
walk(sf, (n) => {
|
|
209
|
+
if (
|
|
210
|
+
callee(n) !== 'mutation' ||
|
|
211
|
+
!n.arguments[0] ||
|
|
212
|
+
!ts.isObjectLiteralExpression(n.arguments[0])
|
|
213
|
+
)
|
|
214
|
+
return
|
|
127
215
|
const def = n.arguments[0]
|
|
128
|
-
if (/\.checkout\(\s*ctx\b/.test(def.getText()) && !prop(def, 'auth'))
|
|
216
|
+
if (/\.checkout\(\s*ctx\b/.test(def.getText()) && !prop(def, 'auth'))
|
|
217
|
+
out.push({
|
|
218
|
+
code: 'PAY010',
|
|
219
|
+
...at(sf, rel, n),
|
|
220
|
+
why: "This mutation opens a checkout with no `auth: 'user'`: a purchase with no owner can't be restored or granted.",
|
|
221
|
+
})
|
|
129
222
|
})
|
|
130
223
|
}
|
|
131
224
|
|
|
@@ -133,44 +226,86 @@ function anonymous(rel, sf, out) {
|
|
|
133
226
|
function webhooks(rel, text, sf, out) {
|
|
134
227
|
if (STRIPE_EVENT.test(text) && /\.json\(\)|JSON\.parse/.test(text) && !VERIFIED.test(text)) {
|
|
135
228
|
const m = STRIPE_EVENT.exec(text)
|
|
136
|
-
out.push(
|
|
229
|
+
out.push({
|
|
230
|
+
code: 'PAY011',
|
|
231
|
+
...spanOf(rel, m.index, m[0].length),
|
|
232
|
+
why: `This handles ${m[0]} without checking a signature: anyone who finds the URL can POST themselves a purchase.`,
|
|
233
|
+
})
|
|
137
234
|
}
|
|
138
235
|
if (!/\bPayRail\b/.test(text)) return
|
|
139
|
-
walk(sf, n => {
|
|
140
|
-
if (
|
|
236
|
+
walk(sf, (n) => {
|
|
237
|
+
if (
|
|
238
|
+
!((ts.isMethodDeclaration(n) || ts.isPropertyAssignment(n)) && n.name.getText() === 'verify')
|
|
239
|
+
)
|
|
240
|
+
return
|
|
141
241
|
const fn = ts.isPropertyAssignment(n) ? n.initializer : n
|
|
142
|
-
if (!/\bheaders\b/.test(fn.getText()))
|
|
242
|
+
if (!/\bheaders\b/.test(fn.getText()))
|
|
243
|
+
out.push({
|
|
244
|
+
code: 'PAY011',
|
|
245
|
+
...at(sf, rel, n.name),
|
|
246
|
+
why: "This rail's `verify` never reads `headers`, so it can't be checking a signature.",
|
|
247
|
+
})
|
|
143
248
|
})
|
|
144
249
|
}
|
|
145
250
|
|
|
146
251
|
/** Every id the server really enforces: `requires` and awaited `entitlements.has`, in server modules and route `load`s. */
|
|
147
252
|
function enforced(rel, sf, ids) {
|
|
148
|
-
walk(sf, n => {
|
|
149
|
-
if (/(^|\.)entitlements\.has$/.test(callee(n)) && n.arguments[0]?.text)
|
|
150
|
-
|
|
253
|
+
walk(sf, (n) => {
|
|
254
|
+
if (/(^|\.)entitlements\.has$/.test(callee(n)) && n.arguments[0]?.text)
|
|
255
|
+
ids.add(n.arguments[0].text)
|
|
256
|
+
if (
|
|
257
|
+
ts.isPropertyAssignment(n) &&
|
|
258
|
+
n.name.getText() === 'requires' &&
|
|
259
|
+
ts.isArrayLiteralExpression(n.initializer)
|
|
260
|
+
)
|
|
261
|
+
for (const e of n.initializer.elements) if (e.text) ids.add(e.text)
|
|
151
262
|
})
|
|
152
263
|
}
|
|
153
264
|
|
|
154
265
|
/** Client reads of `entitlements`: `mine.value?.includes('pro')`, wherever the file got that list from. */
|
|
155
266
|
function clientReads(rel, sf, reads) {
|
|
156
267
|
if (!/\bentitlements\b/.test(sf.text)) return
|
|
157
|
-
walk(sf, n => {
|
|
158
|
-
if (
|
|
268
|
+
walk(sf, (n) => {
|
|
269
|
+
if (
|
|
270
|
+
!ts.isCallExpression(n) ||
|
|
271
|
+
!/\.(includes|has)$/.test(n.expression.getText()) ||
|
|
272
|
+
!n.arguments[0]?.text
|
|
273
|
+
)
|
|
274
|
+
return
|
|
159
275
|
reads.push({ rel, sf, node: n, id: n.arguments[0].text })
|
|
160
276
|
})
|
|
161
277
|
}
|
|
162
278
|
|
|
163
279
|
/** A server entry in the client graph: it would carry the ledger, and it can't run there. */
|
|
164
280
|
function serverImports(rel, text, out) {
|
|
165
|
-
for (const m of text.matchAll(/from\s+['"](@weft-js\/pay\/(?:server|drizzle))['"]/g))
|
|
281
|
+
for (const m of text.matchAll(/from\s+['"](@weft-js\/pay\/(?:server|drizzle))['"]/g))
|
|
282
|
+
out.push({
|
|
283
|
+
code: 'PAY017',
|
|
284
|
+
...spanOf(rel, m.index, m[0].length),
|
|
285
|
+
why: `\`${m[1]}\` is imported in client code: the ledger runs on the server only.`,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function unmatchedReads(reads, known, out) {
|
|
290
|
+
for (const r of reads) {
|
|
291
|
+
if (known.has(r.id)) continue
|
|
292
|
+
out.push({
|
|
293
|
+
code: 'PAY013',
|
|
294
|
+
...at(r.sf, r.rel, r.node),
|
|
295
|
+
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.`,
|
|
296
|
+
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\``,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
166
299
|
}
|
|
167
300
|
|
|
168
301
|
/** @param {import('@weft-js/core/plugin').CheckContext} ctx */
|
|
169
302
|
export function run(ctx) {
|
|
170
303
|
const out = []
|
|
171
|
-
const rels = ctx.files.filter(r => r.startsWith('src/') && !isTest(r) && !r.endsWith('.d.ts'))
|
|
304
|
+
const rels = ctx.files.filter((r) => r.startsWith('src/') && !isTest(r) && !r.endsWith('.d.ts'))
|
|
172
305
|
const rails = Object.entries(ctx.slots['pay.rail'] ?? {}).filter(([, name]) => name)
|
|
173
|
-
const payFile = ['ts', 'mts', 'js', 'mjs']
|
|
306
|
+
const payFile = ['ts', 'mts', 'js', 'mjs']
|
|
307
|
+
.map((x) => `src/pay.server.${x}`)
|
|
308
|
+
.find((rel) => ctx.exists(rel))
|
|
174
309
|
const known = new Set()
|
|
175
310
|
const reads = []
|
|
176
311
|
|
|
@@ -179,7 +314,14 @@ export function run(ctx) {
|
|
|
179
314
|
const server = isServer(rel)
|
|
180
315
|
secrets(rel, text, server, out)
|
|
181
316
|
if (!server) serverImports(rel, text, out)
|
|
182
|
-
if (
|
|
317
|
+
if (
|
|
318
|
+
!(
|
|
319
|
+
/@weft-js\/pay|entitlements|requires:|\bcheckout\b|\bproduct\(|PayRail|price_|prod_|plink_/.test(
|
|
320
|
+
text,
|
|
321
|
+
) || STRIPE_EVENT.test(text)
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
continue
|
|
183
325
|
const sf = parse(rel, text)
|
|
184
326
|
webhooks(rel, text, sf, out)
|
|
185
327
|
money(rel, sf, out)
|
|
@@ -196,35 +338,42 @@ export function run(ctx) {
|
|
|
196
338
|
}
|
|
197
339
|
}
|
|
198
340
|
|
|
199
|
-
|
|
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
|
-
}
|
|
341
|
+
unmatchedReads(reads, known, out)
|
|
203
342
|
|
|
204
|
-
|
|
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
|
-
}
|
|
343
|
+
configChecks({ ctx, out, secrets, spanOf, isTest, SECRET_OPTION })
|
|
210
344
|
|
|
211
|
-
|
|
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'
|
|
345
|
+
const config = ctx.files.find((r) => r.startsWith('weft.config.')) ?? 'weft.config.ts'
|
|
217
346
|
if (!payFile) {
|
|
218
|
-
out.push({
|
|
347
|
+
out.push({
|
|
348
|
+
code: 'PAY014',
|
|
349
|
+
file: config,
|
|
350
|
+
why: 'This plugin is in `plugins`, but there is no `src/pay.server.ts`: no webhook is mounted, so a payment would grant nothing.',
|
|
351
|
+
})
|
|
219
352
|
return out
|
|
220
353
|
}
|
|
221
|
-
if (!rails.length)
|
|
354
|
+
if (!rails.length)
|
|
355
|
+
out.push({
|
|
356
|
+
code: 'PAY018',
|
|
357
|
+
file: payFile,
|
|
358
|
+
why: '`src/pay.server.ts` needs a `pay.rail` provider, and nothing provides it: every checkout fails.',
|
|
359
|
+
})
|
|
222
360
|
const text = ctx.read(payFile)
|
|
223
361
|
const m = /memoryPayStore\(/.exec(text)
|
|
224
|
-
if (m)
|
|
362
|
+
if (m)
|
|
363
|
+
out.push({
|
|
364
|
+
code: 'PAY016',
|
|
365
|
+
...spanOf(payFile, m.index, m[0].length - 1),
|
|
366
|
+
why: 'The ledger is in memory: purchases are gone on restart, and a production server refuses to start with it.',
|
|
367
|
+
})
|
|
225
368
|
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'))
|
|
369
|
+
const env = ['.env.example', 'src/env.server.ts'].map((rel) => ctx.read(rel)).join('\n')
|
|
370
|
+
if (!env.includes('STRIPE_WEBHOOK_SECRET'))
|
|
371
|
+
out.push({
|
|
372
|
+
code: 'PAY011',
|
|
373
|
+
file: payFile,
|
|
374
|
+
why: 'Nothing declares `STRIPE_WEBHOOK_SECRET`. Without it the rail refuses every webhook, so no purchase ever lands.',
|
|
375
|
+
help: 'Add `STRIPE_WEBHOOK_SECRET` to `.env.example` and `src/env.server.ts`',
|
|
376
|
+
})
|
|
228
377
|
}
|
|
229
378
|
return out
|
|
230
379
|
}
|
package/src/drizzle.ts
CHANGED
|
@@ -1,38 +1,57 @@
|
|
|
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
1
|
import { eq, getTableName, type AnyColumn, type SQL, type Table } from 'drizzle-orm'
|
|
5
2
|
import type { DbHandle } from '@weft-js/start/db/core'
|
|
6
3
|
import type { PayStore, Purchase } from './server'
|
|
7
4
|
|
|
8
5
|
/** 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
6
|
interface LedgerClient {
|
|
10
|
-
select(fields?: Record<string, AnyColumn>): {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
select(fields?: Record<string, AnyColumn>): {
|
|
8
|
+
from(table: Table): {
|
|
9
|
+
where(on: SQL | undefined): Promise<Purchase[]> & { limit(n: number): Promise<Purchase[]> }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
insert(table: Table): {
|
|
13
|
+
values(row: object): Promise<unknown> & {
|
|
14
|
+
onDuplicateKeyUpdate(update: { set: object }): Promise<unknown>
|
|
15
|
+
onConflictDoNothing(): Promise<unknown>
|
|
16
|
+
onConflictDoUpdate(update: { target: AnyColumn; set: object }): Promise<unknown>
|
|
17
|
+
}
|
|
18
|
+
}
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/** A `PayStore` on the app's Drizzle tables, on Postgres, SQLite or MySQL. */
|
|
19
|
-
export function drizzlePayStore(
|
|
22
|
+
export function drizzlePayStore(
|
|
23
|
+
db: { $weft: DbHandle },
|
|
24
|
+
tables: {
|
|
25
|
+
purchases: Table & { id: AnyColumn; user: AnyColumn }
|
|
26
|
+
payEvents: Table & { id: AnyColumn }
|
|
27
|
+
},
|
|
28
|
+
): PayStore {
|
|
20
29
|
const { purchases, payEvents } = tables
|
|
21
30
|
const client = db as unknown as LedgerClient
|
|
22
31
|
const mysql = db.$weft.dialect === 'mysql'
|
|
23
32
|
db.$weft.exempt.add(getTableName(purchases)).add(getTableName(payEvents)) // written by a webhook, not by a server function
|
|
24
33
|
return {
|
|
25
|
-
seen: async
|
|
34
|
+
seen: async (id) =>
|
|
35
|
+
(
|
|
36
|
+
await client
|
|
37
|
+
.select({ id: payEvents.id })
|
|
38
|
+
.from(payEvents)
|
|
39
|
+
.where(eq(payEvents.id, id))
|
|
40
|
+
.limit(1)
|
|
41
|
+
).length > 0,
|
|
26
42
|
async mark(id, at) {
|
|
27
43
|
const insert = client.insert(payEvents).values({ id, at })
|
|
28
44
|
await (mysql ? insert.onDuplicateKeyUpdate({ set: { id } }) : insert.onConflictDoNothing())
|
|
29
45
|
},
|
|
30
|
-
purchase: async
|
|
46
|
+
purchase: async (id) =>
|
|
47
|
+
(await client.select().from(purchases).where(eq(purchases.id, id)).limit(1))[0] ?? null,
|
|
31
48
|
async save(row: Purchase) {
|
|
32
49
|
const { id: _id, ...set } = row
|
|
33
50
|
const insert = client.insert(purchases).values(row)
|
|
34
|
-
await (mysql
|
|
51
|
+
await (mysql
|
|
52
|
+
? insert.onDuplicateKeyUpdate({ set })
|
|
53
|
+
: insert.onConflictDoUpdate({ target: purchases.id, set }))
|
|
35
54
|
},
|
|
36
|
-
byUser: user => client.select().from(purchases).where(eq(purchases.user, user)),
|
|
55
|
+
byUser: (user) => client.select().from(purchases).where(eq(purchases.user, user)),
|
|
37
56
|
}
|
|
38
57
|
}
|