Files
hanzo.ai/scripts/sync-rewards.ts

70 lines
3.4 KiB
TypeScript

/**
* The rewards rules, read once per build: what earns points, what each earns,
* which points redeem, the caps, the token milestones, the levels, the badges,
* the board's tabs and where a badge is claimed.
*
* GET https://api.hanzo.ai/v1/rewards/rules
*
* The cloud's rules table (hanzo-inc/cloud apps/rewards) is the one source:
* the cloud awards points by it and every page that says what earns what
* prints this snapshot of it, so the copy cannot drift from the awards. The
* pages also ask the endpoint when they open (lib/hanzo/rewards `useRules`),
* so a change the owner makes reaches a reader before the next build does.
*
* Two rules, the same as every sync in this directory:
*
* never fail the build the endpoint does not answer -> keep the snapshot.
* never take a stranger an answer that is not a rules table is refused, so
* an error page can never become what the site prints.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const OUT = join(here, '..', 'lib', 'data', 'rewards-rules.json')
const URL = 'https://api.hanzo.ai/v1/rewards/rules'
const keep = (why: string): never => {
console.log(` rewards: ${why} — keeping the committed snapshot`)
process.exit(0)
}
/**
* A table is the cloud's Rules (apps/rewards rules.go): its kinds, sources,
* tiers, levels, badges and tabs non-empty lists, its caps a list, a redeem rate,
* a chain, and no payouts. An answer of an earlier shape is refused, so the
* pages never print fields they no longer read.
*/
const table = (raw: unknown): raw is { kinds: { id: string; source: string; points: number }[] } => {
if (!raw || typeof raw !== 'object') return false
const r = raw as Record<string, unknown>
const list = (k: string) => Array.isArray(r[k]) && (r[k] as unknown[]).length > 0
if (!['kinds', 'sources', 'tiers', 'levels', 'badges', 'tabs'].every(list) || !Array.isArray(r.caps)) return false
const redeem = r.redeem as Record<string, unknown> | undefined
if (typeof redeem?.pointsPerDollar !== 'number' || !r.chain || typeof r.chain !== 'object' || r.payouts !== false || typeof r.disclosure !== 'string') return false
return (r.kinds as unknown[]).every((k) => {
const o = k as Record<string, unknown>
return typeof o.id === 'string' && typeof o.source === 'string' && typeof o.points === 'number' && typeof o.live === 'boolean' && typeof o.credit === 'boolean'
})
}
const res = await fetch(URL, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15000) }).catch((err: Error) => keep(err.message))
if (!res.ok) keep(`GET ${URL} answered ${res.status}`)
// Kept as the cloud serves it, byte for byte, so the snapshot is the answer and not a re-rendering of it.
const next = await res.text().catch(() => keep(`GET ${URL} answered no body`))
let body: unknown
try {
body = JSON.parse(next)
} catch {
keep(`GET ${URL} answered something that is not JSON`)
}
const got = table(body) ? body : keep(`GET ${URL} answered something that is not a rules table`)
const now = readFileSync(OUT, 'utf8')
if (next === now) console.log(` rewards: ${got.kinds.length} kinds, unchanged`)
else {
writeFileSync(OUT, next)
console.log(` rewards: ${got.kinds.length} kinds written to lib/data/rewards-rules.json`)
}