@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/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 +238 -74
- package/src/tags.ts +92 -0
- package/src/tui.ts +28 -6
- package/src/version.ts +1 -1
package/src/cli.ts
CHANGED
|
@@ -7,9 +7,20 @@
|
|
|
7
7
|
* builtindie show <slug>
|
|
8
8
|
* builtindie vote <slug>
|
|
9
9
|
* builtindie preview <url> what we would read from a site, nothing saved
|
|
10
|
-
* builtindie
|
|
11
|
-
* builtindie
|
|
10
|
+
* builtindie tags [tag] every tag, or the launches with one
|
|
11
|
+
* builtindie search [words] [--category k] [--tag t] [--has h] [--sort top|new|name]
|
|
12
|
+
* builtindie categories every category and tag, with counts
|
|
13
|
+
* builtindie claim <slug> prove you own the domain (the badge or DNS TXT), BUILTINDIE_KEY
|
|
12
14
|
* builtindie me your launches, BUILTINDIE_KEY
|
|
15
|
+
* builtindie invite your invite link and free Featured credits, BUILTINDIE_KEY
|
|
16
|
+
* builtindie credit <slug> spend a credit: feature a listing you own, BUILTINDIE_KEY
|
|
17
|
+
* builtindie feature <slug> pay to feature it: a CoinPay checkout link (any coin or PayPal), BUILTINDIE_KEY
|
|
18
|
+
* builtindie karma your karma, history, next free Featured week, BUILTINDIE_KEY
|
|
19
|
+
* builtindie leaderboard [karma|invites] [--period week|month|all]
|
|
20
|
+
* builtindie comment <slug> "text" comment on a launch (--reply <id>), BUILTINDIE_KEY
|
|
21
|
+
* builtindie comments <slug> read the comments
|
|
22
|
+
* builtindie review <slug> <1-5> "text" review a launch, BUILTINDIE_KEY
|
|
23
|
+
* builtindie reviews <slug> read the reviews
|
|
13
24
|
* builtindie edit <slug> --tagline … (BUILTINDIE_KEY)
|
|
14
25
|
* builtindie refetch <slug> (BUILTINDIE_KEY)
|
|
15
26
|
* builtindie mcp MCP server on stdio
|
|
@@ -27,10 +38,25 @@ export const HELP = `builtindie ${VERSION}: launch your indie product with one U
|
|
|
27
38
|
builtindie show <slug>
|
|
28
39
|
builtindie vote <slug>
|
|
29
40
|
builtindie preview <url> what we read from a site; nothing is saved
|
|
30
|
-
builtindie
|
|
31
|
-
builtindie
|
|
32
|
-
|
|
41
|
+
builtindie tags [tag] every tag with its count, or the launches tagged <tag>
|
|
42
|
+
builtindie search [words] search; narrow with --category developer-tools --tag ai
|
|
43
|
+
[--has verified,video] [--stack Bun] [--maker "Name"] [--launched today|week|month|year]
|
|
44
|
+
[--sort relevance|top|new|name] [--page N] [--facets]
|
|
45
|
+
builtindie categories every category and tag, with how many launches each
|
|
46
|
+
builtindie claim <slug> [--md] your badge for the homepage (or the DNS TXT record) that proves
|
|
47
|
+
it is yours, then checks both; --md prints the steps as Markdown
|
|
48
|
+
to paste to an AI with DNS or site access
|
|
33
49
|
builtindie me your launches
|
|
50
|
+
builtindie invite your invite link; each maker it brings in who launches = a free Featured week
|
|
51
|
+
builtindie credit <slug> spend a credit to feature a listing you own
|
|
52
|
+
builtindie feature <slug> [--email you@x] pay to feature it: prints a CoinPay checkout link (any coin or PayPal)
|
|
53
|
+
builtindie karma your karma, history and how far to the next free Featured week
|
|
54
|
+
builtindie leaderboard [karma|invites] [--period week|month|all]
|
|
55
|
+
builtindie comment <slug> "text" comment on a launch (+2 karma); --reply <comment id> to answer one
|
|
56
|
+
builtindie comments <slug> read a launch's comments
|
|
57
|
+
builtindie uncomment <id> delete your comment (its karma goes back)
|
|
58
|
+
builtindie review <slug> <1-5> "text" stars and words (+5 karma for 30+ characters)
|
|
59
|
+
builtindie reviews <slug> read a launch's reviews
|
|
34
60
|
builtindie edit <slug> [--name --tagline --description --tags a,b --video URL]
|
|
35
61
|
builtindie refetch <slug> read the homepage again
|
|
36
62
|
builtindie publish <slug> put a draft live
|
|
@@ -54,7 +80,7 @@ export function parseArgs(argv: string[]): { words: string[]; flags: Record<stri
|
|
|
54
80
|
if (a.startsWith('--')) {
|
|
55
81
|
const [k, v] = a.slice(2).split(/=(.*)/s) as [string, string | undefined]
|
|
56
82
|
if (v !== undefined) flags[k] = v
|
|
57
|
-
else if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') && !['json', 'draft', 'md'].includes(k)) flags[k] = argv[++i]!
|
|
83
|
+
else if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') && !['json', 'draft', 'md', 'facets'].includes(k)) flags[k] = argv[++i]!
|
|
58
84
|
else flags[k] = true
|
|
59
85
|
} else words.push(a)
|
|
60
86
|
}
|
|
@@ -135,8 +161,26 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
|
|
|
135
161
|
return 0
|
|
136
162
|
}
|
|
137
163
|
case 'search': {
|
|
138
|
-
const
|
|
139
|
-
|
|
164
|
+
const many = (k: string) => (typeof flags[k] === 'string' ? String(flags[k]).split(',').map((v) => v.trim()).filter(Boolean) : undefined)
|
|
165
|
+
const r = await client.search(rest.join(' '), { category: many('category'), tag: many('tag'), has: many('has'), stack: many('stack'), maker: typeof flags.maker === 'string' ? [flags.maker] : undefined, launched: str('launched'), sort: str('sort'), page: Number(str('page') ?? 1) || 1, limit: Number(str('limit') ?? 30) || 30 })
|
|
166
|
+
const facets = flags.facets && r.facets ? `\n\n${Object.entries(r.facets).filter(([, v]) => v.length).map(([k, v]) => `${k}: ${v.map((f) => `${f.value} (${f.count})`).join(', ')}`).join('\n')}` : ''
|
|
167
|
+
show(r, `${r.products.map((p) => line(p)).join('\n') || 'Nothing matches.'}\n\n${r.total} match${r.total === 1 ? '' : 'es'}${r.pages > 1 ? `, page ${r.page} of ${r.pages}` : ''} · ${r.web}${facets}`)
|
|
168
|
+
return 0
|
|
169
|
+
}
|
|
170
|
+
case 'categories': {
|
|
171
|
+
const r = await client.categories()
|
|
172
|
+
show(r, `${r.categories.map((c) => `${String(c.count).padStart(4)} ${c.label} (${c.key})`).join('\n')}\n\nTags: ${r.tags.map((t) => `${t.tag} (${t.count})`).join(', ')}`)
|
|
173
|
+
return 0
|
|
174
|
+
}
|
|
175
|
+
case 'tags':
|
|
176
|
+
case 'tag': {
|
|
177
|
+
if (rest.length) {
|
|
178
|
+
const r = await client.tagged(rest.join(' '), Number(str('limit') ?? 30))
|
|
179
|
+
show(r, r.products.map((p, i) => line(p, i + 1)).join('\n') || `Nothing tagged ${r.tag} yet.`)
|
|
180
|
+
} else {
|
|
181
|
+
const r = await client.tags()
|
|
182
|
+
show(r, r.tags.map((t) => `${String(t.count).padStart(4)} ${t.tag}`).join('\n') || 'No tags yet.')
|
|
183
|
+
}
|
|
140
184
|
return 0
|
|
141
185
|
}
|
|
142
186
|
case 'show': {
|
|
@@ -179,8 +223,8 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
|
|
|
179
223
|
return 0
|
|
180
224
|
}
|
|
181
225
|
const r = await client.claim(slug)
|
|
182
|
-
if (r.claimed) return show(r, `${slug} is yours
|
|
183
|
-
show(r, `
|
|
226
|
+
if (r.claimed) return show(r, `${slug} is yours ✓${r.method === 'badge' ? ' (we found your badge)' : r.method === 'dns' ? ' (we found your DNS record)' : ''}. Edit it: builtindie edit ${slug} --tagline "…"`), 0
|
|
227
|
+
show(r, `Prove it either way, then run builtindie claim ${slug} again:\n\n1. Easiest: put this badge on your homepage and deploy (its id is yours; it also earns the followed link and the ✓):\n\n ${r.badge}\n\n2. Or add this DNS record (wait a minute for it to show):\n\n type TXT\n name ${r.record.name}\n value ${r.record.value}\n\n(not found yet at ${r.looked.join(', ')})`)
|
|
184
228
|
return 1
|
|
185
229
|
}
|
|
186
230
|
case 'me': {
|
|
@@ -188,6 +232,62 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
|
|
|
188
232
|
show(r, `${r.email}\n${r.products.map((p) => `${line(p)} ${p.owner ? 'claimed ✓' : p.status === 'draft' ? 'draft' : 'not claimed'}`).join('\n') || 'No launches yet.'}`)
|
|
189
233
|
return 0
|
|
190
234
|
}
|
|
235
|
+
case 'invite':
|
|
236
|
+
case 'invites': {
|
|
237
|
+
const r = await client.invites()
|
|
238
|
+
show(r, `Your invite link: ${r.link}\n${r.invited} joined · ${r.launched} launched · ${r.credits} free Featured week${r.credits === 1 ? '' : 's'} to use (${r.feature_days} days each)${r.credits ? '\nUse one: builtindie credit <slug>' : ''}`)
|
|
239
|
+
return 0
|
|
240
|
+
}
|
|
241
|
+
case 'credit': {
|
|
242
|
+
const r = await client.credit(rest[0] ?? '')
|
|
243
|
+
show(r, `${rest[0]} is Featured until ${r.featured_until.slice(0, 10)} ★ (${r.credits} credit${r.credits === 1 ? '' : 's'} left)`)
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
246
|
+
case 'feature': {
|
|
247
|
+
const r = await client.feature(rest[0] ?? '', str('email'))
|
|
248
|
+
show(r, `Pay $${r.usd} for ${r.days} days Featured (${r.methods.join(', ')}):\n${r.checkout_url}`)
|
|
249
|
+
return 0
|
|
250
|
+
}
|
|
251
|
+
case 'karma': {
|
|
252
|
+
const r = await client.karma()
|
|
253
|
+
show(r, `${r.total} karma (${r.week >= 0 ? '+' : ''}${r.week} this week) · ${r.next_credit_in} to your next free Featured week · ${r.credits.earned} earned from karma\n\n${r.events.map((e) => `${(e.points > 0 ? '+' : '') + e.points}`.padStart(4) + ` ${e.label} ${e.at.slice(0, 10)}`).join('\n') || 'No karma yet. How to earn:\n' + r.how_to_earn.map((h) => ` +${h.points} ${h.action}: ${h.rule}`).join('\n')}`)
|
|
254
|
+
return 0
|
|
255
|
+
}
|
|
256
|
+
case 'leaderboard':
|
|
257
|
+
case 'board': {
|
|
258
|
+
const r = await client.leaderboard(rest[0] ?? 'karma', str('period') ?? 'week', Number(str('limit') ?? 25))
|
|
259
|
+
show(r, `${r.board === 'invites' ? 'Top inviters' : 'Top karma'} (${r.period})\n${r.rows.map((x) => `${String(x.rank ?? '').padStart(3)}. ${String(x.value).padStart(5)} ${x.name} ${x.badges.map((b) => b.emoji).join('')}`).join('\n') || 'Nobody yet.'}\n${r.web}`)
|
|
260
|
+
return 0
|
|
261
|
+
}
|
|
262
|
+
case 'comment': {
|
|
263
|
+
const [slug, ...text] = rest
|
|
264
|
+
if (!slug || !text.length) throw new Error('builtindie comment <slug> "text" [--reply <comment id>]')
|
|
265
|
+
const r = await client.comment(slug, text.join(' '), str('reply'))
|
|
266
|
+
show(r, `Posted: ${r.comment.url} (you have ${r.karma} karma)`)
|
|
267
|
+
return 0
|
|
268
|
+
}
|
|
269
|
+
case 'comments': {
|
|
270
|
+
const r = await client.comments(rest[0] ?? '')
|
|
271
|
+
show(r, r.comments.map((x) => `${x.parent ? ' ↳ ' : ''}${x.deleted ? '[deleted]' : `${x.author} (${x.karma} karma)${x.maker ? ' [maker]' : ''} ${x.at.slice(0, 10)} #${x.id}\n${x.parent ? ' ' : ' '}${x.body}`}`).join('\n') || 'No comments yet.')
|
|
272
|
+
return 0
|
|
273
|
+
}
|
|
274
|
+
case 'uncomment': {
|
|
275
|
+
const r = await client.deleteComment(rest[0] ?? '')
|
|
276
|
+
show(r, 'Deleted.')
|
|
277
|
+
return 0
|
|
278
|
+
}
|
|
279
|
+
case 'review': {
|
|
280
|
+
const [slug, stars, ...text] = rest
|
|
281
|
+
if (!slug || !stars) throw new Error('builtindie review <slug> <1-5> "text"')
|
|
282
|
+
const r = await client.review(slug, Number(stars), text.join(' '))
|
|
283
|
+
show(r, `${'★'.repeat(r.review.rating)} saved. ${slug} is rated ${r.product.rating} from ${r.product.count}. You have ${r.karma} karma.`)
|
|
284
|
+
return 0
|
|
285
|
+
}
|
|
286
|
+
case 'reviews': {
|
|
287
|
+
const r = await client.reviews(rest[0] ?? '')
|
|
288
|
+
show(r, `${r.rating === null ? 'No reviews yet.' : `★ ${r.rating} from ${r.count}`}\n${r.reviews.map((x) => `${'★'.repeat(x.rating)} ${x.author} (${x.karma} karma) ${x.at.slice(0, 10)}\n ${x.body}`).join('\n')}`)
|
|
289
|
+
return 0
|
|
290
|
+
}
|
|
191
291
|
case 'verify': {
|
|
192
292
|
const r = await client.verify(rest[0] ?? '')
|
|
193
293
|
show(r, r.verified ? 'Verified ✓: we found the badge.' : 'Not found yet: add the badge HTML to your homepage and deploy.')
|
package/src/client.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* BUILTINDIE_URL points it at another instance; BUILTINDIE_KEY (bi_…) is your
|
|
4
4
|
* account's API key (built.indie/me), for claiming and editing your products.
|
|
5
5
|
*/
|
|
6
|
+
export type SearchFilters = Partial<{ category: string[]; tag: string[]; has: string[]; stack: string[]; maker: string[]; launched: string; sort: string; page: number; limit: number }>
|
|
7
|
+
export type FacetValue = { value: string; label: string; count: number }
|
|
8
|
+
export type SearchResult = { total: number; page: number; pages: number; products: (ApiProduct & { categories: string[] })[]; facets?: Record<string, FacetValue[]>; web: string }
|
|
9
|
+
|
|
6
10
|
export type ApiProduct = {
|
|
7
11
|
slug: string
|
|
8
12
|
name: string
|
|
@@ -12,6 +16,8 @@ export type ApiProduct = {
|
|
|
12
16
|
domain: string
|
|
13
17
|
status: string
|
|
14
18
|
votes: number
|
|
19
|
+
reviews?: number
|
|
20
|
+
rating?: number | null
|
|
15
21
|
tags: string[]
|
|
16
22
|
logo: string | null
|
|
17
23
|
card_image: string | null
|
|
@@ -32,6 +38,13 @@ export type ApiProduct = {
|
|
|
32
38
|
award?: { day: string; rank: number } | null
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
export type Invites = { link: string; code: string; invited: number; launched: number; credits: number; karma_credits?: number; used: number; feature_days: number }
|
|
42
|
+
|
|
43
|
+
export type KarmaInfo = { total: number; week: number; per_credit: number; next_credit_in: number; credits: { earned: number; used: number }; events: { action: string; label: string; ref: string; points: number; at: string }[]; how_to_earn: { action: string; points: number; rule: string }[]; leaderboard: string }
|
|
44
|
+
export type BoardRows = { board: string; period: string; total: number; rows: { rank: number | null; id: string; name: string; value: number; badges: { emoji: string; label: string }[]; url: string }[]; web: string }
|
|
45
|
+
export type ApiComment = { id: string; parent: string | null; body: string | null; deleted: boolean; author: string | null; karma: number; maker: boolean; at: string; url: string; mine?: boolean }
|
|
46
|
+
export type ApiReview = { rating: number; body: string; author: string; karma: number; at: string }
|
|
47
|
+
|
|
35
48
|
export type Launched = { product: ApiProduct; claim_url?: string; existing?: boolean }
|
|
36
49
|
export type ClaimRecord = { type: string; name: string; value: string }
|
|
37
50
|
|
|
@@ -71,8 +84,27 @@ export class BuiltIndieClient {
|
|
|
71
84
|
return this.call('GET', `/api/v1/products?window=${encodeURIComponent(window)}&limit=${limit}`)
|
|
72
85
|
}
|
|
73
86
|
|
|
74
|
-
|
|
75
|
-
return this.call('GET',
|
|
87
|
+
tags(): Promise<{ tags: { tag: string; count: number }[] }> {
|
|
88
|
+
return this.call('GET', '/api/v1/tags')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
tagged(tag: string, limit = 30): Promise<{ tag: string; products: ApiProduct[] }> {
|
|
92
|
+
return this.call('GET', `/api/v1/products?tag=${encodeURIComponent(tag)}&limit=${limit}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Search with filters; the answer carries the facets (counts per category, tag, stack…) to narrow further. */
|
|
96
|
+
search(q: string, filters: SearchFilters = {}): Promise<SearchResult> {
|
|
97
|
+
const p = new URLSearchParams()
|
|
98
|
+
if (q) p.set('q', q)
|
|
99
|
+
for (const k of ['category', 'tag', 'has', 'stack', 'maker'] as const) for (const v of filters[k] ?? []) p.append(k, v)
|
|
100
|
+
for (const k of ['launched', 'sort'] as const) if (filters[k]) p.set(k, filters[k]!)
|
|
101
|
+
if (filters.page) p.set('page', String(filters.page))
|
|
102
|
+
if (filters.limit) p.set('limit', String(filters.limit))
|
|
103
|
+
return this.call('GET', `/api/v1/search?${p}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
categories(): Promise<{ categories: { key: string; label: string; count: number; page: string }[]; tags: { tag: string; count: number; page: string }[] }> {
|
|
107
|
+
return this.call('GET', '/api/v1/categories')
|
|
76
108
|
}
|
|
77
109
|
|
|
78
110
|
product(slug: string): Promise<ApiProduct> {
|
|
@@ -89,18 +121,33 @@ export class BuiltIndieClient {
|
|
|
89
121
|
return k
|
|
90
122
|
}
|
|
91
123
|
|
|
92
|
-
claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord; markdown: string }> {
|
|
124
|
+
claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord; badge: string; markdown: string }> {
|
|
93
125
|
return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/claim`, undefined, this.needKey())
|
|
94
126
|
}
|
|
95
127
|
|
|
96
|
-
claim(slug: string): Promise<{ claimed: boolean; looked: string[]; record: ClaimRecord; markdown?: string }> {
|
|
128
|
+
claim(slug: string): Promise<{ claimed: boolean; method?: 'dns' | 'badge'; looked: string[]; record: ClaimRecord; badge: string; markdown?: string }> {
|
|
97
129
|
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/claim`, {}, this.needKey())
|
|
98
130
|
}
|
|
99
131
|
|
|
100
|
-
me(): Promise<{ email: string; products: (ApiProduct & { owner: boolean })[] }> {
|
|
132
|
+
me(): Promise<{ email: string; products: (ApiProduct & { owner: boolean })[]; invite: Invites }> {
|
|
101
133
|
return this.call('GET', '/api/v1/me', undefined, this.needKey())
|
|
102
134
|
}
|
|
103
135
|
|
|
136
|
+
/** Your invite link and credits: each new member it brings in who launches earns a free Featured week. */
|
|
137
|
+
invites(): Promise<Invites> {
|
|
138
|
+
return this.call('GET', '/api/v1/invites', undefined, this.needKey())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Spend one invite credit to feature a listing you own. */
|
|
142
|
+
credit(slug: string): Promise<{ featured_until: string; credits: number }> {
|
|
143
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/credit`, {}, this.needKey())
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Pay to feature a listing you own: opens a CoinPay checkout session (any coin or PayPal); send the buyer to checkout_url. */
|
|
147
|
+
feature(slug: string, email?: string): Promise<{ checkout_url: string; checkout_session_id: string; methods: string[]; payment: string; usd: number; days: number }> {
|
|
148
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/feature`, email ? { email } : {}, this.needKey())
|
|
149
|
+
}
|
|
150
|
+
|
|
104
151
|
edit(slug: string, patch: Record<string, unknown>, key?: string): Promise<ApiProduct> {
|
|
105
152
|
return this.call('PATCH', `/api/v1/products/${encodeURIComponent(slug)}`, patch, this.needKey(key))
|
|
106
153
|
}
|
|
@@ -109,6 +156,35 @@ export class BuiltIndieClient {
|
|
|
109
156
|
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/${action}`, {}, this.needKey(key))
|
|
110
157
|
}
|
|
111
158
|
|
|
159
|
+
/** Your karma: total, this week, history, and how far to the next free Featured week. */
|
|
160
|
+
karma(): Promise<KarmaInfo> {
|
|
161
|
+
return this.call('GET', '/api/v1/karma', undefined, this.needKey())
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
leaderboard(board = 'karma', period = 'week', limit = 25): Promise<BoardRows> {
|
|
165
|
+
return this.call('GET', `/api/v1/leaderboard?board=${encodeURIComponent(board)}&period=${encodeURIComponent(period)}&limit=${limit}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
comments(slug: string): Promise<{ slug: string; comments: ApiComment[] }> {
|
|
169
|
+
return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/comments`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
comment(slug: string, body: string, parent?: string): Promise<{ comment: ApiComment; karma: number }> {
|
|
173
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/comments`, { body, ...(parent ? { parent } : {}) }, this.needKey())
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
deleteComment(id: string): Promise<{ deleted: boolean }> {
|
|
177
|
+
return this.call('DELETE', `/api/v1/comments/${encodeURIComponent(id)}`, undefined, this.needKey())
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
reviews(slug: string): Promise<{ slug: string; rating: number | null; count: number; reviews: ApiReview[] }> {
|
|
181
|
+
return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/reviews`)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
review(slug: string, rating: number, body: string): Promise<{ review: { rating: number; body: string }; product: { rating: number | null; count: number }; karma: number }> {
|
|
185
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/reviews`, { rating, body }, this.needKey())
|
|
186
|
+
}
|
|
187
|
+
|
|
112
188
|
verify(slug: string): Promise<{ verified: boolean }> {
|
|
113
189
|
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/verify`, {})
|
|
114
190
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign in with CoinPay: the house identity provider (OIDC at coinpayportal.com,
|
|
3
|
+
* authorization code + PKCE S256), through the shared @profullstack/stack/coinpay
|
|
4
|
+
* helpers. Same flow as pwamart.com, so one CoinPay account is a member of both.
|
|
5
|
+
*
|
|
6
|
+
* Linking is by CoinPay `sub` only (user_identities), never by email: CoinPay
|
|
7
|
+
* does not verify emails (email_verified is always false). So:
|
|
8
|
+
* - a returning sub signs in as the member it is linked to;
|
|
9
|
+
* - a signed-in member who connects (from /me) gets the sub attached;
|
|
10
|
+
* - anyone else gets a new member, with their CoinPay name as the display name
|
|
11
|
+
* and a placeholder address (…@coinpay.invalid): the CoinPay email is never
|
|
12
|
+
* used as the account's email, or it would let a CoinPay account sign in as
|
|
13
|
+
* whoever owns that address here. An address that already has an account
|
|
14
|
+
* is refused: that person signs in by email and connects CoinPay from /me.
|
|
15
|
+
*
|
|
16
|
+
* The state and PKCE verifier ride in a short HttpOnly cookie scoped to
|
|
17
|
+
* /api/v1/coinpay. Off (buttons hidden, routes redirect) without a client id and secret.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
20
|
+
import { exchangeCoinPayCode, fetchCoinPayUserinfo, generateCoinPayPkcePair, getCoinPayAuthorizeUrl, validateCoinPayState } from '@profullstack/stack/coinpay'
|
|
21
|
+
import type { Context, Hono } from 'hono'
|
|
22
|
+
import { getCookie, setCookie } from 'hono/cookie'
|
|
23
|
+
import { safeNext, SESSION_COOKIE, type Auth } from './auth.js'
|
|
24
|
+
import type { Store, User } from './db.js'
|
|
25
|
+
|
|
26
|
+
export type CoinPayOAuth = { clientId: string; clientSecret: string; issuer: string; /** Tests pass a fake CoinPay. */ fetch?: typeof fetch }
|
|
27
|
+
|
|
28
|
+
export const PROVIDER = 'coinpay'
|
|
29
|
+
const COOKIE = 'bi_cp'
|
|
30
|
+
const PATH = '/api/v1/coinpay'
|
|
31
|
+
const SCOPES = 'openid profile email'
|
|
32
|
+
|
|
33
|
+
export function coinpayOAuthFromEnv(env: Record<string, string | undefined> = process.env): CoinPayOAuth | null {
|
|
34
|
+
const clientId = env.COINPAY_OAUTH_CLIENT_ID?.trim()
|
|
35
|
+
const clientSecret = env.COINPAY_OAUTH_CLIENT_SECRET?.trim()
|
|
36
|
+
return clientId && clientSecret ? { clientId, clientSecret, issuer: (env.COINPAY_OAUTH_ISSUER ?? 'https://coinpayportal.com').replace(/\/+$/, '') } : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The address a CoinPay-only member is kept under: unique, and never deliverable. */
|
|
40
|
+
export const placeholderEmail = (sub: string) => `cp-${createHash('sha256').update(sub).digest('hex').slice(0, 20)}@coinpay.invalid`
|
|
41
|
+
export const isPlaceholder = (email: string) => email.endsWith('@coinpay.invalid')
|
|
42
|
+
|
|
43
|
+
export function mountCoinPaySignin(
|
|
44
|
+
app: Hono<any>,
|
|
45
|
+
o: { store: Store; auth: Auth; origin: string; oauth: CoinPayOAuth | null; secure: (c: Context) => boolean; sessionUser: (c: Context) => User | null; onNewMember?: (c: Context, user: User) => void },
|
|
46
|
+
): void {
|
|
47
|
+
const redirectUri = `${o.origin}${PATH}/callback`
|
|
48
|
+
const cookie = (c: Context, value: string, maxAge: number) => setCookie(c, COOKIE, value, { httpOnly: true, secure: o.secure(c), sameSite: 'Lax', path: PATH, maxAge })
|
|
49
|
+
|
|
50
|
+
app.get(`${PATH}/login`, (c) => {
|
|
51
|
+
if (!o.oauth) return c.redirect('/login?error=coinpay-off')
|
|
52
|
+
const state = randomBytes(24).toString('base64url')
|
|
53
|
+
const { codeVerifier, codeChallenge } = generateCoinPayPkcePair()
|
|
54
|
+
const next = safeNext(c.req.query('next')) ?? '/me'
|
|
55
|
+
cookie(c, Buffer.from(JSON.stringify({ state, codeVerifier, next })).toString('base64url'), 600)
|
|
56
|
+
return c.redirect(getCoinPayAuthorizeUrl({ issuer: o.oauth.issuer, clientId: o.oauth.clientId, redirectUri, state, codeChallenge, scopes: SCOPES }))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
app.get(`${PATH}/callback`, async (c) => {
|
|
60
|
+
const fail = (why: string) => c.redirect(`/login?error=${encodeURIComponent(why)}`)
|
|
61
|
+
if (!o.oauth) return fail('coinpay-off')
|
|
62
|
+
let saved: { state?: string; codeVerifier?: string; next?: string } | null = null
|
|
63
|
+
try {
|
|
64
|
+
saved = JSON.parse(Buffer.from(getCookie(c, COOKIE) ?? '', 'base64url').toString())
|
|
65
|
+
} catch {}
|
|
66
|
+
cookie(c, '', 0)
|
|
67
|
+
if (!saved?.state || !saved.codeVerifier) return fail('coinpay-expired')
|
|
68
|
+
if (c.req.query('error')) return fail('coinpay-denied')
|
|
69
|
+
if (!validateCoinPayState(c.req.query('state'), saved.state)) return fail('coinpay-state')
|
|
70
|
+
const code = c.req.query('code')
|
|
71
|
+
if (!code) return fail('coinpay-failed')
|
|
72
|
+
|
|
73
|
+
let claims: { sub?: string; email?: string; name?: string }
|
|
74
|
+
try {
|
|
75
|
+
const tokens = await exchangeCoinPayCode({ issuer: o.oauth.issuer, code, redirectUri, clientId: o.oauth.clientId, clientSecret: o.oauth.clientSecret, codeVerifier: saved.codeVerifier, fetch: o.oauth.fetch })
|
|
76
|
+
claims = await fetchCoinPayUserinfo({ issuer: o.oauth.issuer, accessToken: tokens.access_token, fetch: o.oauth.fetch })
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error(`[coinpay-signin] ${(e as Error).message}`)
|
|
79
|
+
return fail('coinpay-failed')
|
|
80
|
+
}
|
|
81
|
+
const sub = String(claims.sub ?? '')
|
|
82
|
+
if (!sub) return fail('coinpay-failed')
|
|
83
|
+
const email = String(claims.email ?? '').trim().toLowerCase() || null
|
|
84
|
+
const name = String(claims.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40) || null
|
|
85
|
+
const next = safeNext(saved.next) ?? '/me'
|
|
86
|
+
|
|
87
|
+
// Connecting CoinPay to the member who is signed in.
|
|
88
|
+
const current = o.sessionUser(c)
|
|
89
|
+
if (current) {
|
|
90
|
+
if (!o.store.linkIdentity({ provider: PROVIDER, subject: sub, userId: current.id, email, name })) return c.redirect('/me?coinpay=taken#signin')
|
|
91
|
+
return c.redirect('/me?coinpay=connected#signin')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let user = o.store.identityUser(PROVIDER, sub)
|
|
95
|
+
let created = false
|
|
96
|
+
if (!user) {
|
|
97
|
+
if (email && o.store.userByEmail(email)) return fail('coinpay-email-exists')
|
|
98
|
+
user = o.store.findOrCreateUser(placeholderEmail(sub))
|
|
99
|
+
created = true
|
|
100
|
+
o.store.linkIdentity({ provider: PROVIDER, subject: sub, userId: user.id, email, name })
|
|
101
|
+
if (name && !user.displayName) o.store.setDisplayName(user.id, name)
|
|
102
|
+
user = o.store.user(user.id)!
|
|
103
|
+
o.onNewMember?.(c, user)
|
|
104
|
+
}
|
|
105
|
+
setCookie(c, SESSION_COOKIE, o.auth.createSession(user.id), { httpOnly: true, secure: o.secure(c), sameSite: 'Lax', path: '/', maxAge: 90 * 86400 })
|
|
106
|
+
return c.redirect(created && next === '/me' ? '/me?coinpay=welcome' : next)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What /login says for each ?error= the flow can end with. */
|
|
111
|
+
export const COINPAY_ERRORS: Record<string, string> = {
|
|
112
|
+
'coinpay-off': 'Sign in with CoinPay is not set up on this server. Use your email instead.',
|
|
113
|
+
'coinpay-expired': 'That CoinPay sign-in took too long. Try again.',
|
|
114
|
+
'coinpay-denied': 'CoinPay sign-in was cancelled.',
|
|
115
|
+
'coinpay-state': 'That CoinPay sign-in did not match this browser. Try again.',
|
|
116
|
+
'coinpay-failed': 'CoinPay did not confirm who you are. Try again in a moment.',
|
|
117
|
+
'coinpay-email-exists': 'That email already has a built.indie account. Sign in with your email, then connect CoinPay on your page.',
|
|
118
|
+
}
|