@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/server.ts CHANGED
@@ -7,16 +7,24 @@
7
7
  * https://built.indie.pit.world, which is the address we hand out (badges,
8
8
  * share links, feeds) because it works for everybody.
9
9
  */
10
+ import { startRingFooterRefresh } from './ring-footer.js'
10
11
  import { Hono, type Context } from 'hono'
11
12
  import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
12
- import { Auth, consoleMailer, resendMailer, safeNext, SESSION_COOKIE, validEmail } from './auth.js'
13
- import { Store, type Product, type User, type Window } from './db.js'
13
+ import { leaderboard } from '@profullstack/leaderboard/hono'
14
+ import { Auth, consoleMailer, resendMailer, safeNext, SESSION_COOKIE, validEmail, type Mailer } from './auth.js'
15
+ import { Store, type Comment, type Product, type Review, type User, type Window } from './db.js'
14
16
  import { privateAddress } from './fetch.js'
15
- import { badgeSvg, boardPage, cardImage, claimMarkdown, claimPage, editPage, esc, helpPage, homePage, loginPage, makerPage, mePage, messagePage, productPage, submitPage, type Ctx } from './html.js'
17
+ import { CATEGORY_LABELS, parseQuery, queryString, search } from './browse.js'
18
+ import { badgeSvg, launchBadgeSvg, boardPage, browsePage, cardImage, claimBadgeSnippet, claimMarkdown, claimPage, editPage, esc, helpPage, homePage, inviteLanding, invitePage, leaderboardPage, loginPage, makerPage, mePage, messagePage, productPage, submitPage, tagsPage, type CommentView, type Ctx, type InviterRow, type ReviewView } from './html.js'
19
+ import { normalizeTag } from './tags.js'
20
+ import { siteLeaderboard } from './leaderboard.js'
21
+ import { HOW_TO_EARN, SHARE_NETWORKS } from './karma.js'
16
22
  import { IMAGE_TYPES, Media } from './media.js'
23
+ import { startPitFooterRefresh } from './pit-footer.js'
17
24
  import { RING_PLACEHOLDER, ringBar, startRingRefresh, visitorOnMoshpit } from './ring.js'
18
- import { BuiltIndie, CLAIM_LABEL, CLAIM_PREFIX, DEFAULT_CHAINS, InputError, type Config, type Edit } from './service.js'
25
+ import { BuiltIndie, CLAIM_LABEL, CLAIM_PREFIX, InputError, type Config, type Edit } from './service.js'
19
26
  import { autofetch, FetchError } from './fetch.js'
27
+ import { COINPAY_ERRORS, coinpayOAuthFromEnv, isPlaceholder, mountCoinPaySignin, PROVIDER as COINPAY, type CoinPayOAuth } from './coinpay-signin.js'
20
28
  import { VERSION } from './version.js'
21
29
 
22
30
  export { VERSION }
@@ -46,7 +54,10 @@ const SECURITY_HEADERS: Record<string, string> = {
46
54
  'cross-origin-opener-policy': 'same-origin',
47
55
  }
48
56
 
49
- export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureCookies?: boolean }
57
+ export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureCookies?: boolean; mailer?: Mailer; /** Sign in with CoinPay; off when null. */ coinpayOAuth?: CoinPayOAuth | null }
58
+
59
+ /** The invite code from /i/<code>, kept until the visitor signs up (it also rides in the magic link). */
60
+ const REF_COOKIE = 'bi_ref'
50
61
  type Env = { Bindings: { ip?: string } }
51
62
 
52
63
  /** A small fixed-window limit per key, in memory: enough to stop a loop, not a fortress. */
@@ -74,6 +85,8 @@ export function createApp(deps: Deps) {
74
85
  const app = new Hono<Env>()
75
86
  const readLimit = new Limiter(20, 10 * 60_000)
76
87
  const voteLimit = new Limiter(60, 10 * 60_000)
88
+ const commentLimit = new Limiter(10, 10 * 60_000)
89
+ const shareLimit = new Limiter(60, 10 * 60_000)
77
90
  const origin = config.origin
78
91
 
79
92
  const direct = (c: Context) => visitorOnMoshpit(SELF, c.req.header('host'), c.req.header('x-moshpit-name'))
@@ -82,7 +95,7 @@ export function createApp(deps: Deps) {
82
95
  const sessionUser = (c: Context) => deps.auth.sessionUser(getCookie(c, SESSION_COOKIE))
83
96
  const ctxOf = (c: Context<Env>): Ctx => {
84
97
  const v = voter(c)
85
- return { origin, user: sessionUser(c), voted: (id) => store.voted(id, v) }
98
+ return { origin, user: sessionUser(c), voted: (id) => store.voted(id, v), coinpay: Boolean(deps.coinpayOAuth) }
86
99
  }
87
100
  // Links in mail open where the visitor asked from: the relay, or the Moshpit name itself.
88
101
  const here = (c: Context) => (direct(c) ? 'https://built.indie' : origin)
@@ -100,6 +113,40 @@ export function createApp(deps: Deps) {
100
113
  const p = store.bySlug(slug)
101
114
  return p?.status === 'live' ? p : null
102
115
  }
116
+ const secure = (c: Context) => deps.secureCookies ?? (c.req.header('x-forwarded-proto') ?? 'https') === 'https'
117
+
118
+ // The leaderboards (@profullstack/leaderboard over the karma ledger and the invites table).
119
+ const board = siteLeaderboard(store, origin)
120
+ const rowsOf = async (id: 'karma' | 'invites', period: 'week' | 'month' | 'all', limit: number): Promise<InviterRow[]> => (await board.top({ board: id, period, limit })).rows.map((r) => ({ ...r, url: `/leaderboard/u/${encodeURIComponent(r.id)}` }))
121
+ const inviters = (limit: number) => rowsOf('invites', 'week', limit)
122
+ /** Top karma this week; an empty week falls back to all time so the widget never looks dead. */
123
+ const topKarma = async (limit: number): Promise<{ period: 'week' | 'all'; rows: InviterRow[] }> => {
124
+ const week = await rowsOf('karma', 'week', limit)
125
+ return week.length ? { period: 'week', rows: week } : { period: 'all', rows: await rowsOf('karma', 'all', limit) }
126
+ }
127
+ // Every karma write: the board re-reads the ledger and checks badges (a projection is never record()ed).
128
+ service.karma.onChange = (userId) => {
129
+ board.invalidate()
130
+ board.award(userId).catch((e) => console.error(`[karma] badges: ${(e as Error).message}`))
131
+ }
132
+ service.karma.onCredit = (userId, granted) => {
133
+ const u = store.user(userId)
134
+ if (!u || !deps.mailer) return
135
+ const text = `Your karma on built.indie crossed another ${service.karma.perCredit}: that is ${granted === 1 ? 'a free Featured week' : `${granted} free Featured weeks`} (${config.featureDays} days pinned on the homepage).\n\nUse it: ${origin}/invite\nYour karma: ${origin}/me#karma`
136
+ deps.mailer({ to: u.email, subject: 'Your karma earned a free Featured week on built.indie', text, html: `<p>${esc(text).replace(/\n/g, '<br>')}</p>` }).catch((e) => console.error(`[karma] mail failed: ${(e as Error).message}`))
137
+ }
138
+ const inviteLink = (user: User) => `${origin}/i/${store.inviteCode(user.id)}`
139
+ service.onInviteEarned = (inviterId, p) => {
140
+ board.invalidate()
141
+ // A projection is never record()ed, so badges are checked here, when the fact they rest on changes.
142
+ board.award(inviterId).catch((e) => console.error(`[invite] badges: ${(e as Error).message}`))
143
+ const inviter = store.user(inviterId)
144
+ if (!inviter || !deps.mailer) return
145
+ const credits = store.inviteStats(inviterId).credits
146
+ const text = `${p.name} just launched on built.indie through your invite: ${origin}/p/${p.slug}\n\nThat earned you a free Featured week. You have ${credits} to use: ${origin}/invite\n\nKeep going: every maker you invite who launches earns another.`
147
+ const html = `<p><b>${esc(p.name)}</b> just launched on built.indie through your invite: <a href="${origin}/p/${esc(p.slug)}">${origin}/p/${esc(p.slug)}</a></p><p>That earned you a free Featured week. You have <b>${credits}</b> to use.</p><p><a href="${origin}/invite" style="display:inline-block;padding:10px 18px;background:#ff5c39;color:#fff;border-radius:8px;text-decoration:none;font-weight:700">Use it</a></p>`
148
+ deps.mailer({ to: inviter.email, subject: `You earned a free Featured week on built.indie`, text, html }).catch((e) => console.error(`[invite] mail failed: ${(e as Error).message}`))
149
+ }
103
150
 
104
151
  app.use('*', async (c, next) => {
105
152
  await next()
@@ -130,8 +177,8 @@ export function createApp(deps: Deps) {
130
177
  app.get('/.well-known/openstack.md', async (c) => c.body(await Bun.file(new URL('../OpenStack.md', import.meta.url)).text(), 200, { 'content-type': 'text/markdown; charset=utf-8', 'access-control-allow-origin': '*' }))
131
178
 
132
179
  // Boards.
133
- app.get('/', (c) =>
134
- page(c, homePage(ctxOf(c), { today: store.list('today'), featured: store.list('featured', 3), yesterday: store.onDay(new Date(Date.now() - 86400_000).toISOString().slice(0, 10), 3), stats: store.count() })),
180
+ app.get('/', async (c) =>
181
+ page(c, homePage(ctxOf(c), { today: store.list('today'), featured: store.list('featured', 3), yesterday: store.onDay(new Date(Date.now() - 86400_000).toISOString().slice(0, 10), 3), stats: store.count(), inviters: await inviters(5), karma: await topKarma(5), featureDays: config.featureDays, featureUsd: config.featureUsd })),
135
182
  )
136
183
  app.get('/top/:window', (c) => {
137
184
  const w = c.req.param('window') as Window
@@ -139,10 +186,30 @@ export function createApp(deps: Deps) {
139
186
  return page(c, boardPage(ctxOf(c), w, store.list(w, 50)))
140
187
  })
141
188
  app.get('/new', (c) => page(c, boardPage(ctxOf(c), 'new', store.list('new', 50))))
142
- app.get('/search', (c) => {
143
- const q = (c.req.query('q') ?? '').trim().slice(0, 60)
144
- return q ? page(c, boardPage(ctxOf(c), 'all', store.search(q), q)) : c.redirect('/top/all')
189
+ // Browse and search, with facets (browse.ts). /c/ and /t/ are one filter each, at an indexable address.
190
+ const browse = (c: Context, extra: Record<string, string> = {}) => {
191
+ const params = new URL(c.req.url).searchParams
192
+ for (const [k, v] of Object.entries(extra)) if (!params.getAll(k).includes(v)) params.append(k, v)
193
+ return search(store.allLive(), parseQuery(params))
194
+ }
195
+ app.get('/search', (c) => page(c, browsePage(ctxOf(c), browse(c))))
196
+ app.get('/c/:category', (c) => {
197
+ const key = c.req.param('category')
198
+ if (!(key in CATEGORY_LABELS)) return c.notFound()
199
+ const label = CATEGORY_LABELS[key]!
200
+ // Further filters go to /search, so /c/<category> stays one page.
201
+ if (new URL(c.req.url).search) return c.redirect(`/search?category=${encodeURIComponent(key)}&${new URL(c.req.url).searchParams}`)
202
+ return page(c, browsePage(ctxOf(c), browse(c, { category: key }), { path: `/c/${key}`, heading: label, intro: `${label}: indie products launched on built.indie` }))
145
203
  })
204
+ app.get('/t/:tag', (c) => {
205
+ const tag = normalizeTag(c.req.param('tag'))
206
+ if (!tag) return c.notFound()
207
+ if (tag !== c.req.param('tag')) return c.redirect(`/t/${encodeURIComponent(tag)}`, 301)
208
+ if (new URL(c.req.url).search) return c.redirect(`/search?tag=${encodeURIComponent(tag)}&${new URL(c.req.url).searchParams}`)
209
+ const r = browse(c, { tag })
210
+ return page(c, browsePage(ctxOf(c), r, { path: `/t/${encodeURIComponent(tag)}`, heading: `#${tag}`, intro: `Indie products tagged ${tag}` }), r.total ? 200 : 404)
211
+ })
212
+ app.get('/tags', (c) => page(c, tagsPage(ctxOf(c), store.tagCounts())))
146
213
  app.get('/help', (c) => page(c, helpPage(ctxOf(c))))
147
214
 
148
215
  // Launching.
@@ -163,21 +230,45 @@ export function createApp(deps: Deps) {
163
230
  })
164
231
 
165
232
  // A product.
166
- const productView = (c: Context<Env>, p: Product) =>
233
+ /** Comments and reviews as the page shows them: public names and karma, never emails. */
234
+ const discussion = (p: Product, me: User | null) => {
235
+ const comments = store.comments(p.id)
236
+ const reviews = store.reviews(p.id)
237
+ const karmaOf = store.karmaOf([...comments.map((x) => x.userId), ...reviews.map((x) => x.userId)])
238
+ const names = new Map<string, string>()
239
+ const who = (id: string) => names.get(id) ?? (names.set(id, store.publicName(id)), names.get(id)!)
240
+ const owner = p.ownerId ?? p.submitterUser
241
+ const cv = (x: Comment): CommentView => ({ id: x.id, parentId: x.parentId, body: x.body, deleted: Boolean(x.deletedAt), at: x.createdAt, author: x.deletedAt ? '' : who(x.userId), karma: karmaOf.get(x.userId) ?? 0, maker: x.userId === owner, mine: Boolean(me && x.userId === me.id) })
242
+ const rv = (x: Review): ReviewView => ({ rating: x.rating, body: x.body, at: x.updatedAt, author: who(x.userId), karma: karmaOf.get(x.userId) ?? 0, mine: Boolean(me && x.userId === me.id) })
243
+ return { comments: comments.map(cv), reviews: reviews.map(rv), myReview: me ? store.review(p.id, me.id) : null }
244
+ }
245
+ const productView = (c: Context<Env>, p: Product, extra: { error?: string } = {}, status = 200) =>
167
246
  page(
168
247
  c,
169
248
  productPage(ctxOf(c), {
249
+ ...discussion(p, sessionUser(c)),
250
+ error: extra.error,
251
+ selfListing: (() => {
252
+ const u = sessionUser(c)
253
+ return Boolean(u && (p.ownerId === u.id || p.submitterUser === u.id))
254
+ })(),
170
255
  product: p,
171
256
  award: store.awards(p.id),
172
257
  maker: p.makerId ? store.maker(p.makerId) : null,
173
258
  featuredOk: c.req.query('featured') === '1',
259
+ featuredCredit: c.req.query('featured') === 'credit',
174
260
  launched: c.req.query('launched') === '1',
175
261
  canEdit: store.canEdit(p, sessionUser(c)),
176
- chains: config.chains,
262
+ credits: (() => {
263
+ const u = sessionUser(c)
264
+ return u && store.canEdit(p, u) ? store.inviteStats(u.id).credits : 0
265
+ })(),
266
+ buyerEmail: sessionUser(c)?.email ?? '',
177
267
  featureUsd: config.featureUsd,
178
268
  featureDays: config.featureDays,
179
269
  payments: Boolean(config.coinpay),
180
270
  }),
271
+ status,
181
272
  )
182
273
  app.get('/p/:slug', (c) => {
183
274
  const p = store.bySlug(c.req.param('slug'))
@@ -232,17 +323,19 @@ export function createApp(deps: Deps) {
232
323
  }
233
324
  })
234
325
 
235
- // Claiming: the domain's owner proves it in DNS and takes the listing.
326
+ // Claiming: the domain's owner proves it with the badge carrying their badge id, or in DNS, and takes the listing.
236
327
  const claimRecord = (p: Product, user: User) => ({ type: 'TXT', name: `${CLAIM_LABEL}.${p.domain.replace(/^www\./, '')}`, value: `${CLAIM_PREFIX}${service.claimToken(p, user)}` })
237
- const claimView = (c: Context<Env>, p: Product, user: User, extra: { result?: { ok: boolean; looked: string[] } } = {}, status = 200) =>
238
- page(c, claimPage(ctxOf(c), { product: p, record: claimRecord(p, user), owner: p.ownerId === user.id, ...extra }), status)
328
+ const claimBadge = (p: Product, user: User) => claimBadgeSnippet(origin, p, store.badgeId(p.id, user.id))
329
+ const claimMd = (p: Product, user: User) => claimMarkdown(origin, p, claimRecord(p, user), claimBadge(p, user))
330
+ const claimView = (c: Context<Env>, p: Product, user: User, extra: { result?: { ok: boolean; looked: string[]; method?: string } } = {}, status = 200) =>
331
+ page(c, claimPage(ctxOf(c), { product: p, record: claimRecord(p, user), badge: claimBadge(p, user), owner: p.ownerId === user.id, ...extra }), status)
239
332
  // The same instructions as Markdown, to paste to an AI agent with DNS access.
240
333
  app.get('/p/:slug/claim.md', (c) => {
241
334
  const user = sessionUser(c)
242
335
  if (!user) return c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`)
243
336
  const p = store.bySlug(c.req.param('slug'))
244
337
  if (!p || p.status === 'hidden') return c.text('not found', 404)
245
- return c.body(claimMarkdown(origin, p, claimRecord(p, user)), 200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'private, no-store' })
338
+ return c.body(claimMd(p, user), 200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'private, no-store' })
246
339
  })
247
340
  app.get('/p/:slug/claim', (c) => {
248
341
  const user = sessionUser(c)
@@ -262,18 +355,40 @@ export function createApp(deps: Deps) {
262
355
  })
263
356
 
264
357
  // Signing in: an emailed link, which is also sign-up.
265
- app.get('/login', (c) => page(c, loginPage(ctxOf(c), { next: safeNext(c.req.query('next')) })))
358
+ const invitedBy = (c: Context) => {
359
+ const inviter = store.inviterOf(getCookie(c, REF_COOKIE) ?? '')
360
+ return inviter && !sessionUser(c) ? store.publicName(inviter) : null
361
+ }
362
+ app.get('/login', (c) => page(c, loginPage(ctxOf(c), { next: safeNext(c.req.query('next')), invitedBy: invitedBy(c), error: COINPAY_ERRORS[c.req.query('error') ?? ''] })))
363
+ // Sign in with CoinPay (coinpay-signin.ts): /api/v1/coinpay/login and /callback.
364
+ mountCoinPaySignin(app, {
365
+ store,
366
+ auth: deps.auth,
367
+ origin,
368
+ oauth: deps.coinpayOAuth ?? null,
369
+ secure,
370
+ sessionUser,
371
+ // A new member who came through an invite link counts, as with a magic link.
372
+ onNewMember: (c, user) => {
373
+ const ref = getCookie(c, REF_COOKIE)
374
+ if (ref) store.recordInvite(user.id, ref), deleteCookie(c, REF_COOKIE, { path: '/' })
375
+ },
376
+ })
266
377
  app.post('/login', async (c) => {
267
378
  const form = (await c.req.parseBody()) as Record<string, string>
268
379
  const next = safeNext(form.next)
269
380
  if (!validEmail(form.email)) return page(c, loginPage(ctxOf(c), { next, error: 'That does not look like an email address.', email: form.email }), 400)
270
- if (!crossSite(c) && readLimit.allow(`mail:${ip(c)}`)) await deps.auth.sendLink(form.email ?? '', next, here(c))
381
+ if (!crossSite(c) && readLimit.allow(`mail:${ip(c)}`)) await deps.auth.sendLink(form.email ?? '', next, here(c), getCookie(c, REF_COOKIE) ?? null)
271
382
  return page(c, loginPage(ctxOf(c), { next, sent: true }))
272
383
  })
273
384
  app.get('/auth/verify', (c) => {
274
385
  const r = deps.auth.consumeLink(c.req.query('token') ?? '')
275
386
  if (!r) return page(c, messagePage(ctxOf(c), { title: 'That link is used up', message: 'Sign-in links work once, for 20 minutes. Ask for a new one.' }), 400)
276
- setCookie(c, SESSION_COOKIE, deps.auth.createSession(r.user.id), { httpOnly: true, secure: deps.secureCookies ?? (c.req.header('x-forwarded-proto') ?? 'https') === 'https', sameSite: 'Lax', path: '/', maxAge: 90 * 86400 })
387
+ setCookie(c, SESSION_COOKIE, deps.auth.createSession(r.user.id), { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 90 * 86400 })
388
+ // A new member who came through an invite link: their first live listing will earn the inviter a credit.
389
+ const ref = r.ref ?? getCookie(c, REF_COOKIE)
390
+ if (r.created && ref) store.recordInvite(r.user.id, ref)
391
+ if (ref) deleteCookie(c, REF_COOKIE, { path: '/' })
277
392
  return c.redirect(r.next ?? '/me')
278
393
  })
279
394
  app.post('/logout', (c) => {
@@ -281,22 +396,77 @@ export function createApp(deps: Deps) {
281
396
  deleteCookie(c, SESSION_COOKIE, { path: '/' })
282
397
  return c.redirect('/')
283
398
  })
399
+ const signinInfo = (c: Context, user: User) => ({
400
+ coinpay: store.identities(user.id).find((i) => i.provider === COINPAY) ?? null,
401
+ emailless: isPlaceholder(user.email),
402
+ coinpayNote: ({ connected: 'CoinPay is connected: sign in with it next time.', taken: 'That CoinPay account is already linked to another built.indie member.', welcome: 'Welcome! You signed in with CoinPay.' } as Record<string, string>)[c.req.query('coinpay') ?? ''] ?? null,
403
+ })
284
404
  app.get('/me', (c) => {
285
405
  const user = sessionUser(c)
286
- return user ? page(c, mePage(ctxOf(c), { user, products: store.mine(user.id) })) : toLogin(c)
406
+ return user ? page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, ...signinInfo(c, user) })) : toLogin(c)
287
407
  })
288
408
  app.post('/me/key', (c) => {
289
409
  const user = sessionUser(c)
290
410
  if (!user) return toLogin(c)
291
411
  if (crossSite(c)) return c.redirect('/me')
292
- return page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), apiKey: store.newApiKey(user.id) }))
412
+ return page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), apiKey: store.newApiKey(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, ...signinInfo(c, user) }))
413
+ })
414
+
415
+ // Invites: a link per member; a new member it brings in who launches earns the inviter a free Featured week.
416
+ app.get('/i/:code', (c) => {
417
+ const code = c.req.param('code')
418
+ const inviter = store.inviterOf(code)
419
+ if (!inviter) return c.redirect('/invite')
420
+ if (!sessionUser(c)) setCookie(c, REF_COOKIE, code, { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
421
+ return page(c, inviteLanding(ctxOf(c), { inviter: store.publicName(inviter) }))
422
+ })
423
+ const inviteView = async (c: Context<Env>, extra: { flash?: string; error?: string } = {}, status = 200) => {
424
+ const user = sessionUser(c)
425
+ const me = user
426
+ ? { link: inviteLink(user), stats: store.inviteStats(user.id), name: store.publicName(user.id), displayName: user.displayName, listings: store.mine(user.id).filter((p) => p.status === 'live'), ...extra }
427
+ : undefined
428
+ return page(c, invitePage(ctxOf(c), { featureDays: config.featureDays, featureUsd: config.featureUsd, inviters: await inviters(10), me }), status)
429
+ }
430
+ app.get('/invite', (c) => inviteView(c))
431
+ app.post('/invite/name', async (c) => {
432
+ const user = sessionUser(c)
433
+ if (!user) return toLogin(c)
434
+ if (crossSite(c)) return c.redirect('/invite')
435
+ const name = String(((await c.req.parseBody()) as Record<string, string>).displayName ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
436
+ store.setDisplayName(user.id, name || null)
437
+ board.invalidate()
438
+ return inviteView(c, { flash: name ? `Saved. The board shows you as ${name}.` : 'Cleared. The board shows your best listing instead.' })
293
439
  })
440
+ app.post('/p/:slug/credit', (c) => {
441
+ const user = sessionUser(c)
442
+ if (!user) return toLogin(c)
443
+ const p = store.bySlug(c.req.param('slug'))
444
+ if (!p || p.status === 'hidden') return c.notFound()
445
+ if (crossSite(c) || !store.canEdit(p, user)) return c.redirect(`/p/${p.slug}`)
446
+ try {
447
+ service.redeemCredit(p, user)
448
+ return c.redirect(`/p/${p.slug}?featured=credit`)
449
+ } catch (e) {
450
+ return inviteView(c, { error: e instanceof Error ? e.message : String(e) }, 400)
451
+ }
452
+ })
453
+
454
+ // The leaderboard: our page, then the package answers everything under /leaderboard (JSON, RSS, share cards, widget).
455
+ app.get('/leaderboard', async (c) => {
456
+ const user = sessionUser(c)
457
+ const which = c.req.query('board') === 'invites' ? 'invites' : 'karma'
458
+ // An empty week shows all time instead, so the board never opens blank.
459
+ const period = (await rowsOf(which, 'week', 1)).length ? 'week' : 'all'
460
+ return page(c, leaderboardPage(ctxOf(c), { board: which, period, featureDays: config.featureDays, featureUsd: config.featureUsd, perCredit: service.karma.perCredit, howToEarn: HOW_TO_EARN, me: user?.id ?? null }))
461
+ })
462
+ app.use('/leaderboard.json', leaderboard(board))
463
+ app.use('/leaderboard/*', leaderboard(board))
294
464
 
295
465
  app.post('/p/:slug/vote', (c) => {
296
466
  const p = live(c.req.param('slug'))
297
467
  if (!p) return wantsJson(c) ? c.json({ error: 'no such product' }, 404) : c.notFound()
298
468
  if (crossSite(c)) return c.json({ error: 'vote from built.indie itself' }, 403)
299
- const counted = voteLimit.allow(`vote:${ip(c)}`) && store.vote(p.id, voter(c))
469
+ const counted = voteLimit.allow(`vote:${ip(c)}`) && service.vote(p, voter(c), sessionUser(c))
300
470
  const now = store.product(p.id)!
301
471
  if (wantsJson(c)) return c.json({ slug: now.slug, votes: now.votes, counted, voted: true })
302
472
  const back = c.req.header('referer')
@@ -307,6 +477,66 @@ export function createApp(deps: Deps) {
307
477
  return c.redirect(to)
308
478
  })
309
479
 
480
+ // Comments, reviews and tracked shares: signed-in members, from this site only.
481
+ const back = (p: Product, hash: string) => `/p/${p.slug}#${hash}`
482
+ app.post('/p/:slug/comments', async (c) => {
483
+ const user = sessionUser(c)
484
+ if (!user) return toLogin(c)
485
+ const p = live(c.req.param('slug'))
486
+ if (!p) return c.notFound()
487
+ if (crossSite(c)) return c.redirect(back(p, 'comments'))
488
+ if (!commentLimit.allow(`comment:${user.id}`)) return productView(c, p, { error: 'That is a lot of comments. Wait a few minutes.' }, 429)
489
+ const form = (await c.req.parseBody()) as Record<string, string>
490
+ try {
491
+ const made = service.comment(p, user, form.body ?? '', form.parent || null)
492
+ return c.redirect(back(p, `c-${made.id}`))
493
+ } catch (e) {
494
+ if (e instanceof InputError) return productView(c, p, { error: e.message }, 400)
495
+ throw e
496
+ }
497
+ })
498
+ app.post('/p/:slug/comments/:id/delete', (c) => {
499
+ const user = sessionUser(c)
500
+ if (!user) return toLogin(c)
501
+ const p = store.bySlug(c.req.param('slug'))
502
+ if (!p) return c.notFound()
503
+ if (crossSite(c)) return c.redirect(back(p, 'comments'))
504
+ const cm = store.comment(c.req.param('id'))
505
+ if (!cm || cm.productId !== p.id) return c.redirect(back(p, 'comments'))
506
+ try {
507
+ service.deleteComment(cm.id, user)
508
+ } catch (e) {
509
+ if (!(e instanceof InputError)) throw e
510
+ }
511
+ return c.redirect(back(p, 'comments'))
512
+ })
513
+ app.post('/p/:slug/review', async (c) => {
514
+ const user = sessionUser(c)
515
+ if (!user) return toLogin(c)
516
+ const p = live(c.req.param('slug'))
517
+ if (!p) return c.notFound()
518
+ if (crossSite(c)) return c.redirect(back(p, 'reviews'))
519
+ const form = (await c.req.parseBody()) as Record<string, string>
520
+ try {
521
+ if (form.action === 'delete') service.deleteReview(p, user.id)
522
+ else service.review(p, user, form.rating, form.body)
523
+ return c.redirect(back(p, 'reviews'))
524
+ } catch (e) {
525
+ if (e instanceof InputError) return productView(c, p, { error: e.message }, 400)
526
+ throw e
527
+ }
528
+ })
529
+ // A share button: app.js sends a beacon here before the share window opens.
530
+ app.post('/p/:slug/share', (c) => {
531
+ const p = live(c.req.param('slug'))
532
+ if (!p) return c.json({ error: 'no such product' }, 404)
533
+ if (crossSite(c)) return c.json({ error: 'share from built.indie itself' }, 403)
534
+ const net = c.req.query('net') ?? ''
535
+ if (!(SHARE_NETWORKS as readonly string[]).includes(net)) return c.json({ error: `net is one of ${SHARE_NETWORKS.join(', ')}` }, 400)
536
+ if (!shareLimit.allow(`share:${ip(c)}`)) return c.json({ counted: false })
537
+ return c.json(service.share(p, sessionUser(c), net))
538
+ })
539
+
310
540
  app.post('/p/:slug/verify', async (c) => {
311
541
  const p = live(c.req.param('slug'))
312
542
  if (!p) return c.notFound()
@@ -320,8 +550,9 @@ export function createApp(deps: Deps) {
320
550
  if (!p) return c.notFound()
321
551
  const form = (await c.req.parseBody()) as Record<string, string>
322
552
  try {
323
- const r = await service.feature(p, form.chain ?? '')
324
- return c.redirect(r.checkoutUrl)
553
+ const user = sessionUser(c)
554
+ const r = await service.feature(p, { email: form.email || user?.email || '', accountId: user?.id ?? null })
555
+ return c.redirect(r.checkoutUrl, 303)
325
556
  } catch (e) {
326
557
  return page(c, messagePage(ctxOf(c), { title: 'Could not start the payment', message: e instanceof Error ? e.message : String(e) }), e instanceof InputError ? 400 : 502)
327
558
  }
@@ -347,8 +578,8 @@ export function createApp(deps: Deps) {
347
578
  })
348
579
 
349
580
  // The badge: the viral part. Cached briefly so the count stays live.
350
- app.get('/badge/:file{[a-z0-9-]+\\.svg}', (c) => {
351
- const p = live(c.req.param('file').replace(/\.svg$/, ''))
581
+ const badgeImage = (c: Context<Env>, p: Product | null, unlisted = false) => {
582
+ if (!p && unlisted) return c.body(launchBadgeSvg(c.req.query('theme') === 'light' ? 'light' : 'dark'), 200, { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'public, max-age=3600', 'access-control-allow-origin': '*', 'cross-origin-resource-policy': 'cross-origin', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" })
352
583
  if (!p) return c.body('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>', 404, { 'content-type': 'image/svg+xml' })
353
584
  return c.body(badgeSvg(p, store.awards(p.id), c.req.query('theme') === 'light' ? 'light' : 'dark'), 200, {
354
585
  'content-type': 'image/svg+xml; charset=utf-8',
@@ -357,6 +588,20 @@ export function createApp(deps: Deps) {
357
588
  'cross-origin-resource-policy': 'cross-origin',
358
589
  'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
359
590
  })
591
+ }
592
+ app.get('/badge/:file{[a-z0-9-]+\\.svg}', (c) => badgeImage(c, live(c.req.param('file').replace(/\.svg$/, ''))))
593
+ // The same badge by badge id: the one the claim page hands out. On a homepage it claims the listing for that account.
594
+ // By site, for snippets shared across many sites (the Profullstack footer): ?site=<homepage>.
595
+ // A site with no listing (built.indie itself, a brand-new site) gets an invitation to launch instead.
596
+ app.get('/for.svg', (c) => badgeImage(c, store.bySite(c.req.query('site') ?? ''), true))
597
+ app.get('/for', (c) => {
598
+ const p = store.bySite(c.req.query('site') ?? '')
599
+ return c.redirect(p ? `/p/${p.slug}?ref=badge` : '/submit?ref=badge')
600
+ })
601
+ app.get('/images.svg', (c) => {
602
+ const b = store.badge(c.req.query('id') ?? '')
603
+ const p = b ? store.product(b.productId) : null
604
+ return badgeImage(c, p?.status === 'live' ? p : null)
360
605
  })
361
606
 
362
607
  app.get('/makers/:id', (c) => {
@@ -377,12 +622,13 @@ export function createApp(deps: Deps) {
377
622
  return c.body(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>built.indie launches</title><link>${origin}/</link><description>Indie products launched on built.indie</description>${items}</channel></rss>`, 200, { 'content-type': 'application/rss+xml; charset=utf-8' })
378
623
  })
379
624
  app.get('/sitemap.xml', (c) => {
380
- const urls = ['/', '/top/today', '/top/week', '/top/month', '/top/all', '/new', '/submit', '/help', ...store.list('all', 5000).map((p) => `/p/${p.slug}`)]
625
+ const all = search(store.allLive(), parseQuery({}), { limit: 0 })
626
+ const urls = ['/', '/search', '/tags', '/top/today', '/top/week', '/top/month', '/top/all', '/new', '/submit', '/help', '/invite', '/leaderboard', ...all.facets.category.map((f) => `/c/${f.value}`), ...store.tagCounts().map((t) => `/t/${encodeURIComponent(t.tag)}`), ...store.list('all', 5000).map((p) => `/p/${p.slug}`)]
381
627
  return c.body(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.map((u) => `<url><loc>${origin}${u}</loc></url>`).join('')}</urlset>`, 200, { 'content-type': 'application/xml' })
382
628
  })
383
629
  app.get('/robots.txt', (c) => c.text(`User-agent: *\nAllow: /\nDisallow: /p/*/edit\nSitemap: ${origin}/sitemap.xml\n`))
384
630
  app.get('/llms.txt', (c) =>
385
- c.text(`# built.indie\n\n> Launch your indie product with one URL. built.indie reads your homepage (meta tags, Open Graph, JSON-LD, llms.txt, OpenSite, OpenProfile), saves the logo and screenshots, and puts it on a daily upvote board. Free, open source, no account.\n\n## API\n\n- [Help](${origin}/help): JSON API, CLI, TUI and MCP\n- [Today](${origin}/api/v1/products?window=today): today's launches as JSON\n- [Launch](${origin}/help#api): POST /api/v1/products {"url": "..."}\n- [MCP](${origin}/help#mcp): claude mcp add builtindie -- bunx @profullstack/builtindie mcp\n- [Install](${origin}/install.sh): curl -fsSL ${origin}/install.sh | sh\n- [Feed](${origin}/feed.xml): RSS of every launch\n`),
631
+ c.text(`# built.indie\n\n> Launch your indie product with one URL. built.indie reads your homepage (meta tags, Open Graph, JSON-LD, llms.txt, OpenSite, OpenProfile), saves the logo and screenshots, and puts it on a daily upvote board. Free, open source, no account.\n\n## API\n\n- [Help](${origin}/help): JSON API, CLI, TUI and MCP\n- [Today](${origin}/api/v1/products?window=today): today's launches as JSON\n- [Launch](${origin}/help#api): POST /api/v1/products {"url": "..."}\n- [MCP](${origin}/help#mcp): claude mcp add builtindie -- bunx @profullstack/builtindie mcp\n- [Install](${origin}/install.sh): curl -fsSL ${origin}/install.sh | sh\n- [Feed](${origin}/feed.xml): RSS of every launch\n- [Karma](${origin}/help#karma): members earn karma for launching, voting, reviewing, commenting and sharing; every ${service.karma.perCredit} karma is a free Featured week. GET /api/v1/karma (your key), GET /api/v1/leaderboard?board=karma&period=week\n- [Leaderboard](${origin}/leaderboard): top karma (default) and top inviters; JSON at /leaderboard/karma.json?period=week\n- [Comments and reviews](${origin}/help#api): GET/POST /api/v1/products/<slug>/comments, GET/POST /api/v1/products/<slug>/reviews, POST /api/v1/products/<slug>/share\n`),
386
632
  )
387
633
  app.get('/.well-known/openprofile.md', (c) =>
388
634
  c.body(`# built.indie\n\n- **Kind**: organization\n- **Web**: ${origin}\n- **Avatar**: ${origin}/icon-512.png\n\nLaunch your indie product with one URL.\n\n## Accounts\n\n- ${'https://github.com/moshcoder/built.indie'}\n- https://pit.world/ring\n\n## Topics\n\n- indie\n- launches\n- startups\n\n## Operator\n\n- **Name**: Profullstack, Inc.\n- **Profile**: https://profullstack.com/.well-known/openprofile.md\n`, 200, { 'content-type': 'text/markdown; charset=utf-8', 'access-control-allow-origin': '*' }),
@@ -409,6 +655,8 @@ export function createApp(deps: Deps) {
409
655
  domain: p.domain,
410
656
  status: p.status,
411
657
  votes: p.votes,
658
+ reviews: p.reviews,
659
+ rating: p.rating,
412
660
  tags: p.tags,
413
661
  logo: p.logo ? `${origin}/m/${p.id}/${p.logo}` : null,
414
662
  card_image: p.ogImage ? `${origin}/m/${p.id}/${p.ogImage}` : null,
@@ -452,11 +700,12 @@ export function createApp(deps: Deps) {
452
700
  })
453
701
 
454
702
  app.post('/api/v1/products', async (c) => {
455
- if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many launches from you; wait a few minutes' }, 429)
703
+ const admin = isAdmin(c)
704
+ if (!admin && !readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many launches from you; wait a few minutes' }, 429)
456
705
  const b = await body(c)
457
706
  try {
458
707
  const user = apiUser(c)
459
- const r = await service.submit({ url: String(b.url ?? ''), makerName: b.maker_name, makerProfile: b.maker_profile }, store.voterFor(ip(c)), user)
708
+ const r = await service.submit({ url: String(b.url ?? ''), makerName: b.maker_name, makerProfile: b.maker_profile }, store.voterFor(ip(c)), user, admin)
460
709
  if (r.existing) return c.json({ existing: true, product: apiProduct(r.product) }, 200)
461
710
  let p = r.product
462
711
  if (user && (b.name || b.tagline || b.description || b.tags || b.videos)) p = await service.edit(p, { name: b.name, tagline: b.tagline, description: b.description, tags: b.tags, videos: b.videos })
@@ -470,11 +719,41 @@ export function createApp(deps: Deps) {
470
719
  app.get('/api/v1/products', (c) => {
471
720
  const w = (c.req.query('window') ?? 'today') as Window
472
721
  const q = c.req.query('q')
473
- if (!q && !WINDOWS.includes(w)) return c.json({ error: `window is one of ${WINDOWS.join(', ')}` }, 400)
722
+ const tag = c.req.query('tag') !== undefined ? normalizeTag(c.req.query('tag')!) : undefined
723
+ if (tag === null) return c.json({ error: 'tag is 2 to 24 letters, digits, spaces or + # . -' }, 400)
724
+ if (!q && !tag && !WINDOWS.includes(w)) return c.json({ error: `window is one of ${WINDOWS.join(', ')}` }, 400)
474
725
  const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 30) || 30))
475
726
  const offset = Math.max(0, Number(c.req.query('offset') ?? 0) || 0)
476
- return c.json({ window: q ? 'search' : w, products: (q ? store.search(q.slice(0, 60), limit) : store.list(w, limit, offset)).map(apiProduct) }, 200, { 'access-control-allow-origin': '*' })
727
+ const products = tag ? store.byTag(tag, limit) : q ? search(store.allLive(), parseQuery({ q }), { limit }).products : store.list(w, limit, offset)
728
+ return c.json({ window: tag ? 'tag' : q ? 'search' : w, ...(tag ? { tag } : {}), products: products.map(apiProduct) }, 200, { 'access-control-allow-origin': '*' })
729
+ })
730
+
731
+ // Search with facets: the browse page as data. Same parameters as /search.
732
+ app.get('/api/v1/search', (c) => {
733
+ const params = new URL(c.req.url).searchParams
734
+ const limit = Math.min(100, Math.max(1, Number(params.get('limit') ?? 30) || 30))
735
+ const r = search(store.allLive(), parseQuery(params), { limit })
736
+ const qs = queryString(r.query)
737
+ return c.json(
738
+ {
739
+ query: r.query,
740
+ total: r.total,
741
+ page: r.query.page,
742
+ pages: Math.ceil(r.total / limit),
743
+ products: r.products.map((p) => ({ ...apiProduct(p), categories: r.categories.get(p.id) ?? [] })),
744
+ ...(params.get('facets') === '0' ? {} : { facets: r.facets }),
745
+ web: `${origin}/search${qs ? `?${qs}` : ''}`,
746
+ },
747
+ 200,
748
+ { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=30' },
749
+ )
477
750
  })
751
+ app.get('/api/v1/categories', (c) => {
752
+ const r = search(store.allLive(), parseQuery({}), { limit: 0 })
753
+ return c.json({ categories: r.facets.category.map((f) => ({ key: f.value, label: f.label, count: f.count, page: `${origin}/c/${f.value}` })), tags: r.facets.tag.map((f) => ({ tag: f.value, count: f.count, page: `${origin}/t/${encodeURIComponent(f.value)}` })) }, 200, { 'access-control-allow-origin': '*' })
754
+ })
755
+
756
+ app.get('/api/v1/tags', (c) => c.json({ tags: store.tagCounts() }, 200, { 'access-control-allow-origin': '*' }))
478
757
 
479
758
  app.get('/api/v1/products/:slug', (c) => {
480
759
  const p = store.bySlug(c.req.param('slug'))
@@ -503,8 +782,10 @@ export function createApp(deps: Deps) {
503
782
  if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many reads from you; wait a few minutes' }, 429)
504
783
  if (action === 'refetch') return c.json(apiProduct(await service.refetch(p)))
505
784
  if (action === 'verify') return c.json({ verified: await service.verify(p) })
506
- const r = await service.feature(p, String((await body(c)).chain ?? ''))
507
- return c.json({ checkout_url: r.checkoutUrl, payment: r.ref, usd: config.featureUsd, days: config.featureDays })
785
+ const b = await body(c)
786
+ const user = apiUser(c)
787
+ const r = await service.feature(p, { email: String(b.email ?? user?.email ?? ''), accountId: user?.id ?? null })
788
+ return c.json({ checkout_url: r.checkoutUrl, checkout_session_id: r.sessionId, methods: r.methods, payment: r.ref, usd: config.featureUsd, days: config.featureDays })
508
789
  } catch (e) {
509
790
  return apiError(c, e)
510
791
  }
@@ -514,7 +795,7 @@ export function createApp(deps: Deps) {
514
795
  const p = live(c.req.param('slug'))
515
796
  if (!p) return c.json({ error: 'no such product' }, 404)
516
797
  if (crossSite(c)) return c.json({ error: 'vote from built.indie itself' }, 403)
517
- const counted = voteLimit.allow(`vote:${ip(c)}`) && store.vote(p.id, voter(c))
798
+ const counted = voteLimit.allow(`vote:${ip(c)}`) && service.vote(p, voter(c), apiUser(c))
518
799
  return c.json({ slug: p.slug, votes: store.product(p.id)!.votes, counted })
519
800
  })
520
801
 
@@ -524,6 +805,9 @@ export function createApp(deps: Deps) {
524
805
  const p = store.bySlug(c.req.param('slug'))
525
806
  if (!p) return c.json({ error: 'no such product' }, 404)
526
807
  store.update(p.id, { status: 'hidden' })
808
+ store.revokeInvite(p.id)
809
+ service.karma.takenDown(p.id)
810
+ board.invalidate()
527
811
  return c.json({ hidden: true })
528
812
  }
529
813
  const p = owned(c)
@@ -539,7 +823,7 @@ export function createApp(deps: Deps) {
539
823
  if (!user) return c.json(NEEDS_ACCOUNT, 401)
540
824
  const p = store.bySlug(c.req.param('slug'))
541
825
  if (!p || p.status === 'hidden') return c.json({ error: 'no such product' }, 404)
542
- return c.json({ owner: p.ownerId === user.id, record: claimRecord(p, user), markdown: claimMarkdown(origin, p, claimRecord(p, user)) })
826
+ return c.json({ owner: p.ownerId === user.id, record: claimRecord(p, user), badge: claimBadge(p, user), markdown: claimMd(p, user) })
543
827
  })
544
828
  app.post('/api/v1/products/:slug/claim', async (c) => {
545
829
  const user = apiUser(c)
@@ -548,12 +832,118 @@ export function createApp(deps: Deps) {
548
832
  if (!p || p.status === 'hidden') return c.json({ error: 'no such product' }, 404)
549
833
  if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many checks; wait a few minutes' }, 429)
550
834
  const r = await service.claim(p, user)
551
- return c.json({ claimed: r.ok, looked: r.looked, record: claimRecord(p, user), ...(r.ok ? {} : { markdown: claimMarkdown(origin, p, claimRecord(p, user)) }) })
835
+ return c.json({ claimed: r.ok, ...(r.method ? { method: r.method } : {}), looked: r.looked, record: claimRecord(p, user), badge: claimBadge(p, user), ...(r.ok ? {} : { markdown: claimMd(p, user) }) })
552
836
  })
553
837
  app.get('/api/v1/me', (c) => {
554
838
  const user = apiUser(c)
555
839
  if (!user) return c.json(NEEDS_ACCOUNT, 401)
556
- return c.json({ email: user.email, products: store.mine(user.id).map((p) => ({ ...apiProduct(p), owner: p.ownerId === user.id })) })
840
+ return c.json({ email: isPlaceholder(user.email) ? null : user.email, coinpay: Boolean(store.identities(user.id).some((i) => i.provider === COINPAY)), products: store.mine(user.id).map((p) => ({ ...apiProduct(p), owner: p.ownerId === user.id })), invite: apiInvite(user), karma: apiKarma(user) })
841
+ })
842
+ const apiInvite = (user: User) => {
843
+ const s = store.inviteStats(user.id)
844
+ return { link: inviteLink(user), code: s.code, invited: s.invited, launched: s.launched, credits: s.credits, karma_credits: s.karmaCredits, used: s.used, feature_days: config.featureDays }
845
+ }
846
+ app.get('/api/v1/invites', (c) => {
847
+ const user = apiUser(c)
848
+ return user ? c.json(apiInvite(user)) : c.json(NEEDS_ACCOUNT, 401)
849
+ })
850
+ app.post('/api/v1/products/:slug/credit', (c) => {
851
+ const user = apiUser(c)
852
+ const p = owned(c)
853
+ if (!user || !p) return c.json(NEEDS_KEY, 401)
854
+ try {
855
+ const after = service.redeemCredit(p, user)
856
+ return c.json({ featured_until: new Date(after.featuredUntil!).toISOString(), credits: store.inviteStats(user.id).credits })
857
+ } catch (e) {
858
+ return apiError(c, e)
859
+ }
860
+ })
861
+
862
+ // Karma, comments, reviews, shares.
863
+ const apiKarma = (user: User) => {
864
+ const k = service.karma.summary(user.id)
865
+ return { total: k.total, week: k.week, per_credit: k.perCredit, next_credit_in: k.nextCreditIn, credits: { earned: k.credits.granted, used: k.credits.used }, events: k.events.map((e) => ({ action: e.action, label: e.label, ref: e.ref, points: e.points, at: new Date(e.at).toISOString() })), how_to_earn: HOW_TO_EARN, leaderboard: `${origin}/leaderboard` }
866
+ }
867
+ app.get('/api/v1/karma', (c) => {
868
+ const user = apiUser(c)
869
+ return user ? c.json(apiKarma(user)) : c.json({ ...NEEDS_ACCOUNT, how_to_earn: HOW_TO_EARN, leaderboard: `${origin}/api/v1/leaderboard?board=karma&period=week` }, 401)
870
+ })
871
+ app.get('/api/v1/leaderboard', async (c) => {
872
+ const which = c.req.query('board') === 'invites' ? 'invites' : 'karma'
873
+ const period = (['week', 'month', 'all'] as const).find((x) => x === c.req.query('period')) ?? 'week'
874
+ const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 25) || 25))
875
+ const top = await board.top({ board: which, period, limit })
876
+ return c.json({ board: which, period, total: top.total, rows: top.rows.map((r) => ({ rank: r.rank, id: r.id, name: r.name, value: r.value, badges: r.badges, url: `${origin}/leaderboard/u/${encodeURIComponent(r.id)}` })), web: `${origin}/leaderboard${which === 'karma' ? '' : '?board=invites'}` }, 200, { 'access-control-allow-origin': '*' })
877
+ })
878
+ const apiComment = (p: Product, x: CommentView) => ({ id: x.id, parent: x.parentId, body: x.deleted ? null : x.body, deleted: x.deleted, author: x.author || null, karma: x.karma, maker: x.maker, at: new Date(x.at).toISOString(), url: `${origin}/p/${p.slug}#c-${x.id}` })
879
+ app.get('/api/v1/products/:slug/comments', (c) => {
880
+ const p = live(c.req.param('slug'))
881
+ if (!p) return c.json({ error: 'no such product' }, 404)
882
+ return c.json({ slug: p.slug, comments: discussion(p, apiUser(c)).comments.map((x) => ({ ...apiComment(p, x), mine: x.mine })) }, 200, { 'access-control-allow-origin': '*' })
883
+ })
884
+ app.post('/api/v1/products/:slug/comments', async (c) => {
885
+ const user = apiUser(c)
886
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
887
+ const p = live(c.req.param('slug'))
888
+ if (!p) return c.json({ error: 'no such product' }, 404)
889
+ if (!commentLimit.allow(`comment:${user.id}`)) return c.json({ error: 'too many comments; wait a few minutes' }, 429)
890
+ const b = await body(c)
891
+ try {
892
+ const made = service.comment(p, user, String(b.body ?? b.text ?? ''), b.parent ? String(b.parent) : null)
893
+ const view = discussion(p, user).comments.find((x) => x.id === made.id)!
894
+ return c.json({ comment: apiComment(p, view), karma: store.karmaTotal(user.id) }, 201)
895
+ } catch (e) {
896
+ return apiError(c, e)
897
+ }
898
+ })
899
+ app.delete('/api/v1/comments/:id', (c) => {
900
+ const admin = isAdmin(c)
901
+ const user = apiUser(c)
902
+ if (!admin && !user) return c.json(NEEDS_ACCOUNT, 401)
903
+ try {
904
+ service.deleteComment(c.req.param('id'), user, admin)
905
+ return c.json({ deleted: true })
906
+ } catch (e) {
907
+ return apiError(c, e)
908
+ }
909
+ })
910
+ const apiReview = (x: ReviewView) => ({ rating: x.rating, body: x.body, author: x.author, karma: x.karma, at: new Date(x.at).toISOString() })
911
+ app.get('/api/v1/products/:slug/reviews', (c) => {
912
+ const p = live(c.req.param('slug'))
913
+ if (!p) return c.json({ error: 'no such product' }, 404)
914
+ return c.json({ slug: p.slug, rating: p.rating, count: p.reviews, reviews: discussion(p, null).reviews.map(apiReview) }, 200, { 'access-control-allow-origin': '*' })
915
+ })
916
+ app.post('/api/v1/products/:slug/reviews', async (c) => {
917
+ const user = apiUser(c)
918
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
919
+ const p = live(c.req.param('slug'))
920
+ if (!p) return c.json({ error: 'no such product' }, 404)
921
+ const b = await body(c)
922
+ try {
923
+ const r = service.review(p, user, b.rating, b.body ?? b.text ?? '')
924
+ const now = store.product(p.id)!
925
+ return c.json({ review: { rating: r.rating, body: r.body }, product: { rating: now.rating, count: now.reviews }, karma: store.karmaTotal(user.id) }, 201)
926
+ } catch (e) {
927
+ return apiError(c, e)
928
+ }
929
+ })
930
+ app.delete('/api/v1/products/:slug/reviews', (c) => {
931
+ const user = apiUser(c)
932
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
933
+ const p = store.bySlug(c.req.param('slug'))
934
+ if (!p) return c.json({ error: 'no such product' }, 404)
935
+ return c.json({ deleted: service.deleteReview(p, user.id) })
936
+ })
937
+ app.post('/api/v1/products/:slug/share', async (c) => {
938
+ const p = live(c.req.param('slug'))
939
+ if (!p) return c.json({ error: 'no such product' }, 404)
940
+ const b = await body(c)
941
+ try {
942
+ if (!shareLimit.allow(`share:${ip(c)}`)) return c.json({ counted: false })
943
+ return c.json(service.share(p, apiUser(c), String(b.network ?? c.req.query('net') ?? '')))
944
+ } catch (e) {
945
+ return apiError(c, e)
946
+ }
557
947
  })
558
948
 
559
949
  app.get('/api/v1/makers/:id', (c) => {
@@ -613,13 +1003,12 @@ export function configFromEnv(env: Record<string, string | undefined> = process.
613
1003
  env.COINPAY_API_KEY && env.COINPAY_BUSINESS_ID
614
1004
  ? { baseUrl: (env.COINPAY_API_URL ?? 'https://coinpayportal.com').replace(/\/+$/, '').replace(/\/api$/, ''), apiKey: env.COINPAY_API_KEY, businessId: env.COINPAY_BUSINESS_ID, webhookSecret: env.COINPAY_WEBHOOK_SECRET ?? '' }
615
1005
  : null
616
- const chains = (env.COINPAY_CHAINS ?? '').split(/[\s,]+/).filter(Boolean)
617
1006
  return {
618
1007
  origin: (env.BUILTINDIE_ORIGIN ?? PUBLIC_ORIGIN).replace(/\/+$/, ''),
619
1008
  coinpay,
620
1009
  featureUsd: Number(env.FEATURE_USD ?? 19),
621
1010
  featureDays: Number(env.FEATURE_DAYS ?? 7),
622
- chains: chains.length ? chains : DEFAULT_CHAINS,
1011
+ karmaPerCredit: Number(env.KARMA_PER_CREDIT ?? 500) || 500,
623
1012
  screenshots: env.BUILTINDIE_SCREENSHOTS !== 'off',
624
1013
  }
625
1014
  }
@@ -631,13 +1020,20 @@ export function serve(options: { port?: number; hostname?: string } = {}) {
631
1020
  const media = new Media(env.BUILTINDIE_MEDIA ?? `${dataDir}/media`)
632
1021
  const service = new BuiltIndie(store, media, { ...configFromEnv(env), claimSecret: env.BUILTINDIE_KEY })
633
1022
  const mailer = env.RESEND_API_KEY ? resendMailer(env.RESEND_API_KEY, env.MAIL_FROM ?? 'built.indie <built@pit.world>') : consoleMailer
634
- const app = createApp({ service, auth: new Auth(store, mailer), adminKey: env.BUILTINDIE_ADMIN_KEY })
1023
+ const app = createApp({ service, auth: new Auth(store, mailer), adminKey: env.BUILTINDIE_ADMIN_KEY, mailer, coinpayOAuth: coinpayOAuthFromEnv(env) })
635
1024
  const relays = (env.BUILTINDIE_RELAY_IPS ?? '23.95.228.174,127.0.0.1,::1').split(/[\s,]+/).filter(Boolean)
636
1025
  startRingRefresh()
1026
+ startPitFooterRefresh('built.indie')
637
1027
  // Drafts nobody launched within a day go, pictures too.
638
1028
  const sweep = () => void Promise.all(store.sweepDrafts().map((id) => media.remove(id))).catch(() => {})
639
1029
  sweep()
640
1030
  setInterval(sweep, 3600_000).unref?.()
1031
+ // Badges placed since launch only count once someone presses Verify; look again every 6 hours, a few at a time.
1032
+ const reverify = async () => {
1033
+ for (const p of store.unverified()) await service.verify(p).catch(() => false)
1034
+ }
1035
+ setTimeout(() => void reverify(), 60_000).unref?.()
1036
+ setInterval(() => void reverify(), 6 * 3600_000).unref?.()
641
1037
  return Bun.serve({
642
1038
  port: options.port ?? Number(env.PORT ?? 8798),
643
1039
  hostname: options.hostname ?? env.HOST ?? '127.0.0.1',
@@ -651,5 +1047,6 @@ export function serve(options: { port?: number; hostname?: string } = {}) {
651
1047
 
652
1048
  if (import.meta.main) {
653
1049
  const server = serve()
1050
+ startRingFooterRefresh()
654
1051
  console.log(`built.indie listening on http://${server.hostname}:${server.port}`)
655
1052
  }