aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-31 09:03:37 -0700
committerAdam Soltys <[email protected]>2026-08-31 09:03:37 -0700
commit790c6fac78d678832634cedf8c0bd67c4d19e89b (patch)
tree287195d871af5c55f9df02516e703f1d8f224c80
parent1e6fb6005948231cb56ecc071576df23b5d7b0be (diff)
downloadcoinosv3-790c6fac78d678832634cedf8c0bd67c4d19e89b.tar.gz
coinosv3-790c6fac78d678832634cedf8c0bd67c4d19e89b.zip
A hard hat for the crew that builds this with us: an operator-giftable construction hat joins the catalog (never listed for sale — like the crown it appears only in a rack that holds it), the registrar grows POST /hats/grant behind the admin token (hex or npub, crown excluded), and a gifted hat settles into the rack instead of knocking a paid hat off a bare-headed recipient wears it at once
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
-rw-r--r--names/server.js31
-rw-r--r--src/features/hats.js16
-rw-r--r--src/i18n.js1
3 files changed, 42 insertions, 6 deletions
diff --git a/names/server.js b/names/server.js
index 55c32ae..a56ed84 100644
--- a/names/server.js
+++ b/names/server.js
@@ -21,7 +21,7 @@ import { verifyEvent, finalizeEvent, generateSecretKey, getPublicKey } from 'nos
import * as nip44 from 'nostr-tools/nip44';
import { wrapManyEvents } from 'nostr-tools/nip17';
import { SimplePool } from 'nostr-tools/pool';
-import { npubEncode } from 'nostr-tools/nip19';
+import { npubEncode, decode as nip19Decode } from 'nostr-tools/nip19';
import { createHash } from 'node:crypto';
import { HDKey } from '@scure/bip32';
import { mnemonicToSeedSync } from '@scure/bip39';
@@ -391,10 +391,12 @@ function hatRec(pk, net = 'mainnet') {
return { owned, equipped };
}
-function grantHat(pk, hat, net = 'mainnet') {
+function grantHat(pk, hat, net = 'mainnet', { equip = true } = {}) {
const r = hatStore(net)[pk] ||= { owned: [], equipped: null };
if (!r.owned.includes(hat)) r.owned.push(hat);
- r.equipped = hat; // a fresh purchase goes straight on the head
+ // a fresh purchase goes straight on the head; a GIFT only crowns a bare
+ // one — knocking off a hat someone paid for is no way to say thanks
+ if (equip || !r.equipped) r.equipped = hat;
}
async function settleLoop() {
@@ -859,6 +861,29 @@ Bun.serve({
return json({ ...hatRec(hm[1], hatNetOf(url.searchParams.get('net'))), prices: HAT_PRICES });
}
+ // Operator gift: drop a hat into someone's rack (x-admin-token). Takes
+ // hex or npub; giftable ids only — the crown stays singular. The gift
+ // sits in the rack rather than force-equipping over a paid hat.
+ if (url.pathname === '/hats/grant' && req.method === 'POST') {
+ if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
+ return json({ error: 'unauthorized' }, 401);
+ }
+ let b;
+ try { b = await req.json(); } catch { return json({ error: 'bad body' }, 400); }
+ let pk = String(b.pubkey || '');
+ if (/^npub1/.test(pk)) {
+ try { pk = nip19Decode(pk).data; } catch { return json({ error: 'bad npub' }, 400); }
+ }
+ if (!/^[0-9a-f]{64}$/.test(pk)) return json({ error: 'bad pubkey' }, 400);
+ const hat = String(b.hat || '');
+ if (!(hat in HAT_PRICES) && hat !== 'construction') return json({ error: 'no such hat' }, 400);
+ const net = hatNetOf(b.net);
+ grantHat(pk, hat, net, { equip: false });
+ persist();
+ log(`hat gifted: ${hat} (${net}) to ${pk.slice(0, 12)}`);
+ return json(hatRec(pk, net));
+ }
+
if (url.pathname === '/hats/invoice' && req.method === 'POST') {
if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
const bodyText = await req.text();
diff --git a/src/features/hats.js b/src/features/hats.js
index 11fe400..351ada5 100644
--- a/src/features/hats.js
+++ b/src/features/hats.js
@@ -81,6 +81,15 @@ const HATS = [
<path d="M41.5 26 l0.9 2 2 0.9 -2 0.9 -0.9 2 -0.9 -2 -2 -0.9 2 -0.9 Z" fill="#ffe08a"/>`),
},
{
+ // Not for sale: gifted by the operator to beta testers (see the
+ // registrar's /hats/grant). Appears in the shop only once owned.
+ id: 'construction', nameKey: 'hatConstruction', sat: null, w: 108, b: 71, r: -6,
+ art: svg(`<path d="M14 30 Q14 9 32 9 Q50 9 50 30 Z" fill="#f2b32a"/>
+ <path d="M28.5 9.6 h7 q1.6 0 1.6 1.6 v7.8 h-10.2 v-7.8 q0-1.6 1.6-1.6 Z" fill="#ffd35c"/>
+ <path d="M20.5 13.5 Q18 20 18 30 M43.5 13.5 Q46 20 46 30" stroke="#d9930f" stroke-width="1.6" fill="none"/>
+ <path d="M7 32 Q7 27.5 13 29.5 L51 29.5 Q57 27.5 57 32 Q57 36.5 32 36.5 Q7 36.5 7 32 Z" fill="#e8a715" stroke="rgba(0,0,0,.1)" stroke-width="0.8"/>`),
+ },
+ {
id: 'crown', nameKey: 'hatCrown', sat: null, w: 86, b: 83, r: -7,
art: svg(`<path d="M10 38 L7 13 L20 25 L32 6 L44 25 L57 13 L54 38 Z" fill="#f2b32a"/>
<circle cx="7" cy="12" r="3" fill="#ffd97a"/><circle cx="32" cy="6" r="3" fill="#ffd97a"/><circle cx="57" cy="12" r="3" fill="#ffd97a"/>
@@ -348,9 +357,10 @@ export function hatsFeature(ctx) {
d == null
? h('div', { style: 'text-align:center;padding:16px' }, h('span', { class: 'spinner' }))
: h('div', { class: 'col', style: 'gap:2px' },
- // the crown is not merchandise — it appears only in the
- // collection of the head that owns it, never as a listing
- ...HATS.filter((hat) => hat.id !== 'crown' || ((d && d.owned) || []).includes('crown'))
+ // unpriced hats (the crown, the beta hard hat) are not
+ // merchandise — they appear only in the collection of a head
+ // that owns them, never as a listing
+ ...HATS.filter((hat) => (priceOf(hat) || 0) > 0 || ((d && d.owned) || []).includes(hat.id))
.map((hat) => shopRow(hat, d, me))),
d && d.offline ? h('div', { class: 'small faint', style: 'text-align:center' }, t('hatShopOffline')) : null),
h('button', { class: 'btn-ghost btn-block', onClick: closeShop }, t('back')));
diff --git a/src/i18n.js b/src/i18n.js
index 35a8cdb..f9f8567 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -275,6 +275,7 @@ const STR = {
hatBowler: 'Bowler',
hatWizard: 'Wizard hat',
hatTop: 'Top hat',
+ hatConstruction: 'Hard hat',
hatCrown: 'The Crown',
hatCrownWho: "Adam's. Sorry.",
hatCrownTaken: 'not for sale',