@profullstack/builtindie 0.1.4 → 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,19 +33,20 @@ 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). */
@@ -51,38 +57,40 @@ export const CLAIM_PREFIX = 'built-indie-verify='
51
57
  * TXT records as public resolvers see them: every answer from 1.1.1.1 and 9.9.9.9, merged.
52
58
  * One Resolver per server on purpose: Bun 1.4's node:dns Resolver given two servers
53
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.
54
62
  */
55
- const PUBLIC_DNS = ['1.1.1.1', '9.9.9.9'].map((server) => {
56
- const r = new Resolver({ timeout: 4000, tries: 2 })
57
- r.setServers([server])
58
- return r
59
- })
60
- export async function publicTxt(name: string, resolvers: Pick<Resolver, 'resolveTxt'>[] = PUBLIC_DNS): Promise<string[][]> {
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[][]> {
61
70
  const answers = await Promise.all(resolvers.map((r) => r.resolveTxt(name).catch(() => [] as string[][])))
62
71
  return answers.flat()
63
72
  }
64
73
 
65
- export const CHAIN_LABELS: Record<string, string> = {
66
- USDC_POL: 'USDC on Polygon', USDC_SOL: 'USDC on Solana', USDC_ETH: 'USDC on Ethereum', USDT_POL: 'USDT on Polygon',
67
- BTC: 'Bitcoin', ETH: 'Ether', SOL: 'Solana', POL: 'Polygon', DOGE: 'Dogecoin', BCH: 'Bitcoin Cash', XRP: 'XRP', ADA: 'Cardano', BNB: 'BNB',
68
- }
69
- export const DEFAULT_CHAINS = Object.keys(CHAIN_LABELS)
70
- export const SETTLED = new Set(['paid', 'completed', 'confirmed', 'forwarded', 'succeeded', 'settled'])
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 }
71
76
 
72
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 }>
73
78
 
74
79
  const list = (v: string[] | string | undefined) => (Array.isArray(v) ? v : String(v ?? '').split(/[\n,]+/)).map((s) => s.trim()).filter(Boolean)
75
80
 
76
81
  export class BuiltIndie {
77
- 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
+ }
78
86
 
79
87
  /** A product page's public address. */
80
88
  link(p: Pick<Product, 'slug'>): string {
81
89
  return `${this.config.origin}/p/${p.slug}`
82
90
  }
83
91
 
84
- /** Read the URL and make a draft. A URL already live comes back as `existing` instead. */
85
- 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 }> {
86
94
  let url: URL
87
95
  try {
88
96
  url = normalizeUrl(input.url)
@@ -91,7 +99,7 @@ export class BuiltIndie {
91
99
  }
92
100
  const already = this.store.byUrl(url.href)
93
101
  if (already?.status === 'live') return { product: already, existing: true }
94
- 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.')
95
103
  const info = await this.read(url.href)
96
104
  const id = newId()
97
105
  const pictures = await this.pictures(id, info)
@@ -129,22 +137,88 @@ export class BuiltIndie {
129
137
  return createHmac('sha256', this.config.claimSecret ?? 'built.indie').update(`claim:${user.id}:${p.domain}`).digest('base64url').slice(0, 24)
130
138
  }
131
139
 
132
- /** Look for the claim record at _built-indie.<domain>, then on the domain itself (and its apex for www). */
133
- 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' }> {
134
149
  if (p.status === 'hidden') throw new InputError('This product was taken down.')
135
150
  if (p.ownerId === user.id) return { ok: true, looked: [] }
136
- const want = `${CLAIM_PREFIX}${this.claimToken(p, user)}`
151
+ const token = this.claimToken(p, user)
152
+ const want = `${CLAIM_PREFIX}${token}`
137
153
  const bare = p.domain.replace(/^www\./, '')
138
154
  const names = [...new Set([`${CLAIM_LABEL}.${bare}`, bare, p.domain])]
155
+ const looked = [...names, p.url]
139
156
  const lookup = this.config.resolveTxt ?? publicTxt
140
157
  for (const name of names) {
141
158
  const records = await lookup(name).catch(() => [] as string[][])
142
159
  if (records.some((chunks) => chunks.join('').trim() === want)) {
143
- this.store.claim(p.id, user.id)
144
- return { ok: true, looked: names }
160
+ this.claimed(p, user, false)
161
+ return { ok: true, looked, method: 'dns' }
145
162
  }
146
163
  }
147
- 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))
148
222
  }
149
223
 
150
224
  private async read(url: string): Promise<SiteInfo> {
@@ -175,7 +249,86 @@ export class BuiltIndie {
175
249
  publish(p: Product): Product {
176
250
  if (p.status === 'hidden') throw new InputError('This product was taken down.')
177
251
  if (!p.name.trim()) throw new InputError('Give it a name', 'name')
178
- 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)!
179
332
  }
180
333
 
181
334
  async refetch(p: Product): Promise<Product> {
@@ -193,7 +346,7 @@ export class BuiltIndie {
193
346
  }
194
347
  if (e.tagline !== undefined) patch.tagline = clip(e.tagline, 90)
195
348
  if (e.description !== undefined) patch.description = clip(e.description, 1200)
196
- 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))
197
350
  if (e.makerName !== undefined) patch.makerName = clip(e.makerName, 60)
198
351
  if (e.videos !== undefined) {
199
352
  const videos: Video[] = []
@@ -249,13 +402,12 @@ export class BuiltIndie {
249
402
  return this.store.update(p.id, { makerProfile: r.url.href, makerId: maker.id, makerVerified: verified, makerName: clip(name?.trim() || profile.name, 60) })!
250
403
  }
251
404
 
252
- /** 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? */
253
406
  async verify(p: Product): Promise<boolean> {
254
- const r = await safeFetch(p.url, { ...this.config.fetch, accept: 'text/html' }).catch(() => null)
255
- const html = r && r.status < 300 ? new TextDecoder().decode(r.body) : ''
256
- const ours = new URL(this.config.origin).hostname
257
- 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)
258
409
  this.store.setVerified(p.id, ok ? Date.now() : null)
410
+ if (ok && p.status === 'live') this.karma.badgeVerified(p)
259
411
  return ok
260
412
  }
261
413
 
@@ -319,61 +471,73 @@ export class BuiltIndie {
319
471
  )
320
472
  }
321
473
 
322
- /** Start a crypto payment to feature a product; returns where to send the buyer. */
323
- 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[] }> {
324
480
  const cp = this.config.coinpay
325
481
  if (!cp) throw new InputError('Featuring is not set up on this server yet.')
326
482
  if (p.status !== 'live') throw new InputError('Publish it first.')
327
- if (!this.config.chains.includes(chain)) throw new InputError('Pick a currency from the list', 'chain')
328
- const res = await fetcher(`${cp.baseUrl}/api/payments/create`, {
329
- method: 'POST',
330
- headers: { authorization: `Bearer ${cp.apiKey}`, 'content-type': 'application/json' },
331
- body: JSON.stringify({
332
- 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({
333
492
  amount: this.config.featureUsd,
334
493
  currency: 'USD',
335
- blockchain: chain,
336
- payment_method: 'crypto',
337
494
  description: `built.indie: feature ${p.name} for ${this.config.featureDays} days`,
338
- metadata: { product_id: p.id, product: 'feature' },
339
- success_url: `${this.link(p)}?featured=1`,
340
- cancel_url: this.link(p),
341
- }),
342
- signal: AbortSignal.timeout(20_000),
343
- })
344
- if (!res.ok) throw new Error(`CoinPay said ${res.status}: ${(await res.text()).slice(0, 200)}`)
345
- const body = (await res.json()) as any
346
- const payment = body?.payment ?? body
347
- const ref = String(payment?.id ?? payment?.payment_id ?? '')
348
- 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')
349
507
  // Written before the buyer leaves: a webhook can beat the redirect.
350
- this.store.addPayment({ ref, productId: p.id, amountUsd: this.config.featureUsd, chain, days: this.config.featureDays })
351
- 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 ?? [] }
352
510
  }
353
511
 
354
- /** CoinPay's webhook: `t=<unix>,v1=<hmac of "t.body">`; the payload is { data: { payment_id, status } }. */
355
- 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 } {
356
520
  const cp = this.config.coinpay
357
521
  if (!cp?.webhookSecret || !header) return { ok: false, status: 401 }
358
- const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=').map((s) => s.trim())))
359
- const t = Number(parts.t)
360
- if (!Number.isFinite(t) || !parts.v1 || Math.abs(nowSeconds - t) > 300) return { ok: false, status: 401 }
361
- const expected = Buffer.from(createHmac('sha256', cp.webhookSecret).update(`${parts.t}.${raw}`).digest('hex'), 'hex')
362
- const given = Buffer.from(String(parts.v1), 'hex')
363
- if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { ok: false, status: 401 }
364
- let payload: any
522
+ if (!verifyWebhook(raw, header, cp.webhookSecret, nowSeconds * 1000)) return { ok: false, status: 401 }
523
+ let payload: unknown
365
524
  try {
366
525
  payload = JSON.parse(raw)
367
526
  } catch {
368
527
  return { ok: false, status: 400 }
369
528
  }
370
- const data = payload?.data ?? payload
371
- const ref = String(data?.payment_id ?? data?.id ?? '')
372
- const status = String(data?.status ?? payload?.type?.replace(/^payment\./, '') ?? '').toLowerCase()
373
- if (!ref || !this.store.payment(ref)) return { ok: true }
374
- if (SETTLED.has(status)) this.store.settle(ref)
375
- else if (status) this.store.setPaymentStatus(ref, status)
376
- 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 }
377
541
  }
378
542
  }
379
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.4'
1
+ export const VERSION = '0.6.0'