@bufinance/kawaii-gate 0.1.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/package.json +37 -0
- package/src/component.tsx +191 -0
- package/src/handlers.ts +96 -0
- package/src/holder.ts +39 -0
- package/src/index.ts +3 -0
- package/src/message.ts +13 -0
- package/src/middleware.ts +33 -0
- package/src/server.ts +14 -0
- package/src/token.test.ts +36 -0
- package/src/token.ts +72 -0
- package/tsconfig.json +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bufinance/kawaii-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"description": "Shared Kawaii Punk NFT access gate — the 'holders only' wall (wallet connect + on-chain mainnet-Avalanche ownership verify) for BUFI private-beta deploys. Self-contained + prop-driven so fx (defi-web) and desk-v1 share ONE gate. Ships the <KawaiiGate/> component, the HMAC cookie token, the /api/gate route handlers, and a middleware guard.",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./server": "./src/server.ts",
|
|
12
|
+
"./middleware": "./src/middleware.ts"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "bun test"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"next": ">=14",
|
|
23
|
+
"react": ">=18",
|
|
24
|
+
"react-dom": ">=18",
|
|
25
|
+
"viem": ">=2",
|
|
26
|
+
"wagmi": ">=2"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/react": "^19",
|
|
30
|
+
"bun-types": "1.3.10",
|
|
31
|
+
"next": "16.2.6",
|
|
32
|
+
"react": "^19",
|
|
33
|
+
"typescript": "5",
|
|
34
|
+
"viem": "2.45.3",
|
|
35
|
+
"wagmi": "2.14.11"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi";
|
|
6
|
+
|
|
7
|
+
import { gateMessage } from "./message";
|
|
8
|
+
|
|
9
|
+
function truncate(addr: string): string {
|
|
10
|
+
return addr.length > 10 ? `${addr.slice(0, 6)}…${addr.slice(-4)}` : addr;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface KawaiiGateProps {
|
|
14
|
+
/** Where to GET a nonce + POST the signed proof. Default "/api/gate". */
|
|
15
|
+
apiPath?: string;
|
|
16
|
+
/** Kawaii Punks logo (host provides the asset). Falls back to a 👻 if unset. */
|
|
17
|
+
logoSrc?: string;
|
|
18
|
+
/** "Mint a Kawaii Punk at …" link. Default https://fx.bu.finance. */
|
|
19
|
+
mintUrl?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
subtitle?: ReactNode;
|
|
22
|
+
/** Called after a successful verify; default reloads the page so the gate clears. */
|
|
23
|
+
onVerified?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Phase = "idle" | "verifying";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* KawaiiGate — the full-screen "holders only" wall. Connect a wallet, sign a
|
|
30
|
+
* nonce'd message, the host's /api/gate verifies on-chain Kawaii ownership and
|
|
31
|
+
* sets the cookie. Portals to <body> so it escapes the host layout's stacking
|
|
32
|
+
* context. Requires a wagmi provider in the tree.
|
|
33
|
+
*/
|
|
34
|
+
export function KawaiiGate({
|
|
35
|
+
apiPath = "/api/gate",
|
|
36
|
+
logoSrc,
|
|
37
|
+
mintUrl = "https://fx.bu.finance",
|
|
38
|
+
title = "Kawaii Punk holders only",
|
|
39
|
+
subtitle,
|
|
40
|
+
onVerified,
|
|
41
|
+
}: KawaiiGateProps) {
|
|
42
|
+
const { address, isConnected } = useAccount();
|
|
43
|
+
const { connect, connectors, isPending: connecting } = useConnect();
|
|
44
|
+
const { disconnect } = useDisconnect();
|
|
45
|
+
const { signMessageAsync } = useSignMessage();
|
|
46
|
+
const [phase, setPhase] = useState<Phase>("idle");
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
const [mounted, setMounted] = useState(false);
|
|
49
|
+
useEffect(() => setMounted(true), []);
|
|
50
|
+
|
|
51
|
+
// Dedupe wallets that announce twice (EIP-6963 + the legacy injected provider).
|
|
52
|
+
const wallets = (() => {
|
|
53
|
+
const seen = new Set<string>();
|
|
54
|
+
return connectors.filter((c) => {
|
|
55
|
+
const key = c.name.toLowerCase();
|
|
56
|
+
if (seen.has(key)) return false;
|
|
57
|
+
seen.add(key);
|
|
58
|
+
return true;
|
|
59
|
+
});
|
|
60
|
+
})();
|
|
61
|
+
|
|
62
|
+
async function verify() {
|
|
63
|
+
if (!address) return;
|
|
64
|
+
setError(null);
|
|
65
|
+
setPhase("verifying");
|
|
66
|
+
try {
|
|
67
|
+
const { nonce } = (await fetch(apiPath, { method: "GET" }).then((r) => r.json())) as {
|
|
68
|
+
nonce: string;
|
|
69
|
+
};
|
|
70
|
+
const issuedAt = Date.now();
|
|
71
|
+
const signature = await signMessageAsync({ message: gateMessage(address, nonce, issuedAt) });
|
|
72
|
+
const res = await fetch(apiPath, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ address, signature, issuedAt }),
|
|
76
|
+
});
|
|
77
|
+
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; reason?: string; error?: string };
|
|
78
|
+
if (res.ok && data.ok) {
|
|
79
|
+
if (onVerified) onVerified();
|
|
80
|
+
else window.location.reload();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setPhase("idle");
|
|
84
|
+
setError(
|
|
85
|
+
data.reason ||
|
|
86
|
+
(data.error === "not_holder"
|
|
87
|
+
? "This wallet doesn't hold a Kawaii Punk on Avalanche."
|
|
88
|
+
: "Verification failed — please try again."),
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
setPhase("idle");
|
|
92
|
+
setError("Signature cancelled or failed. Try again.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const showConnected = isConnected && address;
|
|
97
|
+
|
|
98
|
+
const overlay = (
|
|
99
|
+
<div className="fixed inset-0 z-[9999] flex items-center justify-center overflow-auto bg-[#0b0320] p-6 text-white">
|
|
100
|
+
<div className="w-full max-w-lg rounded-3xl border border-white/10 bg-white/[0.03] p-8 backdrop-blur">
|
|
101
|
+
<div className="mb-6 text-center">
|
|
102
|
+
{logoSrc ? (
|
|
103
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
104
|
+
<img src={logoSrc} alt="Kawaii Punks" className="mx-auto mb-4 h-24 w-auto object-contain" />
|
|
105
|
+
) : (
|
|
106
|
+
<div className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-2xl bg-purple-500/20 text-3xl">
|
|
107
|
+
👻
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
<h1 className="text-2xl font-extrabold">{title}</h1>
|
|
111
|
+
<p className="mt-2 text-sm text-white/60">
|
|
112
|
+
{subtitle ?? (
|
|
113
|
+
<>
|
|
114
|
+
This is the BUFI private beta. Connect the wallet that holds your{" "}
|
|
115
|
+
<span className="font-semibold text-white/80">Kawaii Punk on Avalanche</span> to enter.
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{!showConnected ? (
|
|
122
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
123
|
+
{wallets.map((c) => {
|
|
124
|
+
const icon = (c as { icon?: string }).icon;
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
key={c.uid}
|
|
128
|
+
type="button"
|
|
129
|
+
disabled={connecting}
|
|
130
|
+
onClick={() => connect({ connector: c })}
|
|
131
|
+
className="flex flex-col items-center justify-center gap-1.5 rounded-xl bg-purpleDanis px-2 py-3 text-xs font-bold text-white transition hover:brightness-110 disabled:opacity-50"
|
|
132
|
+
>
|
|
133
|
+
{icon ? (
|
|
134
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
135
|
+
<img src={icon} alt="" className="h-6 w-6 rounded" />
|
|
136
|
+
) : (
|
|
137
|
+
<span className="grid h-6 w-6 place-items-center rounded bg-white/20 text-[10px] uppercase">
|
|
138
|
+
{c.name.slice(0, 2)}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
<span className="w-full truncate text-center">{c.name}</span>
|
|
142
|
+
</button>
|
|
143
|
+
);
|
|
144
|
+
})}
|
|
145
|
+
{wallets.length === 0 && (
|
|
146
|
+
<p className="col-span-full text-center text-xs text-white/50">No wallet connector available.</p>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
) : (
|
|
150
|
+
<div className="space-y-3">
|
|
151
|
+
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
|
152
|
+
<span className="text-sm text-white/70">{truncate(address)}</span>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={() => {
|
|
156
|
+
disconnect();
|
|
157
|
+
setError(null);
|
|
158
|
+
}}
|
|
159
|
+
className="text-xs text-white/50 underline hover:text-white/80"
|
|
160
|
+
>
|
|
161
|
+
Disconnect
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
disabled={phase === "verifying"}
|
|
167
|
+
onClick={verify}
|
|
168
|
+
className="w-full rounded-xl bg-purpleDanis px-4 py-3 text-sm font-bold transition hover:brightness-110 disabled:opacity-60"
|
|
169
|
+
>
|
|
170
|
+
{phase === "verifying" ? "Verifying ownership…" : "Verify & enter"}
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{error && (
|
|
176
|
+
<p className="mt-4 rounded-lg bg-red-500/10 px-3 py-2 text-center text-xs text-red-300">{error}</p>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
<p className="mt-6 text-center text-[11px] text-white/30">
|
|
180
|
+
Don't have one yet? Mint a Kawaii Punk at{" "}
|
|
181
|
+
<a href={mintUrl} className="underline hover:text-white/60">
|
|
182
|
+
{mintUrl.replace(/^https?:\/\//, "")}
|
|
183
|
+
</a>
|
|
184
|
+
.
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return mounted ? createPortal(overlay, document.body) : null;
|
|
191
|
+
}
|
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { isAddress, recoverMessageAddress } from "viem";
|
|
3
|
+
|
|
4
|
+
import { gateMessage } from "./message";
|
|
5
|
+
import type { CheckHolder } from "./holder";
|
|
6
|
+
import {
|
|
7
|
+
GATE_COOKIE,
|
|
8
|
+
GATE_NONCE_COOKIE,
|
|
9
|
+
GATE_TTL_MS,
|
|
10
|
+
GATE_MESSAGE_MAX_AGE_MS,
|
|
11
|
+
signGateToken,
|
|
12
|
+
} from "./token";
|
|
13
|
+
|
|
14
|
+
export interface GateHandlerOptions {
|
|
15
|
+
/** HMAC secret for the gate cookie. Keep identical between route + middleware. */
|
|
16
|
+
secret: string;
|
|
17
|
+
/** "Does this wallet hold a mainnet Kawaii Punk?" — see holder.ts. */
|
|
18
|
+
checkHolder: CheckHolder;
|
|
19
|
+
/**
|
|
20
|
+
* Override signature verification (e.g. to support ERC-1271 smart wallets via a
|
|
21
|
+
* chain client). Default: EOA recovery (recoverMessageAddress).
|
|
22
|
+
*/
|
|
23
|
+
verifySignature?: (args: { address: string; message: string; signature: string }) => Promise<boolean>;
|
|
24
|
+
/** Cookie Domain (e.g. ".bu.finance" to share across subdomains). Default host-only. */
|
|
25
|
+
cookieDomain?: string;
|
|
26
|
+
/** Secure cookies. Default: production. */
|
|
27
|
+
secure?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function defaultVerify({ address, message, signature }: { address: string; message: string; signature: string }): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
const recovered = await recoverMessageAddress({ message, signature: signature as `0x${string}` });
|
|
33
|
+
return recovered.toLowerCase() === address.toLowerCase();
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the GET (nonce) + POST (verify) handlers for `/api/gate`. Mount in the
|
|
41
|
+
* host app: `export const { GET, POST } = createGateHandlers({ secret, checkHolder })`.
|
|
42
|
+
*/
|
|
43
|
+
export function createGateHandlers(opts: GateHandlerOptions) {
|
|
44
|
+
const verify = opts.verifySignature ?? defaultVerify;
|
|
45
|
+
const cookieBase = {
|
|
46
|
+
httpOnly: true,
|
|
47
|
+
secure: opts.secure ?? process.env.NODE_ENV === "production",
|
|
48
|
+
sameSite: "lax" as const,
|
|
49
|
+
path: "/",
|
|
50
|
+
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
async function GET() {
|
|
54
|
+
const nonce = crypto.randomUUID();
|
|
55
|
+
const res = NextResponse.json({ nonce });
|
|
56
|
+
res.cookies.set(GATE_NONCE_COOKIE, nonce, { ...cookieBase, maxAge: 600 });
|
|
57
|
+
return res;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function POST(req: NextRequest) {
|
|
61
|
+
let body: { address?: string; signature?: string; issuedAt?: number };
|
|
62
|
+
try {
|
|
63
|
+
body = await req.json();
|
|
64
|
+
} catch {
|
|
65
|
+
return NextResponse.json({ error: "bad_json" }, { status: 400 });
|
|
66
|
+
}
|
|
67
|
+
const { address, signature, issuedAt } = body;
|
|
68
|
+
if (!address || !isAddress(address) || !signature || typeof issuedAt !== "number") {
|
|
69
|
+
return NextResponse.json({ error: "bad_request" }, { status: 400 });
|
|
70
|
+
}
|
|
71
|
+
if (Math.abs(Date.now() - issuedAt) > GATE_MESSAGE_MAX_AGE_MS) {
|
|
72
|
+
return NextResponse.json({ error: "stale_message" }, { status: 400 });
|
|
73
|
+
}
|
|
74
|
+
const nonce = req.cookies.get(GATE_NONCE_COOKIE)?.value;
|
|
75
|
+
if (!nonce) return NextResponse.json({ error: "no_nonce" }, { status: 400 });
|
|
76
|
+
|
|
77
|
+
const message = gateMessage(address, nonce, issuedAt);
|
|
78
|
+
if (!(await verify({ address, message, signature }))) {
|
|
79
|
+
return NextResponse.json({ error: "bad_signature" }, { status: 401 });
|
|
80
|
+
}
|
|
81
|
+
if (!(await opts.checkHolder(address))) {
|
|
82
|
+
return NextResponse.json(
|
|
83
|
+
{ error: "not_holder", reason: "This wallet doesn't hold a Kawaii Punk on Avalanche." },
|
|
84
|
+
{ status: 403 },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const token = await signGateToken(address, opts.secret);
|
|
89
|
+
const res = NextResponse.json({ ok: true });
|
|
90
|
+
res.cookies.set(GATE_COOKIE, token, { ...cookieBase, maxAge: Math.floor(GATE_TTL_MS / 1000) });
|
|
91
|
+
res.cookies.set(GATE_NONCE_COOKIE, "", { ...cookieBase, maxAge: 0 });
|
|
92
|
+
return res;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { GET, POST };
|
|
96
|
+
}
|
package/src/holder.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holder check — "does this wallet hold a mainnet (Avalanche) Kawaii Punk?"
|
|
3
|
+
*
|
|
4
|
+
* App-agnostic by design: the package ships a default that queries a BUFI
|
|
5
|
+
* `/api/kawaii/status` endpoint (fx.bu.finance) so a consumer with NO on-chain
|
|
6
|
+
* pipeline of its own (e.g. desk-v1) works standalone. A consumer that already
|
|
7
|
+
* has the authoritative check (defi-web) passes its own `CheckHolder` instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type CheckHolder = (address: string) => Promise<boolean>;
|
|
11
|
+
|
|
12
|
+
interface StatusResponse {
|
|
13
|
+
hasNft?: boolean;
|
|
14
|
+
tier?: string | null;
|
|
15
|
+
chainId?: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default check: GET `${baseUrl}/api/kawaii/status?wallet=…` and require a
|
|
20
|
+
* MAINNET (Avalanche 43114) Kawaii Punk. Fails closed on a non-OK response or a
|
|
21
|
+
* network error (never grants access on a blip). `baseUrl` defaults to the live
|
|
22
|
+
* fx app, which is the source of truth for the on-chain ownership read.
|
|
23
|
+
*/
|
|
24
|
+
export function statusEndpointChecker(baseUrl = "https://fx.bu.finance"): CheckHolder {
|
|
25
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
26
|
+
return async (address: string): Promise<boolean> => {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`${base}/api/kawaii/status?wallet=${address}`, {
|
|
29
|
+
cache: "no-store",
|
|
30
|
+
headers: { accept: "application/json" },
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) return false;
|
|
33
|
+
const d = (await res.json()) as StatusResponse;
|
|
34
|
+
return d.hasNft === true && (d.tier === "mainnet" || d.chainId === 43114);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/index.ts
ADDED
package/src/message.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exact message a wallet signs to prove control of `address`. Shared by the
|
|
3
|
+
* <KawaiiGate/> client (to sign) and the verify handler (to reconstruct) so the
|
|
4
|
+
* two produce byte-identical strings. Pure — safe to import client-side.
|
|
5
|
+
*/
|
|
6
|
+
export function gateMessage(address: string, nonce: string, issuedAtMs: number): string {
|
|
7
|
+
return [
|
|
8
|
+
"BUFI beta access — verify you hold a Kawaii Punk (Avalanche mainnet).",
|
|
9
|
+
`Wallet: ${address.toLowerCase()}`,
|
|
10
|
+
`Nonce: ${nonce}`,
|
|
11
|
+
`Issued: ${new Date(issuedAtMs).toISOString()}`,
|
|
12
|
+
].join("\n");
|
|
13
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
|
|
3
|
+
import { GATE_COOKIE, verifyGateToken } from "./token";
|
|
4
|
+
|
|
5
|
+
export interface GateGuardOptions {
|
|
6
|
+
/** HMAC secret — identical to the one the /api/gate handlers use. */
|
|
7
|
+
secret: string;
|
|
8
|
+
/** Hosts behind the gate (e.g. ["kawaii-beta.bu.finance"]). */
|
|
9
|
+
gatedHosts: Iterable<string>;
|
|
10
|
+
/** Paths that stay reachable so a visitor can verify. Default: the gate API + OG. */
|
|
11
|
+
exemptPaths?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns true when this request must be sent to the gate: it's for a gated host,
|
|
16
|
+
* the path isn't exempt, and there's no valid gate cookie. Edge-safe. The host app
|
|
17
|
+
* does the actual rewrite (it owns its gate route + locale handling), e.g.:
|
|
18
|
+
*
|
|
19
|
+
* if (await shouldGate(req, { secret, gatedHosts: ["kawaii-beta.bu.finance"] })) {
|
|
20
|
+
* return NextResponse.rewrite(new URL(`/${locale}/gate`, req.url));
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
export async function shouldGate(req: NextRequest, opts: GateGuardOptions): Promise<boolean> {
|
|
24
|
+
const hosts = opts.gatedHosts instanceof Set ? opts.gatedHosts : new Set(opts.gatedHosts);
|
|
25
|
+
const host = (req.headers.get("host") ?? req.nextUrl.hostname).split(":")[0].toLowerCase();
|
|
26
|
+
if (!hosts.has(host)) return false;
|
|
27
|
+
|
|
28
|
+
const { pathname } = req.nextUrl;
|
|
29
|
+
const exempt = opts.exemptPaths ?? ["/api/gate", "/api/og/"];
|
|
30
|
+
if (exempt.some((p) => pathname === p || pathname.startsWith(p))) return false;
|
|
31
|
+
|
|
32
|
+
return !(await verifyGateToken(req.cookies.get(GATE_COOKIE)?.value, opts.secret));
|
|
33
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Server entry — the /api/gate handlers, the holder check, and the cookie token.
|
|
2
|
+
// (The edge middleware guard is a separate, edge-safe entry: "@bufinance/kawaii-gate/middleware".)
|
|
3
|
+
export { createGateHandlers, type GateHandlerOptions } from "./handlers";
|
|
4
|
+
export { statusEndpointChecker, type CheckHolder } from "./holder";
|
|
5
|
+
export { gateMessage } from "./message";
|
|
6
|
+
export {
|
|
7
|
+
GATE_COOKIE,
|
|
8
|
+
GATE_NONCE_COOKIE,
|
|
9
|
+
GATE_TTL_MS,
|
|
10
|
+
GATE_MESSAGE_MAX_AGE_MS,
|
|
11
|
+
hmacHex,
|
|
12
|
+
signGateToken,
|
|
13
|
+
verifyGateToken,
|
|
14
|
+
} from "./token";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { GATE_TTL_MS, signGateToken, verifyGateToken } from "./token";
|
|
4
|
+
|
|
5
|
+
const SECRET = "test-secret-key";
|
|
6
|
+
const ADDR = "0xC6ee3c13214A37e66f3d2eE477c3205e89a99b38";
|
|
7
|
+
|
|
8
|
+
describe("@bufinance/kawaii-gate token — HMAC roundtrip", () => {
|
|
9
|
+
test("sign → verify returns the lowercased address", async () => {
|
|
10
|
+
const t = await signGateToken(ADDR, SECRET, 1_000_000);
|
|
11
|
+
expect(await verifyGateToken(t, SECRET, 1_000_500)).toBe(ADDR.toLowerCase());
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("a different secret never verifies", async () => {
|
|
15
|
+
const t = await signGateToken(ADDR, SECRET, 1_000_000);
|
|
16
|
+
expect(await verifyGateToken(t, "other-secret", 1_000_500)).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("expired token is rejected", async () => {
|
|
20
|
+
const now = 1_000_000;
|
|
21
|
+
const t = await signGateToken(ADDR, SECRET, now);
|
|
22
|
+
expect(await verifyGateToken(t, SECRET, now + GATE_TTL_MS + 1)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("tampered signature is rejected", async () => {
|
|
26
|
+
const t = await signGateToken(ADDR, SECRET, 1_000_000);
|
|
27
|
+
const [, exp, sig] = t.split(".");
|
|
28
|
+
const flipped = (sig[0] === "0" ? "1" : "0") + sig.slice(1);
|
|
29
|
+
expect(await verifyGateToken(`${ADDR.toLowerCase()}.${exp}.${flipped}`, SECRET, 1_000_500)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("malformed tokens are rejected (no throw)", async () => {
|
|
33
|
+
expect(await verifyGateToken(undefined, SECRET)).toBeNull();
|
|
34
|
+
expect(await verifyGateToken("a.b", SECRET)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/token.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beta-gate cookie token — an HMAC-signed `<address>.<exp>.<hexsig>` proving a
|
|
3
|
+
* wallet passed the Kawaii ownership check. Tamper-proof (signed with a host
|
|
4
|
+
* secret) so a non-holder can't forge access. Plain hex/decimal so the SAME
|
|
5
|
+
* token verifies in BOTH Node (the route) and the Edge middleware (Web Crypto).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const GATE_COOKIE = "bufi_beta_gate";
|
|
9
|
+
/** Nonce cookie set by GET /api/gate, consumed once by POST (anti-replay). */
|
|
10
|
+
export const GATE_NONCE_COOKIE = "bufi_beta_nonce";
|
|
11
|
+
export const GATE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
12
|
+
export const GATE_MESSAGE_MAX_AGE_MS = 10 * 60 * 1000; // 10 min
|
|
13
|
+
|
|
14
|
+
function hex(bytes: ArrayBuffer | Uint8Array): string {
|
|
15
|
+
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
16
|
+
let s = "";
|
|
17
|
+
for (const b of u8) s += b.toString(16).padStart(2, "0");
|
|
18
|
+
return s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeEqual(a: string, b: string): boolean {
|
|
22
|
+
if (a.length !== b.length) return false;
|
|
23
|
+
let diff = 0;
|
|
24
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
25
|
+
return diff === 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** HMAC-SHA256(message, secret) → hex, via Web Crypto (Node 20+ and Edge). */
|
|
29
|
+
export async function hmacHex(message: string, secret: string): Promise<string> {
|
|
30
|
+
const enc = new TextEncoder();
|
|
31
|
+
const key = await crypto.subtle.importKey(
|
|
32
|
+
"raw",
|
|
33
|
+
enc.encode(secret),
|
|
34
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
35
|
+
false,
|
|
36
|
+
["sign"],
|
|
37
|
+
);
|
|
38
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
|
|
39
|
+
return hex(sig);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mint a signed gate token for `address`. */
|
|
43
|
+
export async function signGateToken(
|
|
44
|
+
address: string,
|
|
45
|
+
secret: string,
|
|
46
|
+
now: number = Date.now(),
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const addr = address.toLowerCase();
|
|
49
|
+
const exp = now + GATE_TTL_MS;
|
|
50
|
+
const sig = await hmacHex(`${addr}:${exp}`, secret);
|
|
51
|
+
return `${addr}.${exp}.${sig}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Verify a gate token. Returns the verified lowercase address, or null if it's
|
|
56
|
+
* malformed, expired, or the signature doesn't match. Edge- and Node-safe.
|
|
57
|
+
*/
|
|
58
|
+
export async function verifyGateToken(
|
|
59
|
+
token: string | undefined | null,
|
|
60
|
+
secret: string,
|
|
61
|
+
now: number = Date.now(),
|
|
62
|
+
): Promise<string | null> {
|
|
63
|
+
if (!token) return null;
|
|
64
|
+
const parts = token.split(".");
|
|
65
|
+
if (parts.length !== 3) return null;
|
|
66
|
+
const [addr, expStr, sig] = parts;
|
|
67
|
+
const exp = Number(expStr);
|
|
68
|
+
if (!addr || !Number.isFinite(exp) || exp < now) return null;
|
|
69
|
+
const expected = await hmacHex(`${addr}:${exp}`, secret);
|
|
70
|
+
if (!safeEqual(expected, sig)) return null;
|
|
71
|
+
return addr;
|
|
72
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"module": "esnext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"types": ["bun-types"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"]
|
|
16
|
+
}
|