@profullstack/builtindie 0.1.3 → 0.6.0

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/service.ts CHANGED
@@ -5,22 +5,27 @@
5
5
  * submit URL in, a listing out: everything fetched, pictures saved. Free.
6
6
  * Anonymous launches go live at once; a signed-in submitter checks a draft first.
7
7
  * publish the draft goes live on today's board
8
- * claim the domain's owner proves it with a DNS TXT record and takes the listing
8
+ * claim the domain's owner proves it (a DNS TXT record, or the badge with their token) and takes the listing
9
9
  * refetch read the homepage again (the maker changed their logo)
10
10
  * verify the maker put our badge on their site: a followed link back
11
- * feature pay in crypto (CoinPay) to sit on top for a week
11
+ * feature pay on CoinPay's hosted checkout (any coin or PayPal) to sit on top for a week
12
+ * vote, comment, review, share what members do on a listing; each pays karma (karma.ts)
12
13
  *
13
14
  * Products are described in the LogicSRC open specs: each one is an OpenSite
14
15
  * record (logicsrc.com/docs/opensite) and an OpenProfile.md with Kind
15
16
  * organization (logicsrc.com/docs/openprofile); a maker is an OpenProfile.md
16
17
  * of their own, which we read and link to.
17
18
  */
18
- import { createHmac, timingSafeEqual } from 'node:crypto'
19
+ import { createHmac, randomBytes } from 'node:crypto'
19
20
  import { Resolver } from 'node:dns/promises'
20
21
  import { keyedSection, listSection, makeOpenProfile, renderOpenProfile } from '@profullstack/openprofile'
21
22
  import { autofetch, clip, FetchError, normalizeUrl, readProfile, safeFetch, SOCIAL_KINDS, videoFrom, type FetchOptions, type SiteInfo, type Video } from './fetch.js'
22
23
  import { Media, screenshot } from './media.js'
23
- import { dayOf, makerIdFor, newId, Store, type Product, type User } from './db.js'
24
+ import { dayOf, makerIdFor, newId, Store, type Comment, type Product, type Review, type User } from './db.js'
25
+ import { Karma, SHARE_NETWORKS } from './karma.js'
26
+ import { cleanTags } from './tags.js'
27
+ import { validEmail } from './auth.js'
28
+ import { CoinPay, CoinPayError, readWebhook, verifyWebhook } from '@profullstack/coinpay-onboard'
24
29
 
25
30
  export class InputError extends Error {
26
31
  constructor(message: string, readonly field?: string) {
@@ -28,50 +33,64 @@ export class InputError extends Error {
28
33
  }
29
34
  }
30
35
 
31
- export type CoinPayConfig = { baseUrl: string; apiKey: string; businessId: string; webhookSecret: string }
36
+ export type CoinPayConfig = { baseUrl: string; apiKey: string; businessId: string; webhookSecret: string; /** Tests pass their own. */ fetch?: typeof fetch }
32
37
  export type Config = {
33
38
  origin: string
34
39
  coinpay: CoinPayConfig | null
35
40
  featureUsd: number
36
41
  featureDays: number
37
- chains: string[]
38
42
  fetch?: FetchOptions
39
43
  screenshots?: boolean
40
44
  /** Secret behind each person's claim token. */
41
45
  claimSecret?: string
42
46
  /** TXT lookups; tests pass their own. */
43
47
  resolveTxt?: (name: string) => Promise<string[][]>
48
+ /** Karma per free Featured week (KARMA_PER_CREDIT, default 500). */
49
+ karmaPerCredit?: number
44
50
  }
45
51
 
46
52
  /** The record an owner adds: `_built-indie.<domain> TXT "built-indie-verify=<token>"` (or the same on the domain itself). */
47
53
  export const CLAIM_LABEL = '_built-indie'
48
54
  export const CLAIM_PREFIX = 'built-indie-verify='
49
55
 
50
- const resolver = new Resolver({ timeout: 4000, tries: 2 })
51
- resolver.setServers(['1.1.1.1', '9.9.9.9'])
52
- const publicTxt = (name: string) => resolver.resolveTxt(name)
53
-
54
- export const CHAIN_LABELS: Record<string, string> = {
55
- USDC_POL: 'USDC on Polygon', USDC_SOL: 'USDC on Solana', USDC_ETH: 'USDC on Ethereum', USDT_POL: 'USDT on Polygon',
56
- BTC: 'Bitcoin', ETH: 'Ether', SOL: 'Solana', POL: 'Polygon', DOGE: 'Dogecoin', BCH: 'Bitcoin Cash', XRP: 'XRP', ADA: 'Cardano', BNB: 'BNB',
56
+ /**
57
+ * TXT records as public resolvers see them: every answer from 1.1.1.1 and 9.9.9.9, merged.
58
+ * One Resolver per server on purpose: Bun 1.4's node:dns Resolver given two servers
59
+ * answers ENOTFOUND for a record each of them serves alone, so no claim could succeed.
60
+ * And fresh Resolvers per lookup: a long-lived one caches NXDOMAIN for the zone's SOA
61
+ * minimum (often 30 min), so a maker who checks before adding the record stays refused.
62
+ */
63
+ const publicDns = () =>
64
+ ['1.1.1.1', '9.9.9.9'].map((server) => {
65
+ const r = new Resolver({ timeout: 4000, tries: 2 })
66
+ r.setServers([server])
67
+ return r
68
+ })
69
+ export async function publicTxt(name: string, resolvers: Pick<Resolver, 'resolveTxt'>[] = publicDns()): Promise<string[][]> {
70
+ const answers = await Promise.all(resolvers.map((r) => r.resolveTxt(name).catch(() => [] as string[][])))
71
+ return answers.flat()
57
72
  }
58
- export const DEFAULT_CHAINS = Object.keys(CHAIN_LABELS)
59
- export const SETTLED = new Set(['paid', 'completed', 'confirmed', 'forwarded', 'succeeded', 'settled'])
73
+
74
+ /** Who is paying for a Featured slot: the email is required by CoinPay; the account is the member id, or the email for a guest. */
75
+ export type Buyer = { email: string; accountId?: string | null }
60
76
 
61
77
  export type Edit = Partial<{ name: string; tagline: string; description: string; tags: string[] | string; makerName: string; makerProfile: string; videos: string[] | string; socials: Record<string, string>; remove: string[] | string }>
62
78
 
63
79
  const list = (v: string[] | string | undefined) => (Array.isArray(v) ? v : String(v ?? '').split(/[\n,]+/)).map((s) => s.trim()).filter(Boolean)
64
80
 
65
81
  export class BuiltIndie {
66
- constructor(readonly store: Store, readonly media: Media, readonly config: Config) {}
82
+ readonly karma: Karma
83
+ constructor(readonly store: Store, readonly media: Media, readonly config: Config) {
84
+ this.karma = new Karma(store, config.karmaPerCredit ?? 500)
85
+ }
67
86
 
68
87
  /** A product page's public address. */
69
88
  link(p: Pick<Product, 'slug'>): string {
70
89
  return `${this.config.origin}/p/${p.slug}`
71
90
  }
72
91
 
73
- /** Read the URL and make a draft. A URL already live comes back as `existing` instead. */
74
- async submit(input: { url: string; makerName?: string; makerProfile?: string }, submitter: string, user: User | null = null): Promise<{ product: Product; existing: boolean }> {
92
+ /** Read the URL and make a draft. A URL already live comes back as `existing` instead. `unlimited` (the admin) skips the hourly cap. */
93
+ async submit(input: { url: string; makerName?: string; makerProfile?: string }, submitter: string, user: User | null = null, unlimited = false): Promise<{ product: Product; existing: boolean }> {
75
94
  let url: URL
76
95
  try {
77
96
  url = normalizeUrl(input.url)
@@ -80,7 +99,7 @@ export class BuiltIndie {
80
99
  }
81
100
  const already = this.store.byUrl(url.href)
82
101
  if (already?.status === 'live') return { product: already, existing: true }
83
- if (this.store.submissionsFrom(submitter, Date.now() - 3600_000) >= 10) throw new InputError('That is a lot of launches in an hour. Try again later.')
102
+ if (!unlimited && this.store.submissionsFrom(submitter, Date.now() - 3600_000) >= 10) throw new InputError('That is a lot of launches in an hour. Try again later.')
84
103
  const info = await this.read(url.href)
85
104
  const id = newId()
86
105
  const pictures = await this.pictures(id, info)
@@ -118,22 +137,88 @@ export class BuiltIndie {
118
137
  return createHmac('sha256', this.config.claimSecret ?? 'built.indie').update(`claim:${user.id}:${p.domain}`).digest('base64url').slice(0, 24)
119
138
  }
120
139
 
121
- /** Look for the claim record at _built-indie.<domain>, then on the domain itself (and its apex for www). */
122
- async claim(p: Product, user: User): Promise<{ ok: boolean; looked: string[] }> {
140
+ /**
141
+ * Two proofs, either one claims it: the TXT record at _built-indie.<domain> (or on the
142
+ * domain itself, and its apex for www), or, failing DNS, our badge on the homepage
143
+ * carrying this person's badge id (`/images.svg?id=<id>`, the easy way). Kept for
144
+ * badges already placed: `/badge/<slug>.svg?claim=<token>`, or the bare
145
+ * `built-indie-verify=<token>` anywhere in the HTML, e.g. a meta tag. A badge that
146
+ * claims also earns the followed link and the ✓, as `verify` would.
147
+ */
148
+ async claim(p: Product, user: User): Promise<{ ok: boolean; looked: string[]; method?: 'dns' | 'badge' }> {
123
149
  if (p.status === 'hidden') throw new InputError('This product was taken down.')
124
150
  if (p.ownerId === user.id) return { ok: true, looked: [] }
125
- const want = `${CLAIM_PREFIX}${this.claimToken(p, user)}`
151
+ const token = this.claimToken(p, user)
152
+ const want = `${CLAIM_PREFIX}${token}`
126
153
  const bare = p.domain.replace(/^www\./, '')
127
154
  const names = [...new Set([`${CLAIM_LABEL}.${bare}`, bare, p.domain])]
155
+ const looked = [...names, p.url]
128
156
  const lookup = this.config.resolveTxt ?? publicTxt
129
157
  for (const name of names) {
130
158
  const records = await lookup(name).catch(() => [] as string[][])
131
159
  if (records.some((chunks) => chunks.join('').trim() === want)) {
132
- this.store.claim(p.id, user.id)
133
- return { ok: true, looked: names }
160
+ this.claimed(p, user, false)
161
+ return { ok: true, looked, method: 'dns' }
134
162
  }
135
163
  }
136
- return { ok: false, looked: names }
164
+ const html = await this.homepage(p)
165
+ const own = this.badgeIds(html).some((id) => {
166
+ const b = this.store.badge(id)
167
+ return b?.productId === p.id && b.userId === user.id
168
+ })
169
+ if (own) {
170
+ this.claimed(p, user, true)
171
+ return { ok: true, looked, method: 'badge' }
172
+ }
173
+ if (this.hasClaimBadge(html, token)) {
174
+ this.claimed(p, user, this.linksBack(html))
175
+ return { ok: true, looked, method: 'badge' }
176
+ }
177
+ return { ok: false, looked }
178
+ }
179
+
180
+ /** The claim took: ownership, karma for it, and for the badge when it was the proof. */
181
+ private claimed(p: Product, user: User, verified: boolean): void {
182
+ this.store.claim(p.id, user.id)
183
+ if (verified) this.store.setVerified(p.id, Date.now())
184
+ const now = this.store.product(p.id)!
185
+ if (now.status === 'live') this.karma.claimed(now, user.id)
186
+ if (verified && now.status === 'live') this.karma.badgeVerified(now)
187
+ }
188
+
189
+ /** The product's homepage HTML, read the safe way (no private addresses, robots honoured); '' if it will not load. */
190
+ private async homepage(p: Product): Promise<string> {
191
+ const r = await safeFetch(p.url, { ...this.config.fetch, accept: 'text/html' }).catch(() => null)
192
+ return r && r.status < 300 ? new TextDecoder().decode(r.body) : ''
193
+ }
194
+
195
+ private ourHosts(): string {
196
+ return `(?:${new URL(this.config.origin).hostname.replace(/\./g, '\\.')}|built\\.indie)`
197
+ }
198
+
199
+ /** A followed-link-worthy link back to us. */
200
+ private linksBack(html: string): boolean {
201
+ return new RegExp(`href=["']?https?://${this.ourHosts()}(/|["'])`, 'i').test(html)
202
+ }
203
+
204
+ /** Every link or image URL in the page that points at our host. */
205
+ private ourUrls(html: string): string[] {
206
+ if (!html) return []
207
+ return [...html.matchAll(new RegExp(`(?:href|src)\\s*=\\s*["']?((?:https?:)?//${this.ourHosts()}/[^"'\\s>]*)`, 'gi'))].map((m) => m[1]!)
208
+ }
209
+
210
+ /** The ids of every `/images.svg?id=<id>` badge on our host in the page. */
211
+ badgeIds(html: string): string[] {
212
+ const ids = this.ourUrls(html).map((u) => u.match(/\/images\.svg\?(?:[^#]*?(?:&|&amp;))?id=([A-Za-z0-9_-]+)/)?.[1])
213
+ return [...new Set(ids.filter((id): id is string => Boolean(id)))]
214
+ }
215
+
216
+ /** A link or image on our host whose URL carries claim=<token>, or the bare verify string anywhere. */
217
+ private hasClaimBadge(html: string, token: string): boolean {
218
+ if (!html) return false
219
+ const exact = (prefix: string) => new RegExp(`${prefix}${token}(?![A-Za-z0-9_-])`)
220
+ if (exact(CLAIM_PREFIX).test(html)) return true
221
+ return this.ourUrls(html).some((u) => exact('[?&](?:amp;)?claim=').test(u))
137
222
  }
138
223
 
139
224
  private async read(url: string): Promise<SiteInfo> {
@@ -164,7 +249,86 @@ export class BuiltIndie {
164
249
  publish(p: Product): Product {
165
250
  if (p.status === 'hidden') throw new InputError('This product was taken down.')
166
251
  if (!p.name.trim()) throw new InputError('Give it a name', 'name')
167
- return this.store.update(p.id, { status: 'live' })!
252
+ const live = this.store.update(p.id, { status: 'live' })!
253
+ this.karma.launched(live)
254
+ // A claim made while it was a draft pays once it is live.
255
+ if (live.ownerId) this.karma.claimed(live, live.ownerId)
256
+ // An invited member's first live listing earns whoever invited them a free Featured week.
257
+ const inviter = p.submitterUser ? this.store.earnInvite(p.submitterUser, p.id) : null
258
+ if (inviter) {
259
+ this.karma.inviteConverted(inviter, p.id)
260
+ this.onInviteEarned?.(inviter, live)
261
+ }
262
+ return live
263
+ }
264
+
265
+ // What members do on a listing. Anyone may vote; comments and reviews need an account.
266
+
267
+ /** One vote per visitor (their address); a signed-in voter is noted and earns karma. True when it counted. */
268
+ vote(p: Product, voter: string, user: User | null = null): boolean {
269
+ if (p.status !== 'live') throw new InputError('Only a live listing takes votes.')
270
+ if (!this.store.vote(p.id, voter, Date.now(), user?.id ?? null)) return false
271
+ this.karma.voted(p, user?.id ?? null)
272
+ return true
273
+ }
274
+
275
+ comment(p: Product, user: User, text: string, parentId?: string | null): Comment {
276
+ if (p.status !== 'live') throw new InputError('Only a live listing takes comments.')
277
+ const body = String(text ?? '').replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim().slice(0, 2000)
278
+ if (body.length < 2) throw new InputError('Write something first.', 'body')
279
+ let parent: Comment | null = null
280
+ if (parentId) {
281
+ parent = this.store.comment(parentId)
282
+ if (!parent || parent.productId !== p.id || parent.deletedAt) throw new InputError('That comment is gone.', 'parent')
283
+ // One level of replies: answering a reply answers its thread.
284
+ if (parent.parentId) parent = this.store.comment(parent.parentId)
285
+ }
286
+ const c = this.store.addComment({ productId: p.id, userId: user.id, parentId: parent?.id ?? null, body })
287
+ this.karma.commented(p, user.id, c)
288
+ return c
289
+ }
290
+
291
+ /** Delete a comment: its author, or the admin. Takes back the karma it earned. */
292
+ deleteComment(id: string, user: User | null, admin = false): Comment {
293
+ const c = this.store.comment(id)
294
+ if (!c || c.deletedAt) throw new InputError('No such comment.')
295
+ if (!admin && c.userId !== user?.id) throw new InputError('Only its author can delete a comment.')
296
+ this.store.deleteComment(id)
297
+ this.karma.uncommented(id)
298
+ return c
299
+ }
300
+
301
+ review(p: Product, user: User, rating: unknown, text: unknown): Review {
302
+ if (p.status !== 'live') throw new InputError('Only a live listing takes reviews.')
303
+ const stars = Number(rating)
304
+ if (!Number.isInteger(stars) || stars < 1 || stars > 5) throw new InputError('Pick 1 to 5 stars.', 'rating')
305
+ const body = String(text ?? '').replace(/\r\n?/g, '\n').trim().slice(0, 2000)
306
+ const r = this.store.putReview({ productId: p.id, userId: user.id, rating: stars, body })
307
+ this.karma.reviewed(p, user.id, r)
308
+ return r
309
+ }
310
+
311
+ deleteReview(p: Product, userId: string): boolean {
312
+ const gone = this.store.deleteReview(p.id, userId)
313
+ if (gone) this.karma.unreviewed(p, userId)
314
+ return gone
315
+ }
316
+
317
+ /** A share button clicked. Pays a signed-in member (once per listing, network and day). */
318
+ share(p: Product, user: User | null, network: string): { counted: boolean } {
319
+ if (p.status !== 'live') throw new InputError('Only a live listing can be shared.')
320
+ if (!(SHARE_NETWORKS as readonly string[]).includes(network)) throw new InputError(`network is one of ${SHARE_NETWORKS.join(', ')}`, 'network')
321
+ return { counted: this.karma.shared(p, user?.id ?? null, network) }
322
+ }
323
+
324
+ /** Called when a listing earns its submitter's inviter a credit: the server mails them and refreshes the board. */
325
+ onInviteEarned?: (inviterId: string, product: Product) => void
326
+
327
+ /** Spend one invite credit on a live listing: featured for the usual number of days, free. */
328
+ redeemCredit(p: Product, user: User): Product {
329
+ if (p.status !== 'live') throw new InputError('Only a live listing can be featured.')
330
+ if (!this.store.redeemCredit(user.id, p.id, this.config.featureDays)) throw new InputError('No invite credits left. Invite a maker who launches to earn one.')
331
+ return this.store.product(p.id)!
168
332
  }
169
333
 
170
334
  async refetch(p: Product): Promise<Product> {
@@ -182,7 +346,7 @@ export class BuiltIndie {
182
346
  }
183
347
  if (e.tagline !== undefined) patch.tagline = clip(e.tagline, 90)
184
348
  if (e.description !== undefined) patch.description = clip(e.description, 1200)
185
- if (e.tags !== undefined) patch.tags = [...new Set(list(e.tags).map((t) => t.toLowerCase().replace(/^#/, '').replace(/[^a-z0-9 +#.-]/g, '').slice(0, 24)).filter((t) => t.length >= 2))].slice(0, 6)
349
+ if (e.tags !== undefined) patch.tags = cleanTags(list(e.tags))
186
350
  if (e.makerName !== undefined) patch.makerName = clip(e.makerName, 60)
187
351
  if (e.videos !== undefined) {
188
352
  const videos: Video[] = []
@@ -238,13 +402,12 @@ export class BuiltIndie {
238
402
  return this.store.update(p.id, { makerProfile: r.url.href, makerId: maker.id, makerVerified: verified, makerName: clip(name?.trim() || profile.name, 60) })!
239
403
  }
240
404
 
241
- /** Has the maker linked back to us from their homepage? */
405
+ /** Has the maker linked back to us from their homepage, or put this listing's badge (any account's id) on it? */
242
406
  async verify(p: Product): Promise<boolean> {
243
- const r = await safeFetch(p.url, { ...this.config.fetch, accept: 'text/html' }).catch(() => null)
244
- const html = r && r.status < 300 ? new TextDecoder().decode(r.body) : ''
245
- const ours = new URL(this.config.origin).hostname
246
- const ok = new RegExp(`href=["']?https?://(${ours.replace(/\./g, '\\.')}|built\\.indie)(/|["'])`, 'i').test(html)
407
+ const html = await this.homepage(p)
408
+ const ok = this.linksBack(html) || this.badgeIds(html).some((id) => this.store.badge(id)?.productId === p.id)
247
409
  this.store.setVerified(p.id, ok ? Date.now() : null)
410
+ if (ok && p.status === 'live') this.karma.badgeVerified(p)
248
411
  return ok
249
412
  }
250
413
 
@@ -308,61 +471,73 @@ export class BuiltIndie {
308
471
  )
309
472
  }
310
473
 
311
- /** Start a crypto payment to feature a product; returns where to send the buyer. */
312
- async feature(p: Product, chain: string, fetcher: typeof fetch = fetch): Promise<{ checkoutUrl: string; ref: string }> {
474
+ /**
475
+ * Start a CoinPay hosted checkout session to feature a product; returns where to send
476
+ * the buyer. The buyer picks any coin or PayPal on CoinPay's page, so there is no
477
+ * currency picker here.
478
+ */
479
+ async feature(p: Product, buyer: Buyer): Promise<{ checkoutUrl: string; ref: string; sessionId: string; methods: string[] }> {
313
480
  const cp = this.config.coinpay
314
481
  if (!cp) throw new InputError('Featuring is not set up on this server yet.')
315
482
  if (p.status !== 'live') throw new InputError('Publish it first.')
316
- if (!this.config.chains.includes(chain)) throw new InputError('Pick a currency from the list', 'chain')
317
- const res = await fetcher(`${cp.baseUrl}/api/payments/create`, {
318
- method: 'POST',
319
- headers: { authorization: `Bearer ${cp.apiKey}`, 'content-type': 'application/json' },
320
- body: JSON.stringify({
321
- business_id: cp.businessId,
483
+ const email = validEmail(buyer.email)
484
+ if (!email) throw new InputError('Enter your email: CoinPay sends the receipt there', 'email')
485
+ const accountId = String(buyer.accountId ?? '').trim() || email
486
+ const ref = `fp_${randomBytes(9).toString('base64url')}`
487
+ const link = this.link(p)
488
+ const client = new CoinPay({ token: cp.apiKey, base: cp.baseUrl, fetch: cp.fetch ?? fetch })
489
+ let session
490
+ try {
491
+ session = await client.createCheckoutSession({
322
492
  amount: this.config.featureUsd,
323
493
  currency: 'USD',
324
- blockchain: chain,
325
- payment_method: 'crypto',
326
494
  description: `built.indie: feature ${p.name} for ${this.config.featureDays} days`,
327
- metadata: { product_id: p.id, product: 'feature' },
328
- success_url: `${this.link(p)}?featured=1`,
329
- cancel_url: this.link(p),
330
- }),
331
- signal: AbortSignal.timeout(20_000),
332
- })
333
- if (!res.ok) throw new Error(`CoinPay said ${res.status}: ${(await res.text()).slice(0, 200)}`)
334
- const body = (await res.json()) as any
335
- const payment = body?.payment ?? body
336
- const ref = String(payment?.id ?? payment?.payment_id ?? '')
337
- if (!ref) throw new Error('CoinPay returned no payment reference')
495
+ customerEmail: email,
496
+ accountId,
497
+ property: new URL(this.config.origin || 'https://built.indie.pit.world').hostname,
498
+ metadata: { product_id: p.id, product: 'feature', days: this.config.featureDays, payment_ref: ref },
499
+ successUrl: `${link}?featured=1`,
500
+ cancelUrl: link,
501
+ expiresInMinutes: 60,
502
+ })
503
+ } catch (e) {
504
+ throw new Error(`CoinPay said ${e instanceof CoinPayError ? e.status : 'error'}: ${(e as Error).message.slice(0, 200)}`)
505
+ }
506
+ if (!session?.id || !session.url) throw new Error('CoinPay returned no checkout session')
338
507
  // Written before the buyer leaves: a webhook can beat the redirect.
339
- this.store.addPayment({ ref, productId: p.id, amountUsd: this.config.featureUsd, chain, days: this.config.featureDays })
340
- return { checkoutUrl: `${cp.baseUrl}/pay/${ref}`, ref }
508
+ this.store.addPayment({ ref, productId: p.id, amountUsd: this.config.featureUsd, chain: 'checkout', days: this.config.featureDays, checkoutSessionId: session.id, email, accountId })
509
+ return { checkoutUrl: session.url, ref, sessionId: session.id, methods: session.methods ?? [] }
341
510
  }
342
511
 
343
- /** CoinPay's webhook: `t=<unix>,v1=<hmac of "t.body">`; the payload is { data: { payment_id, status } }. */
344
- webhook(raw: string, header: string | undefined, nowSeconds = Date.now() / 1000): { ok: boolean; status?: number } {
512
+ /**
513
+ * CoinPay's webhook: `X-CoinPay-Signature: t=<unix>,v1=<hmac of "t.body">` over the raw
514
+ * bytes; the body is nested, `{ id: evt_..., type, data: { payment_id, status,
515
+ * checkout_session_id, metadata } }`. Paid = payment.confirmed (a PayPal capture has
516
+ * status 'completed') or payment.forwarded; one session can send both, so settling is
517
+ * once per local payment.
518
+ */
519
+ webhook(raw: string, header: string | undefined, nowSeconds = Date.now() / 1000): { ok: boolean; status?: number; settled?: boolean } {
345
520
  const cp = this.config.coinpay
346
521
  if (!cp?.webhookSecret || !header) return { ok: false, status: 401 }
347
- const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=').map((s) => s.trim())))
348
- const t = Number(parts.t)
349
- if (!Number.isFinite(t) || !parts.v1 || Math.abs(nowSeconds - t) > 300) return { ok: false, status: 401 }
350
- const expected = Buffer.from(createHmac('sha256', cp.webhookSecret).update(`${parts.t}.${raw}`).digest('hex'), 'hex')
351
- const given = Buffer.from(String(parts.v1), 'hex')
352
- if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { ok: false, status: 401 }
353
- let payload: any
522
+ if (!verifyWebhook(raw, header, cp.webhookSecret, nowSeconds * 1000)) return { ok: false, status: 401 }
523
+ let payload: unknown
354
524
  try {
355
525
  payload = JSON.parse(raw)
356
526
  } catch {
357
527
  return { ok: false, status: 400 }
358
528
  }
359
- const data = payload?.data ?? payload
360
- const ref = String(data?.payment_id ?? data?.id ?? '')
361
- const status = String(data?.status ?? payload?.type?.replace(/^payment\./, '') ?? '').toLowerCase()
362
- if (!ref || !this.store.payment(ref)) return { ok: true }
363
- if (SETTLED.has(status)) this.store.settle(ref)
364
- else if (status) this.store.setPaymentStatus(ref, status)
365
- return { ok: true }
529
+ const ev = readWebhook(payload)
530
+ const row =
531
+ (ev.checkoutSessionId && this.store.paymentBySession(ev.checkoutSessionId)) ||
532
+ (typeof ev.metadata.payment_ref === 'string' && this.store.payment(ev.metadata.payment_ref)) ||
533
+ (ev.paymentId && this.store.payment(ev.paymentId)) ||
534
+ null
535
+ if (!row) return { ok: true }
536
+ if (ev.paymentId) this.store.notePaymentId(row.ref, ev.paymentId, ev.method || null)
537
+ if (ev.paid) return { ok: true, settled: this.store.settle(row.ref) }
538
+ const status = ev.status || ev.type.replace(/^payment\./, '')
539
+ if (status) this.store.setPaymentStatus(row.ref, status)
540
+ return { ok: true, settled: false }
366
541
  }
367
542
  }
368
543
 
package/src/tags.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tags: one shape for every tag, and topics inferred from a listing's own words
3
+ * when its homepage declares none (no keywords meta, JSON-LD category or
4
+ * opensite.json tags). Inference is a fixed vocabulary, so it is cheap,
5
+ * offline and the same on every run.
6
+ */
7
+
8
+ export const MAX_TAGS = 6
9
+
10
+ /** Lower case, no leading #, only a-z 0-9 space + # . -, 2 to 24 characters; null if nothing is left. */
11
+ export function normalizeTag(raw: string): string | null {
12
+ const t = String(raw)
13
+ .toLowerCase()
14
+ .replace(/^#/, '')
15
+ .replace(/application$/, '')
16
+ .replace(/[^a-z0-9 +#.-]/g, '')
17
+ .replace(/\s+/g, ' ')
18
+ .trim()
19
+ return t.length >= 2 && t.length <= 24 ? t : null
20
+ }
21
+
22
+ /** Normalized, unique, at most `max`. */
23
+ export function cleanTags(raw: Iterable<string>, max = MAX_TAGS): string[] {
24
+ const out = new Set<string>()
25
+ for (const r of raw) {
26
+ const t = normalizeTag(r)
27
+ if (t) out.add(t)
28
+ if (out.size >= max) break
29
+ }
30
+ return [...out]
31
+ }
32
+
33
+ /** Topic, and the words that mean it. Earlier topics win a tie. */
34
+ const TOPICS: [string, RegExp][] = [
35
+ ['ai', /\b(ai|llms?|gpt|claude|codex|opencode|agentic|ai agents?|open models?|whisper(\.cpp)?)\b/g],
36
+ ['agents', /\b(agents?|agentic)\b/g],
37
+ ['developer tools', /\b(developers?|cli|apis?|sdk|codebase|coding|git|webhooks?|npm|registry|registries|ci\/?cd)\b/g],
38
+ ['game', /\b(games?|gaming|gamers?|play|shooter|multiplayer|co-op|campaign)\b/g],
39
+ ['tactical', /\b(operatives?|tactical|combat|military|war)\b/g],
40
+ ['sports', /\b(sports?|youth-sports|athletes?|league|fixtures|teams? in the world)\b/g],
41
+ ['crypto', /\b(crypto|bitcoin|btc|eth|usdc|sol|stablecoins?|lightning|web3|wallets?|coinpay)\b/g],
42
+ ['payments', /\b(payments?|checkout|pay link|get paid|pays you|invoices?|billing|pay by the)\b/g],
43
+ ['sms', /\b(sms|texts?|texting|phone numbers?|sim( cards?)?|carrier)\b/g],
44
+ ['verification', /\b(otp|2fa|verification( codes?)?|one-time codes?)\b/g],
45
+ ['email', /\b(e-?mail|newsletters?|inbox)\b/g],
46
+ ['voice', /\b(voice|calls?|dials?|phone tree|on hold)\b/g],
47
+ ['customer support', /\b(customer support|phone tree|helpdesk|support tickets?)\b/g],
48
+ ['video', /\b(videos?|reels?|films?|demo video|highlight)\b/g],
49
+ ['tv', /\b(iptv|tv( guide)?|live-tv|channels?|m3u|xtream)\b/g],
50
+ ['music', /\b(music|spotify|albums?|songs?|genres?)\b/g],
51
+ ['audio', /\b(audio|podcasts?|recordings?|transcripts?|transcri\w+)\b/g],
52
+ ['news', /\b(news|newsrooms?|headlines|journalism|the desk)\b/g],
53
+ ['alerts', /\b(reminders?|alerts?|notifications?|get told|never miss|before it drops)\b/g],
54
+ ['jobs', /\b(jobs?|hiring|freelanc\w*|gigs?|careers?)\b/g],
55
+ ['health', /\b(health|telehealth|medical|patients?|clinics?|prescriptions?|labs)\b/g],
56
+ ['food', /\b(recipes?|cookbooks?|food|cooking|ingredients)\b/g],
57
+ ['cannabis', /\b(cannabis|dispensar(y|ies)|weed)\b/g],
58
+ ['directory', /\b(directory|directories|listings?|search engine|compared)\b/g],
59
+ ['data', /\b(database|datasets?|structured data|feeds?|json|real-time public data)\b/g],
60
+ ['marketing', /\b(ads|advertis\w*|promot\w*|seo|marketing|launch sites|submissions?)\b/g],
61
+ ['automation', /\b(automat\w*|workflows?|deterministic)\b/g],
62
+ ['open source', /\b(open-source|open source|foss|self-host\w*)\b/g],
63
+ ['security', /\b(security|malware|encrypt\w*|vulnerab\w*|privacy|audit)\b/g],
64
+ ['photos', /\b(photos?|images?|pictures?|backgrounds?|camera)\b/g],
65
+ ['terminal', /\b(terminal|ssh|tmux|bbs|bulletin board)\b/g],
66
+ ['no-code', /\b(no-code|your own words|describe what you want)\b/g],
67
+ ['open standards', /\b(standards|schemas?|specs?|conventions|primitives)\b/g],
68
+ ['shopping', /\b(shop|buy|marketplace|e-?commerce|store)\b/g],
69
+ ['art', /\b(tattoos?|artistry|artist)\b/g],
70
+ ['automotive', /\b(auto detailing|cars?|automotive)\b/g],
71
+ ['cigars', /\b(cigars?|spliffs?|tobacco)\b/g],
72
+ ['events', /\b(events?|meetups?|herf|every other sunday)\b/g],
73
+ ['productivity', /\b(productivity|organi[sz]e|searchable|right folder)\b/g],
74
+ ]
75
+
76
+ /** Up to `max` topics the text is about, most mentioned first. */
77
+ export function inferTags(text: string, max = 4): string[] {
78
+ const t = ` ${text.toLowerCase()} `
79
+ return TOPICS.map(([tag, re], i) => ({ tag, i, n: t.match(re)?.length ?? 0 }))
80
+ .filter((s) => s.n > 0)
81
+ // "never miss a game" is a match, not a video game.
82
+ .filter((s, _, all) => s.tag !== 'game' || !all.some((o) => o.tag === 'sports'))
83
+ .sort((a, b) => b.n - a.n || a.i - b.i)
84
+ .slice(0, max)
85
+ .map((s) => s.tag)
86
+ }
87
+
88
+ /** What a listing is tagged with: its declared tags, else topics from its name, tagline and description. */
89
+ export function tagsFor(declared: string[], text: { name?: string; tagline?: string; description?: string }): string[] {
90
+ const own = cleanTags(declared)
91
+ return own.length ? own : inferTags([text.name, text.tagline, text.description].filter(Boolean).join('. '))
92
+ }
package/src/tui.ts CHANGED
@@ -2,13 +2,13 @@
2
2
  * `builtindie` with no arguments: the board in the terminal.
3
3
  *
4
4
  * ←/→ switch board (today, week, month, all, new), ↑/↓ pick, Enter shows it,
5
- * v upvotes, l launches a URL you type, q quits. Glyphs come from the OpenIcon
5
+ * v upvotes, l launches a URL you type, k shows the karma leaderboard, q quits. Glyphs come from the OpenIcon
6
6
  * set: Nerd Font, Unicode or ASCII, whichever this terminal can draw.
7
7
  */
8
8
  const UP = glyph('arrow-up') || '^'
9
9
  const OK = glyph('check') || '+'
10
10
  import { createApp } from '@profullstack/hqtui'
11
- import { BuiltIndieClient, type ApiProduct } from './client.js'
11
+ import { BuiltIndieClient, type ApiProduct, type BoardRows } from './client.js'
12
12
  import { glyph } from './icons.js'
13
13
 
14
14
  export const WINDOWS = ['today', 'week', 'month', 'all', 'new'] as const
@@ -21,12 +21,14 @@ export type TuiState = {
21
21
  typing: string | null
22
22
  busy: string | null
23
23
  flash: string | null
24
+ /** The karma leaderboard, while it is open (k). */
25
+ board: BoardRows | null
24
26
  }
25
27
 
26
- export const initialState = (): TuiState => ({ window: 0, products: [], at: 0, open: null, typing: null, busy: null, flash: null })
28
+ export const initialState = (): TuiState => ({ window: 0, products: [], at: 0, open: null, typing: null, busy: null, flash: null, board: null })
27
29
 
28
30
  /** Pure key handling, testable without a terminal. */
29
- export function onKey(s: TuiState, key: { name: string; key: string; char?: string }): 'load' | 'vote' | 'launch' | 'quit' | null {
31
+ export function onKey(s: TuiState, key: { name: string; key: string; char?: string }): 'load' | 'vote' | 'launch' | 'karma' | 'quit' | null {
30
32
  if (key.key === 'ctrl+c') return 'quit'
31
33
  if (s.typing !== null) {
32
34
  if (key.name === 'escape') s.typing = null
@@ -35,8 +37,14 @@ export function onKey(s: TuiState, key: { name: string; key: string; char?: stri
35
37
  else if (key.char && key.char.length === 1 && !key.key.startsWith('ctrl+')) s.typing += key.char
36
38
  return null
37
39
  }
40
+ if (s.board) {
41
+ if (key.name === 'escape' || key.char === 'k') s.board = null
42
+ else if (key.char === 'q') return 'quit'
43
+ return null
44
+ }
38
45
  if (key.name === 'escape') return s.open ? ((s.open = null), null) : 'quit'
39
46
  if (key.char === 'q') return 'quit'
47
+ if (key.char === 'k') return 'karma'
40
48
  if (key.char === 'l') return (s.typing = ''), null
41
49
  if (key.char === 'v') return s.products[s.at] ? 'vote' : null
42
50
  if (key.name === 'right' || key.name === 'left') {
@@ -72,6 +80,12 @@ export async function runTui(client = new BuiltIndieClient()): Promise<void> {
72
80
  const action = onKey(s, event)
73
81
  if (action === 'quit') app.quit()
74
82
  else if (action === 'load') void load()
83
+ else if (action === 'karma') {
84
+ void run('Loading the karma board…', async () => {
85
+ const week = await client.leaderboard('karma', 'week', 25)
86
+ s.board = week.rows.length ? week : await client.leaderboard('karma', 'all', 25)
87
+ })
88
+ }
75
89
  else if (action === 'vote') {
76
90
  const p = s.products[s.at]!
77
91
  void run('Voting…', async () => {
@@ -98,7 +112,14 @@ export async function runTui(client = new BuiltIndieClient()): Promise<void> {
98
112
  h.text(WINDOWS.map((w, i) => (i === s.window ? `[${w}]` : w)).join(' '), { fg: theme.text })
99
113
  h.text(`${client.base.replace(/^https?:\/\//, '')} `, { fg: theme.muted, align: 'right' })
100
114
  })
101
- if (s.open) {
115
+ if (s.board) {
116
+ const r = s.board
117
+ ui.panel({ title: `Top karma (${r.period === 'all' ? 'all time' : 'this week'})` }, (b: any) => {
118
+ if (!r.rows.length) b.text('Nobody yet. Sign in at built.indie, then upvote, review, comment and share to earn karma.', { fg: theme.muted })
119
+ for (const x of r.rows) b.text(`${String(x.rank ?? '').padStart(3)}. ${String(x.value).padStart(5)} ${x.name} ${x.badges.map((g) => g.emoji).join('')}`, { fg: x.rank === 1 ? theme.accent : theme.text, bold: x.rank === 1 })
120
+ b.text(r.web, { fg: theme.muted })
121
+ })
122
+ } else if (s.open) {
102
123
  const p = s.open
103
124
  ui.panel({ title: `${p.name} ${UP} ${p.votes}` }, (b: any) => {
104
125
  b.text(p.tagline, { fg: theme.accent, bold: true })
@@ -108,6 +129,7 @@ export async function runTui(client = new BuiltIndieClient()): Promise<void> {
108
129
  if (p.tags.length) b.text(`tags ${p.tags.join(', ')}`, { fg: theme.muted })
109
130
  if (p.videos.length) b.text(`video ${p.videos[0]!.url}`, { fg: theme.muted })
110
131
  if (p.maker) b.text(`maker ${p.maker.name}${p.maker.verified ? ' ✓' : ''}`, { fg: theme.muted })
132
+ if (p.rating != null) b.text(`rated ★ ${p.rating} from ${p.reviews}`, { fg: theme.muted })
111
133
  })
112
134
  } else {
113
135
  ui.panel({ title: s.busy ?? `${WINDOWS[s.window]} (${s.products.length})` }, (b: any) => {
@@ -120,7 +142,7 @@ export async function runTui(client = new BuiltIndieClient()): Promise<void> {
120
142
  }
121
143
  if (s.typing !== null) ui.panel({ title: 'Launch a URL', size: 3 }, (b: any) => b.text(`${s.typing}▏`, { fg: theme.accent }))
122
144
  else if (s.flash) ui.panel({ title: 'built.indie', size: 3 }, (b: any) => b.text(s.flash, { fg: theme.accent }))
123
- ui.statusBar({ items: s.typing !== null ? [{ key: 'Enter', label: 'launch' }, { key: 'Esc', label: 'cancel' }] : [{ key: '←→', label: 'board' }, { key: '↑↓', label: 'pick' }, { key: 'Enter', label: 'open' }, { key: 'v', label: 'upvote' }, { key: 'l', label: 'launch' }, { key: 'q', label: 'quit' }], right: [{ label: 'built.indie' }] })
145
+ ui.statusBar({ items: s.typing !== null ? [{ key: 'Enter', label: 'launch' }, { key: 'Esc', label: 'cancel' }] : [{ key: '←→', label: 'board' }, { key: '↑↓', label: 'pick' }, { key: 'Enter', label: 'open' }, { key: 'v', label: 'upvote' }, { key: 'l', label: 'launch' }, { key: 'k', label: 'karma' }, { key: 'q', label: 'quit' }], right: [{ label: 'built.indie' }] })
124
146
  })
125
147
  void load()
126
148
  await app.start()
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.1.3'
1
+ export const VERSION = '0.6.0'