@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/src/checks.mjs CHANGED
@@ -1,37 +1,48 @@
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`.
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 = /\b(?:process\.env|import\.meta\.env)\.\w*STRIPE_(?:SECRET_KEY|WEBHOOK_SECRET|RESTRICTED_KEY)\b/g
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 = /['"`](?:checkout\.session\.completed|customer\.subscription\.\w+|invoice\.paid|invoice\.payment_\w+|payment_intent\.succeeded|charge\.refunded)['"`]/
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) => ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
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 = n => ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n)
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(({ 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.` }))
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) 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.` }))
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)) 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.` }))
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(({ 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 }] } }))
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])) return
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)) out.push(({ code: 'PAY008', ...at(sf, rel, price), why: `\`${id}\`'s price has no currency: ${price.text} of what?` }))
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)) for (const p of plans.properties) if (ts.isPropertyAssignment(p) && ts.isObjectLiteralExpression(p.initializer)) amounts(p.initializer, `${id}.${p.name.getText()}`)
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 = 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.` }))
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 (ts.isPropertyAccessExpression(up) && up.name.text === 'then' && ts.isCallExpression(up.parent) && up.parent.arguments[0]) writes(up.parent.arguments[0], 0)
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)) 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.` }))
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 = (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))
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 && { 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 }))
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 (callee(n) !== 'mutation' || !n.arguments[0] || !ts.isObjectLiteralExpression(n.arguments[0])) return
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')) 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.' }))
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(({ 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.` }))
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 (!((ts.isMethodDeclaration(n) || ts.isPropertyAssignment(n)) && n.name.getText() === 'verify')) return
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())) 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." }))
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) 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)
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 (!ts.isCallExpression(n) || !/\.(includes|has)$/.test(n.expression.getText()) || !n.arguments[0]?.text) return
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)) 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.` })
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'].map(x => `src/pay.server.${x}`).find(rel => ctx.exists(rel))
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 (!(/@weft-js\/pay|entitlements|requires:|\bcheckout\b|\bproduct\(|PayRail|price_|prod_|plink_/.test(text) || STRIPE_EVENT.test(text))) continue
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
- 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
- }
341
+ unmatchedReads(reads, known, out)
203
342
 
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
- }
343
+ configChecks({ ctx, out, secrets, spanOf, isTest, SECRET_OPTION })
210
344
 
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'
345
+ const config = ctx.files.find((r) => r.startsWith('weft.config.')) ?? 'weft.config.ts'
217
346
  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.' })
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) out.push({ code: 'PAY018', file: payFile, why: '`src/pay.server.ts` needs a `pay.rail` provider, and nothing provides it: every checkout fails.' })
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) 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.' })
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')) 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`' })
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>): { 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
- } }
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(db: { $weft: DbHandle }, tables: { purchases: Table & { id: AnyColumn; user: AnyColumn }; payEvents: Table & { id: AnyColumn } }): PayStore {
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 id => (await client.select({ id: payEvents.id }).from(payEvents).where(eq(payEvents.id, id)).limit(1)).length > 0,
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 id => (await client.select().from(purchases).where(eq(purchases.id, id)).limit(1))[0] ?? null,
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 ? insert.onDuplicateKeyUpdate({ set }) : insert.onConflictDoUpdate({ target: purchases.id, set }))
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
  }