@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/OpenStack.md +1 -1
- package/README.md +24 -4
- package/package.json +5 -2
- package/public/app.js +58 -0
- package/public/style.css +108 -11
- package/src/auth.ts +10 -6
- package/src/browse.ts +243 -0
- package/src/cli.ts +110 -10
- package/src/client.ts +81 -5
- package/src/coinpay-signin.ts +118 -0
- package/src/db.ts +398 -8
- package/src/fetch.ts +15 -7
- package/src/html.ts +490 -76
- package/src/karma.ts +179 -0
- package/src/leaderboard.ts +59 -0
- package/src/mcp.ts +52 -4
- package/src/pit-footer.ts +39 -0
- package/src/ring-footer.ts +53 -0
- package/src/ring.ts +5 -1
- package/src/server.ts +439 -42
- package/src/service.ts +246 -71
- package/src/tags.ts +92 -0
- package/src/tui.ts +28 -6
- package/src/version.ts +1 -1
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
122
|
-
|
|
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
|
|
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.
|
|
133
|
-
return { ok: true, looked:
|
|
160
|
+
this.claimed(p, user, false)
|
|
161
|
+
return { ok: true, looked, method: 'dns' }
|
|
134
162
|
}
|
|
135
163
|
}
|
|
136
|
-
|
|
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\?(?:[^#]*?(?:&|&))?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
|
-
|
|
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 =
|
|
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
|
|
244
|
-
const
|
|
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
|
-
/**
|
|
312
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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:
|
|
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
|
-
/**
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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.
|
|
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
|
+
export const VERSION = '0.6.0'
|