@profullstack/builtindie 0.1.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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 search <words>
11
- * builtindie claim <slug> prove you own the domain (DNS TXT), BUILTINDIE_KEY
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 search <words>
31
- builtindie claim <slug> [--md] the DNS TXT record that proves it is yours, then checks it;
32
- --md prints the steps as Markdown to paste to an AI with DNS access
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 r = await client.search(rest.join(' '))
139
- show(r, r.products.map((p) => line(p)).join('\n') || 'Nothing matches.')
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 ✓. Edit it: builtindie edit ${slug} --tagline "…"`), 0
183
- show(r, `Add this DNS record, wait a minute, then run builtindie claim ${slug} again:\n\n type TXT\n name ${r.record.name}\n value ${r.record.value}\n\n(not found yet at ${r.looked.join(', ')})`)
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
- search(q: string): Promise<{ products: ApiProduct[] }> {
75
- return this.call('GET', `/api/v1/products?q=${encodeURIComponent(q)}`)
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
+ }