@profullstack/builtindie 0.1.4 → 0.7.1

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,17 +7,27 @@
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, INVITE_BONUS_DAYS, INVITES_PER_PERSON, 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'
29
+ import { mountProfileRoutes, PROFILE_ENHANCE_SCRIPT, saveSignupProfile, validateProfile } from '@profullstack/profile'
30
+ import { openProfiles } from './profile.js'
21
31
 
22
32
  export { VERSION }
23
33
 
@@ -28,7 +38,8 @@ const CSP = [
28
38
  "default-src 'none'",
29
39
  "style-src 'self' 'unsafe-inline'",
30
40
  "script-src 'self' https://crawlproof.com",
31
- "img-src 'self' data:",
41
+ // The shared footer's badges: ours is same-origin, pwamart's is not.
42
+ "img-src 'self' data: https://pwamart.com",
32
43
  "connect-src 'self' https://crawlproof.com",
33
44
  'frame-src https://crawlproof.com https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com',
34
45
  "manifest-src 'self'",
@@ -46,7 +57,14 @@ const SECURITY_HEADERS: Record<string, string> = {
46
57
  'cross-origin-opener-policy': 'same-origin',
47
58
  }
48
59
 
49
- export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureCookies?: boolean }
60
+ export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureCookies?: boolean; mailer?: Mailer; /** Sign in with CoinPay; off when null. */ coinpayOAuth?: CoinPayOAuth | null }
61
+
62
+ /** The invite code from /i/<code>, kept until the visitor signs up (it also rides in the magic link). */
63
+ const REF_COOKIE = 'bi_ref'
64
+ /** A scarce single-use invite code (INV-XXXX-XXXX-XXXX) from /i/<code>, kept until the visitor signs up. */
65
+ const INVITE_COOKIE = 'bi_invite'
66
+ /** An @profullstack/affiliates invite code, as opposed to a member's open invite-link code. */
67
+ const isInviteCode = (code: string | null | undefined): code is string => Boolean(code && /^INV-?[0-9A-Z]{4}-?[0-9A-Z]{4}-?[0-9A-Z]{4}$/i.test(code.trim()))
50
68
  type Env = { Bindings: { ip?: string } }
51
69
 
52
70
  /** A small fixed-window limit per key, in memory: enough to stop a loop, not a fortress. */
@@ -74,7 +92,12 @@ export function createApp(deps: Deps) {
74
92
  const app = new Hono<Env>()
75
93
  const readLimit = new Limiter(20, 10 * 60_000)
76
94
  const voteLimit = new Limiter(60, 10 * 60_000)
95
+ const commentLimit = new Limiter(10, 10 * 60_000)
96
+ const shareLimit = new Limiter(60, 10 * 60_000)
97
+ const redeemLimit = new Limiter(10, 10 * 60_000)
77
98
  const origin = config.origin
99
+ // The shared profile fields (profile.ts); user_profiles is created as the app starts.
100
+ const profiles = openProfiles(store.db)
78
101
 
79
102
  const direct = (c: Context) => visitorOnMoshpit(SELF, c.req.header('host'), c.req.header('x-moshpit-name'))
80
103
  const ip = (c: Context<Env>) => c.env?.ip ?? 'local'
@@ -82,7 +105,7 @@ export function createApp(deps: Deps) {
82
105
  const sessionUser = (c: Context) => deps.auth.sessionUser(getCookie(c, SESSION_COOKIE))
83
106
  const ctxOf = (c: Context<Env>): Ctx => {
84
107
  const v = voter(c)
85
- return { origin, user: sessionUser(c), voted: (id) => store.voted(id, v) }
108
+ return { origin, user: sessionUser(c), voted: (id) => store.voted(id, v), coinpay: Boolean(deps.coinpayOAuth) }
86
109
  }
87
110
  // Links in mail open where the visitor asked from: the relay, or the Moshpit name itself.
88
111
  const here = (c: Context) => (direct(c) ? 'https://built.indie' : origin)
@@ -100,12 +123,54 @@ export function createApp(deps: Deps) {
100
123
  const p = store.bySlug(slug)
101
124
  return p?.status === 'live' ? p : null
102
125
  }
126
+ const secure = (c: Context) => deps.secureCookies ?? (c.req.header('x-forwarded-proto') ?? 'https') === 'https'
127
+
128
+ // The leaderboards (@profullstack/leaderboard over the karma ledger and the invites table).
129
+ const board = siteLeaderboard(store, origin)
130
+ 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)}` }))
131
+ const inviters = (limit: number) => rowsOf('invites', 'week', limit)
132
+ /** Top karma this week; an empty week falls back to all time so the widget never looks dead. */
133
+ const topKarma = async (limit: number): Promise<{ period: 'week' | 'all'; rows: InviterRow[] }> => {
134
+ const week = await rowsOf('karma', 'week', limit)
135
+ return week.length ? { period: 'week', rows: week } : { period: 'all', rows: await rowsOf('karma', 'all', limit) }
136
+ }
137
+ // Every karma write: the board re-reads the ledger and checks badges (a projection is never record()ed).
138
+ service.karma.onChange = (userId) => {
139
+ board.invalidate()
140
+ board.award(userId).catch((e) => console.error(`[karma] badges: ${(e as Error).message}`))
141
+ }
142
+ service.karma.onCredit = (userId, granted) => {
143
+ const u = store.user(userId)
144
+ if (!u || !deps.mailer) return
145
+ 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`
146
+ 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}`))
147
+ }
148
+ const inviteLink = (user: User) => `${origin}/i/${store.inviteCode(user.id)}`
149
+ service.onInviteEarned = (inviterId, p) => {
150
+ board.invalidate()
151
+ // A projection is never record()ed, so badges are checked here, when the fact they rest on changes.
152
+ board.award(inviterId).catch((e) => console.error(`[invite] badges: ${(e as Error).message}`))
153
+ const inviter = store.user(inviterId)
154
+ if (!inviter || !deps.mailer) return
155
+ const credits = store.inviteStats(inviterId).credits
156
+ 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.`
157
+ 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>`
158
+ 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}`))
159
+ }
103
160
 
104
161
  app.use('*', async (c, next) => {
105
162
  await next()
106
163
  if (!c.res.headers.has('content-security-policy')) for (const [k, v] of Object.entries(SECURITY_HEADERS)) c.header(k, v)
107
164
  })
108
165
 
166
+ // ?ref=<a member's invite code> on any page is their invite link too: the same bi_ref cookie
167
+ // /i/<code> sets, first touch wins, so one sign-up is counted once, in one place (the invites table).
168
+ app.use('*', async (c, next) => {
169
+ const ref = c.req.query('ref')
170
+ if (ref && ref.length <= 40 && !getCookie(c, REF_COOKIE) && store.inviterOf(ref) && !sessionUser(c)) setCookie(c, REF_COOKIE, ref, { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
171
+ await next()
172
+ })
173
+
109
174
  app.get('/health', (c) => c.json({ ok: true, version: VERSION }))
110
175
 
111
176
  // Static files.
@@ -130,8 +195,8 @@ export function createApp(deps: Deps) {
130
195
  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
196
 
132
197
  // 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() })),
198
+ app.get('/', async (c) =>
199
+ 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
200
  )
136
201
  app.get('/top/:window', (c) => {
137
202
  const w = c.req.param('window') as Window
@@ -139,10 +204,30 @@ export function createApp(deps: Deps) {
139
204
  return page(c, boardPage(ctxOf(c), w, store.list(w, 50)))
140
205
  })
141
206
  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')
207
+ // Browse and search, with facets (browse.ts). /c/ and /t/ are one filter each, at an indexable address.
208
+ const browse = (c: Context, extra: Record<string, string> = {}) => {
209
+ const params = new URL(c.req.url).searchParams
210
+ for (const [k, v] of Object.entries(extra)) if (!params.getAll(k).includes(v)) params.append(k, v)
211
+ return search(store.allLive(), parseQuery(params))
212
+ }
213
+ app.get('/search', (c) => page(c, browsePage(ctxOf(c), browse(c))))
214
+ app.get('/c/:category', (c) => {
215
+ const key = c.req.param('category')
216
+ if (!(key in CATEGORY_LABELS)) return c.notFound()
217
+ const label = CATEGORY_LABELS[key]!
218
+ // Further filters go to /search, so /c/<category> stays one page.
219
+ if (new URL(c.req.url).search) return c.redirect(`/search?category=${encodeURIComponent(key)}&${new URL(c.req.url).searchParams}`)
220
+ return page(c, browsePage(ctxOf(c), browse(c, { category: key }), { path: `/c/${key}`, heading: label, intro: `${label}: indie products launched on built.indie` }))
221
+ })
222
+ app.get('/t/:tag', (c) => {
223
+ const tag = normalizeTag(c.req.param('tag'))
224
+ if (!tag) return c.notFound()
225
+ if (tag !== c.req.param('tag')) return c.redirect(`/t/${encodeURIComponent(tag)}`, 301)
226
+ if (new URL(c.req.url).search) return c.redirect(`/search?tag=${encodeURIComponent(tag)}&${new URL(c.req.url).searchParams}`)
227
+ const r = browse(c, { tag })
228
+ return page(c, browsePage(ctxOf(c), r, { path: `/t/${encodeURIComponent(tag)}`, heading: `#${tag}`, intro: `Indie products tagged ${tag}` }), r.total ? 200 : 404)
145
229
  })
230
+ app.get('/tags', (c) => page(c, tagsPage(ctxOf(c), store.tagCounts())))
146
231
  app.get('/help', (c) => page(c, helpPage(ctxOf(c))))
147
232
 
148
233
  // Launching.
@@ -163,21 +248,50 @@ export function createApp(deps: Deps) {
163
248
  })
164
249
 
165
250
  // A product.
166
- const productView = (c: Context<Env>, p: Product) =>
251
+ /** Comments and reviews as the page shows them: public names and karma, never emails. */
252
+ const discussion = (p: Product, me: User | null) => {
253
+ const comments = store.comments(p.id)
254
+ const reviews = store.reviews(p.id)
255
+ const karmaOf = store.karmaOf([...comments.map((x) => x.userId), ...reviews.map((x) => x.userId)])
256
+ const names = new Map<string, string>()
257
+ const who = (id: string) => names.get(id) ?? (names.set(id, store.publicName(id)), names.get(id)!)
258
+ const owner = p.ownerId ?? p.submitterUser
259
+ 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) })
260
+ 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) })
261
+ return { comments: comments.map(cv), reviews: reviews.map(rv), myReview: me ? store.review(p.id, me.id) : null }
262
+ }
263
+ const productView = async (c: Context<Env>, p: Product, extra: { error?: string } = {}, status = 200) =>
167
264
  page(
168
265
  c,
169
266
  productPage(ctxOf(c), {
267
+ ...discussion(p, sessionUser(c)),
268
+ error: extra.error,
269
+ selfListing: (() => {
270
+ const u = sessionUser(c)
271
+ return Boolean(u && (p.ownerId === u.id || p.submitterUser === u.id))
272
+ })(),
170
273
  product: p,
171
274
  award: store.awards(p.id),
172
275
  maker: p.makerId ? store.maker(p.makerId) : null,
173
276
  featuredOk: c.req.query('featured') === '1',
277
+ featuredCredit: c.req.query('featured') === 'credit',
278
+ featuredDaysUsed: c.req.query('featured') === 'days',
279
+ daysBalance: await (async () => {
280
+ const u = sessionUser(c)
281
+ return u && store.canEdit(p, u) ? service.featuredDays(u) : 0
282
+ })(),
174
283
  launched: c.req.query('launched') === '1',
175
284
  canEdit: store.canEdit(p, sessionUser(c)),
176
- chains: config.chains,
285
+ credits: (() => {
286
+ const u = sessionUser(c)
287
+ return u && store.canEdit(p, u) ? store.inviteStats(u.id).credits : 0
288
+ })(),
289
+ buyerEmail: sessionUser(c)?.email ?? '',
177
290
  featureUsd: config.featureUsd,
178
291
  featureDays: config.featureDays,
179
292
  payments: Boolean(config.coinpay),
180
293
  }),
294
+ status,
181
295
  )
182
296
  app.get('/p/:slug', (c) => {
183
297
  const p = store.bySlug(c.req.param('slug'))
@@ -232,17 +346,19 @@ export function createApp(deps: Deps) {
232
346
  }
233
347
  })
234
348
 
235
- // Claiming: the domain's owner proves it in DNS and takes the listing.
349
+ // Claiming: the domain's owner proves it with the badge carrying their badge id, or in DNS, and takes the listing.
236
350
  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)
351
+ const claimBadge = (p: Product, user: User) => claimBadgeSnippet(origin, p, store.badgeId(p.id, user.id))
352
+ const claimMd = (p: Product, user: User) => claimMarkdown(origin, p, claimRecord(p, user), claimBadge(p, user))
353
+ const claimView = (c: Context<Env>, p: Product, user: User, extra: { result?: { ok: boolean; looked: string[]; method?: string } } = {}, status = 200) =>
354
+ page(c, claimPage(ctxOf(c), { product: p, record: claimRecord(p, user), badge: claimBadge(p, user), owner: p.ownerId === user.id, ...extra }), status)
239
355
  // The same instructions as Markdown, to paste to an AI agent with DNS access.
240
356
  app.get('/p/:slug/claim.md', (c) => {
241
357
  const user = sessionUser(c)
242
358
  if (!user) return c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`)
243
359
  const p = store.bySlug(c.req.param('slug'))
244
360
  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' })
361
+ return c.body(claimMd(p, user), 200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'private, no-store' })
246
362
  })
247
363
  app.get('/p/:slug/claim', (c) => {
248
364
  const user = sessionUser(c)
@@ -261,19 +377,64 @@ export function createApp(deps: Deps) {
261
377
  return claimView(c, store.product(p.id)!, user, { result })
262
378
  })
263
379
 
380
+ /**
381
+ * A new member: a scarce invite code (bi_invite) gives both people Featured days, and an
382
+ * invite link (bi_ref, /i/<code> or ?ref=) makes their first live listing earn the inviter a credit.
383
+ * Both write the same invites row, first one wins, so a sign-up is counted once.
384
+ */
385
+ const joined = async (c: Context, user: User, carried: string | null) => {
386
+ const parts = (carried ?? '').split(' ').filter(Boolean)
387
+ const invite = parts.find(isInviteCode) ?? getCookie(c, INVITE_COOKIE)
388
+ const ref = parts.find((x) => !isInviteCode(x)) ?? getCookie(c, REF_COOKIE)
389
+ if (invite) await service.acceptInvite(invite, user).catch((e) => console.error(`[invite] accept failed: ${(e as Error).message}`))
390
+ if (ref) store.recordInvite(user.id, ref)
391
+ for (const name of [REF_COOKIE, INVITE_COOKIE]) deleteCookie(c, name, { path: '/' })
392
+ }
393
+
264
394
  // 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')) })))
395
+ const invitedBy = (c: Context) => {
396
+ const inviter = store.inviterOf(getCookie(c, REF_COOKIE) ?? '')
397
+ return inviter && !sessionUser(c) ? store.publicName(inviter) : null
398
+ }
399
+ 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') ?? ''] })))
400
+ // Creating an account asks for the shared profile fields; plain sign-in never does.
401
+ app.get('/signup', (c) => page(c, loginPage(ctxOf(c), { next: safeNext(c.req.query('next')), invitedBy: invitedBy(c), signup: true })))
402
+ app.get('/vendor-profile.js', (c) => c.body(PROFILE_ENHANCE_SCRIPT, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=86400' }))
403
+ // Sign in with CoinPay (coinpay-signin.ts): /api/v1/coinpay/login and /callback.
404
+ mountCoinPaySignin(app, {
405
+ store,
406
+ auth: deps.auth,
407
+ origin,
408
+ oauth: deps.coinpayOAuth ?? null,
409
+ secure,
410
+ sessionUser,
411
+ // A new member who came through an invite link counts, as with a magic link.
412
+ onNewMember: (c, user) => void joined(c, user, null),
413
+ })
266
414
  app.post('/login', async (c) => {
267
415
  const form = (await c.req.parseBody()) as Record<string, string>
268
416
  const next = safeNext(form.next)
269
- 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))
417
+ const signup = form.mode === 'signup'
418
+ if (!validEmail(form.email)) return page(c, loginPage(ctxOf(c), { next, error: 'That does not look like an email address.', email: form.email, signup, values: form }), 400)
419
+ // The signup fields are checked before any link exists; they ride on the link and are saved on verify.
420
+ let profile: object | null = null
421
+ if (signup) {
422
+ const check = validateProfile(form)
423
+ if (!check.ok) return page(c, loginPage(ctxOf(c), { next, error: 'Some fields need fixing.', email: form.email, signup, values: form, errors: check.errors }), 400)
424
+ profile = check.profile
425
+ }
426
+ // The invites ride in the link (space-separated), so opening it in another browser still counts.
427
+ if (!crossSite(c) && readLimit.allow(`mail:${ip(c)}`)) await deps.auth.sendLink(form.email ?? '', next, here(c), [getCookie(c, INVITE_COOKIE), getCookie(c, REF_COOKIE)].filter(Boolean).join(' ') || null, profile)
271
428
  return page(c, loginPage(ctxOf(c), { next, sent: true }))
272
429
  })
273
- app.get('/auth/verify', (c) => {
430
+ app.get('/auth/verify', async (c) => {
274
431
  const r = deps.auth.consumeLink(c.req.query('token') ?? '')
275
432
  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 })
433
+ setCookie(c, SESSION_COOKIE, deps.auth.createSession(r.user.id), { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 90 * 86400 })
434
+ if (r.created) await joined(c, r.user, r.ref ?? null)
435
+ else for (const name of [REF_COOKIE, INVITE_COOKIE]) deleteCookie(c, name, { path: '/' })
436
+ // The signup form's fields, now that the account exists. A failure never costs the sign-in: /me asks again.
437
+ if (r.profile) await saveSignupProfile(await profiles.ready, r.user.id, r.profile, { email: r.user.email }).catch((error) => console.error(`[auth] signup profile save failed: ${(error as Error).message}`))
277
438
  return c.redirect(r.next ?? '/me')
278
439
  })
279
440
  app.post('/logout', (c) => {
@@ -281,22 +442,142 @@ export function createApp(deps: Deps) {
281
442
  deleteCookie(c, SESSION_COOKIE, { path: '/' })
282
443
  return c.redirect('/')
283
444
  })
284
- app.get('/me', (c) => {
445
+ const signinInfo = (c: Context, user: User) => ({
446
+ coinpay: store.identities(user.id).find((i) => i.provider === COINPAY) ?? null,
447
+ emailless: isPlaceholder(user.email),
448
+ 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,
449
+ })
450
+ app.get('/me', async (c) => {
285
451
  const user = sessionUser(c)
286
- return user ? page(c, mePage(ctxOf(c), { user, products: store.mine(user.id) })) : toLogin(c)
452
+ 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, daysBalance: await service.featuredDays(user), ...signinInfo(c, user), profile: await (await profiles.ready).get(user.id), detailsNote: c.req.query('details') === 'saved' ? 'Your details are saved.' : null })) : toLogin(c)
287
453
  })
288
- app.post('/me/key', (c) => {
454
+ // "Have a code?": a credit code (@profullstack/redeem-credits) adds Featured days to the balance.
455
+ app.post('/me/redeem', async (c) => {
289
456
  const user = sessionUser(c)
290
457
  if (!user) return toLogin(c)
291
458
  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) }))
459
+ const code = String(((await c.req.parseBody()) as Record<string, string>).code ?? '')
460
+ let note: string
461
+ let status = 200
462
+ if (!redeemLimit.allow(`redeem:${user.id}`) || !redeemLimit.allow(`redeem-ip:${ip(c)}`)) (note = 'Too many tries. Wait a few minutes.'), (status = 429)
463
+ else
464
+ try {
465
+ note = (await service.redeem(user, code)).message
466
+ } catch (e) {
467
+ note = e instanceof Error ? e.message : String(e)
468
+ status = 400
469
+ }
470
+ return 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, daysBalance: await service.featuredDays(user), redeemNote: note, redeemError: status !== 200, ...signinInfo(c, user), profile: await (await profiles.ready).get(user.id) }), status)
471
+ })
472
+ // Spend Featured days from the balance on a listing you own.
473
+ app.post('/p/:slug/feature-days', async (c) => {
474
+ const user = sessionUser(c)
475
+ if (!user) return toLogin(c)
476
+ const p = store.bySlug(c.req.param('slug'))
477
+ if (!p || p.status === 'hidden') return c.notFound()
478
+ if (crossSite(c) || !store.canEdit(p, user)) return c.redirect(`/p/${p.slug}`)
479
+ const days = Number(((await c.req.parseBody()) as Record<string, string>).days ?? 0)
480
+ try {
481
+ await service.spendFeaturedDays(p, user, days)
482
+ return c.redirect(`/p/${p.slug}?featured=days`)
483
+ } catch (e) {
484
+ return productView(c, p, { error: e instanceof Error ? e.message : String(e) }, 400)
485
+ }
486
+ })
487
+ // The shared profile (@profullstack/profile): the /me form, and GET/PUT/PATCH /api/v1/me/profile for a session or an API key.
488
+ app.post('/me/profile', async (c) => {
489
+ const user = sessionUser(c)
490
+ if (!user) return toLogin(c)
491
+ if (crossSite(c)) return c.redirect('/me')
492
+ const form = (await c.req.parseBody()) as Record<string, string>
493
+ const email = isPlaceholder(user.email) ? null : user.email
494
+ const check = validateProfile({ ...form, email }, { required: ['first_name', 'last_name'] })
495
+ if (!check.ok) return 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, daysBalance: await service.featuredDays(user), ...signinInfo(c, user), profile: form, detailsNote: 'Some fields need fixing.', detailsError: true, detailsErrors: check.errors }), 400)
496
+ await (await profiles.ready).save(user.id, check.profile)
497
+ return c.redirect('/me?details=saved#details')
498
+ })
499
+ app.post('/me/key', async (c) => {
500
+ const user = sessionUser(c)
501
+ if (!user) return toLogin(c)
502
+ if (crossSite(c)) return c.redirect('/me')
503
+ 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, daysBalance: await service.featuredDays(user), ...signinInfo(c, user) }))
504
+ })
505
+
506
+ // Invites: a link per member; a new member it brings in who launches earns the inviter a free Featured week.
507
+ app.get('/i/:code', (c) => {
508
+ const code = c.req.param('code')
509
+ // A scarce invite code: whoever made it stays private until it is used.
510
+ if (isInviteCode(code)) {
511
+ if (!sessionUser(c)) setCookie(c, INVITE_COOKIE, code.trim().toUpperCase(), { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
512
+ return page(c, inviteLanding(ctxOf(c), { inviter: null, bonusDays: INVITE_BONUS_DAYS }))
513
+ }
514
+ const inviter = store.inviterOf(code)
515
+ if (!inviter) return c.redirect('/invite')
516
+ if (!sessionUser(c)) setCookie(c, REF_COOKIE, code, { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
517
+ return page(c, inviteLanding(ctxOf(c), { inviter: store.publicName(inviter) }))
518
+ })
519
+ const inviteCodes = async (user: User) => ({ quota: await service.affiliates.quota(user.id), invites: await service.affiliates.invites(user.id), bonusDays: INVITE_BONUS_DAYS })
520
+ const inviteView = async (c: Context<Env>, extra: { flash?: string; error?: string; newCode?: string } = {}, status = 200) => {
521
+ const user = sessionUser(c)
522
+ const me = user
523
+ ? { 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'), codes: { ...(await inviteCodes(user)), newLink: extra.newCode ? `${origin}/i/${extra.newCode}` : undefined }, ...extra }
524
+ : undefined
525
+ return page(c, invitePage(ctxOf(c), { featureDays: config.featureDays, featureUsd: config.featureUsd, inviters: await inviters(10), me }), status)
526
+ }
527
+ app.get('/invite', (c) => inviteView(c))
528
+ app.get('/invites', (c) => c.redirect('/invite#codes'))
529
+ // A scarce invite code: three each, single use.
530
+ app.post('/invite/code', async (c) => {
531
+ const user = sessionUser(c)
532
+ if (!user) return toLogin(c)
533
+ if (crossSite(c)) return c.redirect('/invite')
534
+ const note = String(((await c.req.parseBody()) as Record<string, string>).note ?? '').slice(0, 80)
535
+ try {
536
+ const invite = await service.affiliates.createInvite(user.id, note)
537
+ return inviteView(c, { newCode: invite.code })
538
+ } catch (e) {
539
+ return inviteView(c, { error: (e as Error).message === 'no invites left' ? `You have used all ${INVITES_PER_PERSON} invite codes.` : (e as Error).message }, 400)
540
+ }
541
+ })
542
+ app.post('/invite/name', async (c) => {
543
+ const user = sessionUser(c)
544
+ if (!user) return toLogin(c)
545
+ if (crossSite(c)) return c.redirect('/invite')
546
+ const name = String(((await c.req.parseBody()) as Record<string, string>).displayName ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
547
+ store.setDisplayName(user.id, name || null)
548
+ board.invalidate()
549
+ return inviteView(c, { flash: name ? `Saved. The board shows you as ${name}.` : 'Cleared. The board shows your best listing instead.' })
550
+ })
551
+ app.post('/p/:slug/credit', (c) => {
552
+ const user = sessionUser(c)
553
+ if (!user) return toLogin(c)
554
+ const p = store.bySlug(c.req.param('slug'))
555
+ if (!p || p.status === 'hidden') return c.notFound()
556
+ if (crossSite(c) || !store.canEdit(p, user)) return c.redirect(`/p/${p.slug}`)
557
+ try {
558
+ service.redeemCredit(p, user)
559
+ return c.redirect(`/p/${p.slug}?featured=credit`)
560
+ } catch (e) {
561
+ return inviteView(c, { error: e instanceof Error ? e.message : String(e) }, 400)
562
+ }
293
563
  })
294
564
 
565
+ // The leaderboard: our page, then the package answers everything under /leaderboard (JSON, RSS, share cards, widget).
566
+ app.get('/leaderboard', async (c) => {
567
+ const user = sessionUser(c)
568
+ const which = c.req.query('board') === 'invites' ? 'invites' : 'karma'
569
+ // An empty week shows all time instead, so the board never opens blank.
570
+ const period = (await rowsOf(which, 'week', 1)).length ? 'week' : 'all'
571
+ 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 }))
572
+ })
573
+ app.use('/leaderboard.json', leaderboard(board))
574
+ app.use('/leaderboard/*', leaderboard(board))
575
+
295
576
  app.post('/p/:slug/vote', (c) => {
296
577
  const p = live(c.req.param('slug'))
297
578
  if (!p) return wantsJson(c) ? c.json({ error: 'no such product' }, 404) : c.notFound()
298
579
  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))
580
+ const counted = voteLimit.allow(`vote:${ip(c)}`) && service.vote(p, voter(c), sessionUser(c))
300
581
  const now = store.product(p.id)!
301
582
  if (wantsJson(c)) return c.json({ slug: now.slug, votes: now.votes, counted, voted: true })
302
583
  const back = c.req.header('referer')
@@ -307,6 +588,66 @@ export function createApp(deps: Deps) {
307
588
  return c.redirect(to)
308
589
  })
309
590
 
591
+ // Comments, reviews and tracked shares: signed-in members, from this site only.
592
+ const back = (p: Product, hash: string) => `/p/${p.slug}#${hash}`
593
+ app.post('/p/:slug/comments', async (c) => {
594
+ const user = sessionUser(c)
595
+ if (!user) return toLogin(c)
596
+ const p = live(c.req.param('slug'))
597
+ if (!p) return c.notFound()
598
+ if (crossSite(c)) return c.redirect(back(p, 'comments'))
599
+ if (!commentLimit.allow(`comment:${user.id}`)) return productView(c, p, { error: 'That is a lot of comments. Wait a few minutes.' }, 429)
600
+ const form = (await c.req.parseBody()) as Record<string, string>
601
+ try {
602
+ const made = service.comment(p, user, form.body ?? '', form.parent || null)
603
+ return c.redirect(back(p, `c-${made.id}`))
604
+ } catch (e) {
605
+ if (e instanceof InputError) return productView(c, p, { error: e.message }, 400)
606
+ throw e
607
+ }
608
+ })
609
+ app.post('/p/:slug/comments/:id/delete', (c) => {
610
+ const user = sessionUser(c)
611
+ if (!user) return toLogin(c)
612
+ const p = store.bySlug(c.req.param('slug'))
613
+ if (!p) return c.notFound()
614
+ if (crossSite(c)) return c.redirect(back(p, 'comments'))
615
+ const cm = store.comment(c.req.param('id'))
616
+ if (!cm || cm.productId !== p.id) return c.redirect(back(p, 'comments'))
617
+ try {
618
+ service.deleteComment(cm.id, user)
619
+ } catch (e) {
620
+ if (!(e instanceof InputError)) throw e
621
+ }
622
+ return c.redirect(back(p, 'comments'))
623
+ })
624
+ app.post('/p/:slug/review', async (c) => {
625
+ const user = sessionUser(c)
626
+ if (!user) return toLogin(c)
627
+ const p = live(c.req.param('slug'))
628
+ if (!p) return c.notFound()
629
+ if (crossSite(c)) return c.redirect(back(p, 'reviews'))
630
+ const form = (await c.req.parseBody()) as Record<string, string>
631
+ try {
632
+ if (form.action === 'delete') service.deleteReview(p, user.id)
633
+ else service.review(p, user, form.rating, form.body)
634
+ return c.redirect(back(p, 'reviews'))
635
+ } catch (e) {
636
+ if (e instanceof InputError) return productView(c, p, { error: e.message }, 400)
637
+ throw e
638
+ }
639
+ })
640
+ // A share button: app.js sends a beacon here before the share window opens.
641
+ app.post('/p/:slug/share', (c) => {
642
+ const p = live(c.req.param('slug'))
643
+ if (!p) return c.json({ error: 'no such product' }, 404)
644
+ if (crossSite(c)) return c.json({ error: 'share from built.indie itself' }, 403)
645
+ const net = c.req.query('net') ?? ''
646
+ if (!(SHARE_NETWORKS as readonly string[]).includes(net)) return c.json({ error: `net is one of ${SHARE_NETWORKS.join(', ')}` }, 400)
647
+ if (!shareLimit.allow(`share:${ip(c)}`)) return c.json({ counted: false })
648
+ return c.json(service.share(p, sessionUser(c), net))
649
+ })
650
+
310
651
  app.post('/p/:slug/verify', async (c) => {
311
652
  const p = live(c.req.param('slug'))
312
653
  if (!p) return c.notFound()
@@ -320,8 +661,9 @@ export function createApp(deps: Deps) {
320
661
  if (!p) return c.notFound()
321
662
  const form = (await c.req.parseBody()) as Record<string, string>
322
663
  try {
323
- const r = await service.feature(p, form.chain ?? '')
324
- return c.redirect(r.checkoutUrl)
664
+ const user = sessionUser(c)
665
+ const r = await service.feature(p, { email: form.email || user?.email || '', accountId: user?.id ?? null })
666
+ return c.redirect(r.checkoutUrl, 303)
325
667
  } catch (e) {
326
668
  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
669
  }
@@ -347,8 +689,8 @@ export function createApp(deps: Deps) {
347
689
  })
348
690
 
349
691
  // 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$/, ''))
692
+ const badgeImage = (c: Context<Env>, p: Product | null, unlisted = false) => {
693
+ 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
694
  if (!p) return c.body('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>', 404, { 'content-type': 'image/svg+xml' })
353
695
  return c.body(badgeSvg(p, store.awards(p.id), c.req.query('theme') === 'light' ? 'light' : 'dark'), 200, {
354
696
  'content-type': 'image/svg+xml; charset=utf-8',
@@ -357,6 +699,20 @@ export function createApp(deps: Deps) {
357
699
  'cross-origin-resource-policy': 'cross-origin',
358
700
  'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
359
701
  })
702
+ }
703
+ app.get('/badge/:file{[a-z0-9-]+\\.svg}', (c) => badgeImage(c, live(c.req.param('file').replace(/\.svg$/, ''))))
704
+ // The same badge by badge id: the one the claim page hands out. On a homepage it claims the listing for that account.
705
+ // By site, for snippets shared across many sites (the Profullstack footer): ?site=<homepage>.
706
+ // A site with no listing (built.indie itself, a brand-new site) gets an invitation to launch instead.
707
+ app.get('/for.svg', (c) => badgeImage(c, store.bySite(c.req.query('site') ?? ''), true))
708
+ app.get('/for', (c) => {
709
+ const p = store.bySite(c.req.query('site') ?? '')
710
+ return c.redirect(p ? `/p/${p.slug}?ref=badge` : '/submit?ref=badge')
711
+ })
712
+ app.get('/images.svg', (c) => {
713
+ const b = store.badge(c.req.query('id') ?? '')
714
+ const p = b ? store.product(b.productId) : null
715
+ return badgeImage(c, p?.status === 'live' ? p : null)
360
716
  })
361
717
 
362
718
  app.get('/makers/:id', (c) => {
@@ -377,12 +733,13 @@ export function createApp(deps: Deps) {
377
733
  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
734
  })
379
735
  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}`)]
736
+ const all = search(store.allLive(), parseQuery({}), { limit: 0 })
737
+ 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
738
  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
739
  })
383
740
  app.get('/robots.txt', (c) => c.text(`User-agent: *\nAllow: /\nDisallow: /p/*/edit\nSitemap: ${origin}/sitemap.xml\n`))
384
741
  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`),
742
+ 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
743
  )
387
744
  app.get('/.well-known/openprofile.md', (c) =>
388
745
  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 +766,8 @@ export function createApp(deps: Deps) {
409
766
  domain: p.domain,
410
767
  status: p.status,
411
768
  votes: p.votes,
769
+ reviews: p.reviews,
770
+ rating: p.rating,
412
771
  tags: p.tags,
413
772
  logo: p.logo ? `${origin}/m/${p.id}/${p.logo}` : null,
414
773
  card_image: p.ogImage ? `${origin}/m/${p.id}/${p.ogImage}` : null,
@@ -452,11 +811,12 @@ export function createApp(deps: Deps) {
452
811
  })
453
812
 
454
813
  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)
814
+ const admin = isAdmin(c)
815
+ if (!admin && !readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many launches from you; wait a few minutes' }, 429)
456
816
  const b = await body(c)
457
817
  try {
458
818
  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)
819
+ const r = await service.submit({ url: String(b.url ?? ''), makerName: b.maker_name, makerProfile: b.maker_profile }, store.voterFor(ip(c)), user, admin)
460
820
  if (r.existing) return c.json({ existing: true, product: apiProduct(r.product) }, 200)
461
821
  let p = r.product
462
822
  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,12 +830,42 @@ export function createApp(deps: Deps) {
470
830
  app.get('/api/v1/products', (c) => {
471
831
  const w = (c.req.query('window') ?? 'today') as Window
472
832
  const q = c.req.query('q')
473
- if (!q && !WINDOWS.includes(w)) return c.json({ error: `window is one of ${WINDOWS.join(', ')}` }, 400)
833
+ const tag = c.req.query('tag') !== undefined ? normalizeTag(c.req.query('tag')!) : undefined
834
+ if (tag === null) return c.json({ error: 'tag is 2 to 24 letters, digits, spaces or + # . -' }, 400)
835
+ if (!q && !tag && !WINDOWS.includes(w)) return c.json({ error: `window is one of ${WINDOWS.join(', ')}` }, 400)
474
836
  const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 30) || 30))
475
837
  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': '*' })
838
+ const products = tag ? store.byTag(tag, limit) : q ? search(store.allLive(), parseQuery({ q }), { limit }).products : store.list(w, limit, offset)
839
+ return c.json({ window: tag ? 'tag' : q ? 'search' : w, ...(tag ? { tag } : {}), products: products.map(apiProduct) }, 200, { 'access-control-allow-origin': '*' })
840
+ })
841
+
842
+ // Search with facets: the browse page as data. Same parameters as /search.
843
+ app.get('/api/v1/search', (c) => {
844
+ const params = new URL(c.req.url).searchParams
845
+ const limit = Math.min(100, Math.max(1, Number(params.get('limit') ?? 30) || 30))
846
+ const r = search(store.allLive(), parseQuery(params), { limit })
847
+ const qs = queryString(r.query)
848
+ return c.json(
849
+ {
850
+ query: r.query,
851
+ total: r.total,
852
+ page: r.query.page,
853
+ pages: Math.ceil(r.total / limit),
854
+ products: r.products.map((p) => ({ ...apiProduct(p), categories: r.categories.get(p.id) ?? [] })),
855
+ ...(params.get('facets') === '0' ? {} : { facets: r.facets }),
856
+ web: `${origin}/search${qs ? `?${qs}` : ''}`,
857
+ },
858
+ 200,
859
+ { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=30' },
860
+ )
861
+ })
862
+ app.get('/api/v1/categories', (c) => {
863
+ const r = search(store.allLive(), parseQuery({}), { limit: 0 })
864
+ 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': '*' })
477
865
  })
478
866
 
867
+ app.get('/api/v1/tags', (c) => c.json({ tags: store.tagCounts() }, 200, { 'access-control-allow-origin': '*' }))
868
+
479
869
  app.get('/api/v1/products/:slug', (c) => {
480
870
  const p = store.bySlug(c.req.param('slug'))
481
871
  const mine = owned(c)
@@ -503,8 +893,10 @@ export function createApp(deps: Deps) {
503
893
  if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many reads from you; wait a few minutes' }, 429)
504
894
  if (action === 'refetch') return c.json(apiProduct(await service.refetch(p)))
505
895
  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 })
896
+ const b = await body(c)
897
+ const user = apiUser(c)
898
+ const r = await service.feature(p, { email: String(b.email ?? user?.email ?? ''), accountId: user?.id ?? null })
899
+ 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
900
  } catch (e) {
509
901
  return apiError(c, e)
510
902
  }
@@ -514,7 +906,7 @@ export function createApp(deps: Deps) {
514
906
  const p = live(c.req.param('slug'))
515
907
  if (!p) return c.json({ error: 'no such product' }, 404)
516
908
  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))
909
+ const counted = voteLimit.allow(`vote:${ip(c)}`) && service.vote(p, voter(c), apiUser(c))
518
910
  return c.json({ slug: p.slug, votes: store.product(p.id)!.votes, counted })
519
911
  })
520
912
 
@@ -524,6 +916,9 @@ export function createApp(deps: Deps) {
524
916
  const p = store.bySlug(c.req.param('slug'))
525
917
  if (!p) return c.json({ error: 'no such product' }, 404)
526
918
  store.update(p.id, { status: 'hidden' })
919
+ store.revokeInvite(p.id)
920
+ service.karma.takenDown(p.id)
921
+ board.invalidate()
527
922
  return c.json({ hidden: true })
528
923
  }
529
924
  const p = owned(c)
@@ -539,7 +934,7 @@ export function createApp(deps: Deps) {
539
934
  if (!user) return c.json(NEEDS_ACCOUNT, 401)
540
935
  const p = store.bySlug(c.req.param('slug'))
541
936
  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)) })
937
+ return c.json({ owner: p.ownerId === user.id, record: claimRecord(p, user), badge: claimBadge(p, user), markdown: claimMd(p, user) })
543
938
  })
544
939
  app.post('/api/v1/products/:slug/claim', async (c) => {
545
940
  const user = apiUser(c)
@@ -548,12 +943,169 @@ export function createApp(deps: Deps) {
548
943
  if (!p || p.status === 'hidden') return c.json({ error: 'no such product' }, 404)
549
944
  if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many checks; wait a few minutes' }, 429)
550
945
  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)) }) })
946
+ 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) }) })
947
+ })
948
+ mountProfileRoutes(app, {
949
+ store: profiles.store,
950
+ getUser: (_req: Request, c: Context) => {
951
+ const user = sessionUser(c) ?? apiUser(c)
952
+ return user ? { id: user.id, email: isPlaceholder(user.email) ? null : user.email } : null
953
+ },
954
+ })
955
+ app.get('/api/v1/me', async (c) => {
956
+ const user = apiUser(c)
957
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
958
+ 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: await apiInvite(user), featured_days: await service.featuredDays(user), karma: apiKarma(user) })
959
+ })
960
+ const apiInvite = async (user: User) => {
961
+ const s = store.inviteStats(user.id)
962
+ const codes = await inviteCodes(user)
963
+ return {
964
+ link: inviteLink(user),
965
+ code: s.code,
966
+ invited: s.invited,
967
+ launched: s.launched,
968
+ credits: s.credits,
969
+ karma_credits: s.karmaCredits,
970
+ used: s.used,
971
+ feature_days: config.featureDays,
972
+ // Scarce single-use invite codes: the code itself is shown once, when made.
973
+ invite_codes: { quota: codes.quota, bonus_days: codes.bonusDays, invites: codes.invites.map((i) => ({ id: i.id, note: i.note, created_at: new Date(i.createdAt).toISOString(), expires_at: i.expiresAt ? new Date(i.expiresAt).toISOString() : null, used: Boolean(i.usedAt), used_at: i.usedAt ? new Date(i.usedAt).toISOString() : null })) },
974
+ }
975
+ }
976
+ app.get('/api/v1/invites', async (c) => {
977
+ const user = apiUser(c)
978
+ return user ? c.json(await apiInvite(user)) : c.json(NEEDS_ACCOUNT, 401)
979
+ })
980
+ app.post('/api/v1/invites', async (c) => {
981
+ const user = apiUser(c)
982
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
983
+ try {
984
+ const invite = await service.affiliates.createInvite(user.id, String((await body(c)).note ?? '').slice(0, 80))
985
+ return c.json({ link: `${origin}/i/${invite.code}`, code: invite.code, expires_at: invite.expiresAt ? new Date(invite.expiresAt).toISOString() : null, left: (await service.affiliates.quota(user.id)).left, bonus_days: INVITE_BONUS_DAYS }, 201)
986
+ } catch (e) {
987
+ return c.json({ error: (e as Error).message === 'no invites left' ? `You have used all ${INVITES_PER_PERSON} invite codes.` : (e as Error).message }, 400)
988
+ }
989
+ })
990
+ app.post('/api/v1/redeem', async (c) => {
991
+ const user = apiUser(c)
992
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
993
+ if (!redeemLimit.allow(`redeem:${user.id}`) || !redeemLimit.allow(`redeem-ip:${ip(c)}`)) return c.json({ error: 'too many tries; wait a few minutes' }, 429)
994
+ try {
995
+ const r = await service.redeem(user, String((await body(c)).code ?? ''))
996
+ return c.json({ ok: true, message: r.message, featured_days: r.days })
997
+ } catch (e) {
998
+ return apiError(c, e)
999
+ }
1000
+ })
1001
+ app.post('/api/v1/products/:slug/feature-days', async (c) => {
1002
+ const user = apiUser(c)
1003
+ const p = owned(c)
1004
+ if (!user || !p) return c.json(NEEDS_KEY, 401)
1005
+ try {
1006
+ const after = await service.spendFeaturedDays(p, user, Number((await body(c)).days ?? 0))
1007
+ return c.json({ featured_until: new Date(after.featuredUntil!).toISOString(), featured_days: await service.featuredDays(user) })
1008
+ } catch (e) {
1009
+ return apiError(c, e)
1010
+ }
1011
+ })
1012
+ app.post('/api/v1/products/:slug/credit', (c) => {
1013
+ const user = apiUser(c)
1014
+ const p = owned(c)
1015
+ if (!user || !p) return c.json(NEEDS_KEY, 401)
1016
+ try {
1017
+ const after = service.redeemCredit(p, user)
1018
+ return c.json({ featured_until: new Date(after.featuredUntil!).toISOString(), credits: store.inviteStats(user.id).credits })
1019
+ } catch (e) {
1020
+ return apiError(c, e)
1021
+ }
1022
+ })
1023
+
1024
+ // Karma, comments, reviews, shares.
1025
+ const apiKarma = (user: User) => {
1026
+ const k = service.karma.summary(user.id)
1027
+ 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` }
1028
+ }
1029
+ app.get('/api/v1/karma', (c) => {
1030
+ const user = apiUser(c)
1031
+ 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)
1032
+ })
1033
+ app.get('/api/v1/leaderboard', async (c) => {
1034
+ const which = c.req.query('board') === 'invites' ? 'invites' : 'karma'
1035
+ const period = (['week', 'month', 'all'] as const).find((x) => x === c.req.query('period')) ?? 'week'
1036
+ const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 25) || 25))
1037
+ const top = await board.top({ board: which, period, limit })
1038
+ 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': '*' })
1039
+ })
1040
+ 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}` })
1041
+ app.get('/api/v1/products/:slug/comments', (c) => {
1042
+ const p = live(c.req.param('slug'))
1043
+ if (!p) return c.json({ error: 'no such product' }, 404)
1044
+ 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': '*' })
552
1045
  })
553
- app.get('/api/v1/me', (c) => {
1046
+ app.post('/api/v1/products/:slug/comments', async (c) => {
554
1047
  const user = apiUser(c)
555
1048
  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 })) })
1049
+ const p = live(c.req.param('slug'))
1050
+ if (!p) return c.json({ error: 'no such product' }, 404)
1051
+ if (!commentLimit.allow(`comment:${user.id}`)) return c.json({ error: 'too many comments; wait a few minutes' }, 429)
1052
+ const b = await body(c)
1053
+ try {
1054
+ const made = service.comment(p, user, String(b.body ?? b.text ?? ''), b.parent ? String(b.parent) : null)
1055
+ const view = discussion(p, user).comments.find((x) => x.id === made.id)!
1056
+ return c.json({ comment: apiComment(p, view), karma: store.karmaTotal(user.id) }, 201)
1057
+ } catch (e) {
1058
+ return apiError(c, e)
1059
+ }
1060
+ })
1061
+ app.delete('/api/v1/comments/:id', (c) => {
1062
+ const admin = isAdmin(c)
1063
+ const user = apiUser(c)
1064
+ if (!admin && !user) return c.json(NEEDS_ACCOUNT, 401)
1065
+ try {
1066
+ service.deleteComment(c.req.param('id'), user, admin)
1067
+ return c.json({ deleted: true })
1068
+ } catch (e) {
1069
+ return apiError(c, e)
1070
+ }
1071
+ })
1072
+ const apiReview = (x: ReviewView) => ({ rating: x.rating, body: x.body, author: x.author, karma: x.karma, at: new Date(x.at).toISOString() })
1073
+ app.get('/api/v1/products/:slug/reviews', (c) => {
1074
+ const p = live(c.req.param('slug'))
1075
+ if (!p) return c.json({ error: 'no such product' }, 404)
1076
+ return c.json({ slug: p.slug, rating: p.rating, count: p.reviews, reviews: discussion(p, null).reviews.map(apiReview) }, 200, { 'access-control-allow-origin': '*' })
1077
+ })
1078
+ app.post('/api/v1/products/:slug/reviews', async (c) => {
1079
+ const user = apiUser(c)
1080
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
1081
+ const p = live(c.req.param('slug'))
1082
+ if (!p) return c.json({ error: 'no such product' }, 404)
1083
+ const b = await body(c)
1084
+ try {
1085
+ const r = service.review(p, user, b.rating, b.body ?? b.text ?? '')
1086
+ const now = store.product(p.id)!
1087
+ return c.json({ review: { rating: r.rating, body: r.body }, product: { rating: now.rating, count: now.reviews }, karma: store.karmaTotal(user.id) }, 201)
1088
+ } catch (e) {
1089
+ return apiError(c, e)
1090
+ }
1091
+ })
1092
+ app.delete('/api/v1/products/:slug/reviews', (c) => {
1093
+ const user = apiUser(c)
1094
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
1095
+ const p = store.bySlug(c.req.param('slug'))
1096
+ if (!p) return c.json({ error: 'no such product' }, 404)
1097
+ return c.json({ deleted: service.deleteReview(p, user.id) })
1098
+ })
1099
+ app.post('/api/v1/products/:slug/share', async (c) => {
1100
+ const p = live(c.req.param('slug'))
1101
+ if (!p) return c.json({ error: 'no such product' }, 404)
1102
+ const b = await body(c)
1103
+ try {
1104
+ if (!shareLimit.allow(`share:${ip(c)}`)) return c.json({ counted: false })
1105
+ return c.json(service.share(p, apiUser(c), String(b.network ?? c.req.query('net') ?? '')))
1106
+ } catch (e) {
1107
+ return apiError(c, e)
1108
+ }
557
1109
  })
558
1110
 
559
1111
  app.get('/api/v1/makers/:id', (c) => {
@@ -613,13 +1165,12 @@ export function configFromEnv(env: Record<string, string | undefined> = process.
613
1165
  env.COINPAY_API_KEY && env.COINPAY_BUSINESS_ID
614
1166
  ? { 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
1167
  : null
616
- const chains = (env.COINPAY_CHAINS ?? '').split(/[\s,]+/).filter(Boolean)
617
1168
  return {
618
1169
  origin: (env.BUILTINDIE_ORIGIN ?? PUBLIC_ORIGIN).replace(/\/+$/, ''),
619
1170
  coinpay,
620
1171
  featureUsd: Number(env.FEATURE_USD ?? 19),
621
1172
  featureDays: Number(env.FEATURE_DAYS ?? 7),
622
- chains: chains.length ? chains : DEFAULT_CHAINS,
1173
+ karmaPerCredit: Number(env.KARMA_PER_CREDIT ?? 500) || 500,
623
1174
  screenshots: env.BUILTINDIE_SCREENSHOTS !== 'off',
624
1175
  }
625
1176
  }
@@ -631,13 +1182,20 @@ export function serve(options: { port?: number; hostname?: string } = {}) {
631
1182
  const media = new Media(env.BUILTINDIE_MEDIA ?? `${dataDir}/media`)
632
1183
  const service = new BuiltIndie(store, media, { ...configFromEnv(env), claimSecret: env.BUILTINDIE_KEY })
633
1184
  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 })
1185
+ const app = createApp({ service, auth: new Auth(store, mailer), adminKey: env.BUILTINDIE_ADMIN_KEY, mailer, coinpayOAuth: coinpayOAuthFromEnv(env) })
635
1186
  const relays = (env.BUILTINDIE_RELAY_IPS ?? '23.95.228.174,127.0.0.1,::1').split(/[\s,]+/).filter(Boolean)
636
1187
  startRingRefresh()
1188
+ startPitFooterRefresh('built.indie')
637
1189
  // Drafts nobody launched within a day go, pictures too.
638
1190
  const sweep = () => void Promise.all(store.sweepDrafts().map((id) => media.remove(id))).catch(() => {})
639
1191
  sweep()
640
1192
  setInterval(sweep, 3600_000).unref?.()
1193
+ // Badges placed since launch only count once someone presses Verify; look again every 6 hours, a few at a time.
1194
+ const reverify = async () => {
1195
+ for (const p of store.unverified()) await service.verify(p).catch(() => false)
1196
+ }
1197
+ setTimeout(() => void reverify(), 60_000).unref?.()
1198
+ setInterval(() => void reverify(), 6 * 3600_000).unref?.()
641
1199
  return Bun.serve({
642
1200
  port: options.port ?? Number(env.PORT ?? 8798),
643
1201
  hostname: options.hostname ?? env.HOST ?? '127.0.0.1',
@@ -651,5 +1209,6 @@ export function serve(options: { port?: number; hostname?: string } = {}) {
651
1209
 
652
1210
  if (import.meta.main) {
653
1211
  const server = serve()
1212
+ startRingFooterRefresh()
654
1213
  console.log(`built.indie listening on http://${server.hostname}:${server.port}`)
655
1214
  }