# Skooby Web2 → Web3 starter

A Next.js App Router application with Tailwind, Privy email/social login, embedded and external wallets, Wagmi, Viem, server-verified ERC-721/ERC-20 access and authenticated Pinata uploads. This is a deployable scaffold; you must supply your own provider configuration. It does not automatically decentralize your server, identity provider or hosting.

## 1. Initialize

Use Node.js 22.13 or later. For a fresh application:

```bash
npx create-next-app@16.3.4 skooby-web3 --typescript --tailwind --eslint --app --src-dir --import-alias '@/*' --use-npm
cd skooby-web3
npm install @privy-io/react-auth@3.42.0 @privy-io/wagmi@4.0.17 wagmi@3.7.7 viem@2.56.0 @tanstack/react-query@5.102.8 @privy-io/node@0.34.0 pinata@2.5.6 server-only@0.0.1 @stripe/stripe-js@9.16.0
```

Copy this starter's `src/`, `postcss.config.mjs`, and `.env.example` into that project. Alternatively, use the complete downloaded starter directly:

```bash
npm install
cp .env.example .env.local
npm run typecheck
npm run dev
```

The included package.json pins a compatible version set. Keep the generated lockfile and review dependency updates rather than silently changing SDK majors. The documented Privy methods occasionally differ from installed SDK types: this starter uses `privy.utils().auth().verifyAccessToken(token)` and `claims.user_id`, verified against `@privy-io/node@0.34.0`.

## 2. Configure providers and networks

Create a Privy application in the [Privy dashboard](https://dashboard.privy.io). Enable email and Google login, embedded Ethereum wallets and the external wallet options you need. Add `http://localhost:3000` and your production HTTPS origin to its allowed domains. Configure your Google OAuth connection according to the Privy dashboard prompts.

Set `NEXT_PUBLIC_PRIVY_APP_ID` and the **server-only** `PRIVY_APP_SECRET`. In `src/components/Web3Provider.tsx`, Privy wraps TanStack Query, then the Privy-aware WagmiProvider. A per-mount QueryClient prevents shared server caches. Base and Polygon are available; `NEXT_PUBLIC_CHAIN=base` chooses Base for token access. Choose `polygon` to use Polygon and update the server `RPC_URL` to a Polygon RPC. Never put a secret RPC credential in `NEXT_PUBLIC_*`; use an origin-restricted browser key or a metered proxy for production.

The current embedded-wallet setting is `embeddedWallets.ethereum.createOnLogin`. `users-without-wallets` creates an embedded wallet for a new email/social user who does not already have one. Existing external-wallet users can retain their own wallet. Wallet creation is asynchronous; the UI waits for an address rather than pretending it exists immediately.

## 3. Connect with familiar login

`ConnectButton.tsx` calls `usePrivy().login()` and shows email, Google and wallet login. It also exposes Coinbase Wallet, MetaMask, Rainbow and the WalletConnect registry. Users with multiple wallets can select the active one through `useSetActiveWallet`. Sign out through Privy so authentication and Wagmi stay aligned.

Connecting an external wallet and linking it to an authenticated Privy identity are different operations. If a newly connected wallet is rejected by the protected API, complete Privy's wallet-linking and signature verification flow. Never bypass the server ownership check. No seed phrase or private key should ever be requested by your application.

## 4. Token-gated content

Set a deployed contract on the chosen chain:

```dotenv
NEXT_PUBLIC_CHAIN=base
NEXT_PUBLIC_GATE_CONTRACT=YOUR_DEPLOYED_CONTRACT_ADDRESS
NEXT_PUBLIC_GATE_STANDARD=erc721
NEXT_PUBLIC_GATE_MINIMUM=1
```

For an ERC-20 gate, use `erc20` and a positive minimum such as `10.5`. The app reads `decimals()` and uses bigint arithmetic; a token does not necessarily use 18 decimals. For an ERC-721 gate, at least one NFT grants access; the numeric minimum setting is ignored.

`TokenGate.tsx` reads the active address with Wagmi and uses `useReadContract` to provide a responsive preview. It distinguishes signed-out, loading, ineligible and RPC error states. Reads target the configured chain explicitly, so checking a balance does not require a signing-network switch.

The client gate is a convenience only. `/api/exclusive` verifies the bearer token with Privy, obtains the authoritative linked wallet list, and checks that the requested address belongs to that user. `lib/gate.ts` then reads the contract again with Viem, confirms the RPC chain ID and pins balance/decimals reads to the same block. Exclusive content exists only on the server and is returned with `Cache-Control: private, no-store` after authorization. Apply the same checks to each paid or token-gated API and download. Cached client eligibility never authorizes a request. Content already delivered to a browser cannot be recalled after a token transfer.

## 5. Publish a public file to IPFS

Create a scoped Pinata JWT with only the file-write permission needed. Set `PINATA_JWT` and your gateway hostname in server environment variables. The small core operation is:

```ts
// Server only. Never put PINATA_JWT in browser code.
const pinata = new PinataSDK({ pinataJwt: process.env.PINATA_JWT! });
const file = new File([JSON.stringify({ title: 'My public note' })], 'note.json', {
  type: 'application/json',
});
const uploaded = await pinata.upload.public.file(file);
const permanentIdentifier = `ipfs://${uploaded.cid}`;
```

The complete route is safer than that minimal snippet: it authenticates, checks the configured request origin, bounds the incoming stream, accepts only non-empty UTF-8 text or valid JSON up to 2 MiB, replaces the filename, and requires explicit consent to public IPFS publication. `IpfsUpload.tsx` uses FormData; do not manually set a multipart Content-Type because the browser must supply its boundary.

Configure a small [Upstash Redis](https://upstash.com/docs/redis/features/restapi) instance and set its server-only REST URL/token for an atomic five-attempt daily per-user quota. The route fails closed if quota storage is absent or unreachable. Set provider account budgets and your hosting request limits too. Upstash is an additional backend service used here for durable upload quotas, not for blockchain data.

Public IPFS content may be copied or pinned by others. Unpinning does not guarantee deletion. Do not put secrets, personal identity documents or private evidence on public IPFS. Private access-controlled files require a different storage design.

## 6. Validate before production

```bash
npm run typecheck
npm run build
npm start
```

Check new email login, existing Google login, Coinbase/MetaMask login, multiple-wallet selection, rejected signatures and mobile wallet browsers. Exercise zero/nonzero NFT balances, an ERC-20 with six decimals, the wrong RPC chain, expired bearer tokens, a wallet belonging to another account and unavailable RPCs. Confirm content cannot be fetched by simply calling the API without authorization. Test a successful public upload, missing consent, oversized/invalid files, exhausted quota and a missing Pinata key.

Add request-level abuse controls for your hosted endpoints and monitor provider budgets. Allowlist production domains. Run provider-backed tests using your own development credentials before activating paid features. The starter neither signs transactions nor grants recovery access to a wallet.

## What is decentralized here?

Token balances come from the chosen blockchain and public uploads use IPFS CIDs. Privy authentication, Pinata pinning, RPC providers, Redis and Next.js hosting remain service dependencies. A token gate does not make your entire website decentralized.

## Primary references

- [Privy + Wagmi](https://docs.privy.io/wallets/connectors/ethereum/integrations/wagmi)
- [Privy access-token verification](https://docs.privy.io/authentication/user-authentication/access-tokens)
- [Privy user lookup](https://docs.privy.io/user-management/users/managing-users/querying-users)
- [Wallet options](https://docs.privy.io/wallets/connectors/setup/configuring-external-connector-wallets)
- [Pinata file uploads](https://docs.pinata.cloud/sdk/upload/public/file)
- [Viem readContract](https://viem.sh/docs/contract/readContract)
- [Wagmi useReadContract](https://wagmi.sh/react/api/hooks/useReadContract)

Prepared September 12, 2026. Provider setup and policies can change; verify them before activating your deployment.


# Complete commented source

## .env.example

```text
# Public configuration is bundled into the browser. Never put secrets here.
NEXT_PUBLIC_PRIVY_APP_ID=
NEXT_PUBLIC_CHAIN=base
NEXT_PUBLIC_GATE_CONTRACT=
NEXT_PUBLIC_GATE_STANDARD=erc721
NEXT_PUBLIC_GATE_MINIMUM=1
NEXT_PUBLIC_BASE_RPC_URL=https://mainnet.base.org
NEXT_PUBLIC_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# Server-only secrets and policy. Configure the same origin as the deployed app.
APP_ORIGIN=http://localhost:3000
PRIVY_APP_SECRET=
RPC_URL=https://mainnet.base.org
PINATA_JWT=
PINATA_GATEWAY=
# Durable upload quotas. Missing configuration fails closed.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

```

## next.config.ts

```ts
import type {NextConfig} from 'next';
// Standalone Next.js configuration; do not inherit the parent Skooby Worker config.
const config:NextConfig={};
export default config;

```

## package.json

```json
{
  "name": "skooby-web3-starter",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@privy-io/node": "0.34.0",
    "@privy-io/react-auth": "3.42.0",
    "@privy-io/wagmi": "4.0.17",
    "@stripe/stripe-js": "9.16.0",
    "@tanstack/react-query": "5.102.8",
    "next": "16.3.4",
    "pinata": "2.5.6",
    "react": "19.2.6",
    "react-dom": "19.2.6",
    "server-only": "0.0.1",
    "viem": "2.56.0",
    "wagmi": "3.7.7"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "4.2.1",
    "@types/node": "22.19.19",
    "@types/react": "19.2.14",
    "@types/react-dom": "19.2.3",
    "tailwindcss": "4.2.1",
    "typescript": "5.9.3"
  }
}

```

## pnpm-workspace.yaml

```yaml
# Optional native accelerators are unnecessary for this portable starter.
# These explicit decisions keep installation non-interactive.
allowBuilds:
  '@reown/appkit': false
  bufferutil: false
  keccak: false
  utf-8-validate: false
  sharp: true

```

## postcss.config.mjs

```js
export default {plugins:{'@tailwindcss/postcss':{}}};

```

## src/app/api/exclusive/route.ts

```ts
import {sameOrigin,ownedWallet,failure,HttpError} from '@/lib/auth';
import {holdsAccessToken} from '@/lib/gate';
export const runtime='nodejs';
export async function POST(request:Request){try{
 sameOrigin(request);const raw=await request.text();if(raw.length>512)throw new HttpError('Request too large.',413);
 const {address}=await ownedWallet(request,JSON.parse(raw).address);
 const result=await holdsAccessToken(address);
 if(!result.allowed)throw new HttpError('This wallet does not meet the token requirement.',403);
 // Protected content lives on the server. It is never shipped in the client JS bundle.
 // Repeat this authorization for EVERY protected read/download/action.
 return Response.json({content:'Welcome to the private builder desk. Replace this sample with your server-held exclusive content.',blockNumber:result.blockNumber.toString()},{headers:{'Cache-Control':'private, no-store'}});
}catch(error){return failure(error);}}

```

## src/app/api/upload/route.ts

```ts
import {PinataSDK} from 'pinata';
import {authenticatedUser,sameOrigin,failure,HttpError} from '@/lib/auth';
import {reserveUpload} from '@/lib/upload-quota';
export const runtime='nodejs';
const MAX_BYTES=2*1024*1024;
export async function POST(request:Request){try{
 sameOrigin(request);const user=await authenticatedUser(request);
 if(!process.env.PINATA_JWT)throw new HttpError('Storage is not configured.',503);
 // Enforce a bounded body even if a client omits Content-Length.
 const reader=request.body?.getReader();if(!reader)throw new HttpError('Choose a file.',400);
 const chunks:Uint8Array[]=[];let size=0;
 while(true){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>MAX_BYTES+16384){await reader.cancel();throw new HttpError('Choose a file smaller than 2 MiB.',413);}chunks.push(value);}
 const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}
 const form=await new Response(bytes,{headers:{'Content-Type':request.headers.get('content-type')||''}}).formData();
 if(form.get('publicConsent')!=='yes')throw new HttpError('Confirm the file may be public on IPFS.',400);
 const file=form.get('file');
 if(!(file instanceof File)||file.size===0||file.size>MAX_BYTES)throw new HttpError('Choose a non-empty file up to 2 MiB.',400);
 // Narrow example: UTF-8 plain text or valid JSON only, no active HTML or SVG.
 if(!['text/plain','application/json'].includes(file.type))throw new HttpError('Choose a text or JSON file.',415);
 const content=new TextDecoder('utf-8',{fatal:true}).decode(await file.arrayBuffer());
 if(content.includes('\u0000'))throw new HttpError('Binary content is not supported.',415);
 if(file.type==='application/json'){try{JSON.parse(content);}catch{throw new HttpError('Invalid JSON file.',400);}}
 await reserveUpload(user.id);
 // Only the server sees the scoped Pinata JWT. Do not expose it via NEXT_PUBLIC_.
 const pinata=new PinataSDK({pinataJwt:process.env.PINATA_JWT,pinataGateway:process.env.PINATA_GATEWAY});
 const safeName=`public-${crypto.randomUUID()}.${file.type==='application/json'?'json':'txt'}`;
 const formattedFile=new File([content],safeName,{type:file.type});
 const upload=await pinata.upload.public.file(formattedFile);
 return Response.json({cid:upload.cid,uri:`ipfs://${upload.cid}`,bytes:upload.size},{headers:{'Cache-Control':'no-store'}});
}catch(error){return failure(error);}}

```

## src/app/globals.css

```css
@import "tailwindcss";
body{background:#0e0914;color:#f4effb;font-family:Arial,sans-serif}h1{font-size:clamp(2.5rem,6vw,4.5rem);line-height:1.05;margin:1rem 0}h2{font-size:1.5rem;margin-bottom:1rem}p{line-height:1.7;color:#c7bbd1}main>section{background:#1a1125;border:1px solid #493359;padding:1.5rem;border-radius:1rem}button{background:#af81de;color:#140b1d;padding:.75rem 1rem;border-radius:.5rem;cursor:pointer}button:disabled{opacity:.5;cursor:default}label{display:block}input,select{display:block;width:100%;margin-top:.5rem;background:#100b17;color:#fff;padding:.7rem;border:1px solid #614779;border-radius:.4rem}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #f2d46d;outline-offset:3px}

```

## src/app/layout.tsx

```tsx
import type {Metadata} from 'next';
import Web3Provider from '@/components/Web3Provider';
import './globals.css';
export const metadata:Metadata={title:'Skooby Web3 Starter',description:'Email login, embedded wallets, server-verified token access and public IPFS uploads.'};
// The layout stays a Server Component. Only the provider and interactive leaves are clients.
export default function Layout({children}:{children:React.ReactNode}){return <html lang="en"><body><Web3Provider>{children}</Web3Provider></body></html>;}

```

## src/app/page.tsx

```tsx
import ConnectButton from '@/components/ConnectButton';
import TokenGate from '@/components/TokenGate';
import IpfsUpload from '@/components/IpfsUpload';
export default function Home(){return <main className="mx-auto max-w-3xl space-y-10 px-5 py-14"><header><p className="text-purple-300">SKOOBY / BUILDER DESK</p><h1>Familiar login.<br/>A Web3 foundation.</h1><p>Use email or Google to create a wallet, or connect a wallet you already use.</p></header><section><ConnectButton/></section><section><TokenGate/></section><section><IpfsUpload/></section></main>;}

```

## src/components/ConnectButton.tsx

```tsx
'use client';
import {useState} from 'react';
import {usePrivy,useWallets} from '@privy-io/react-auth';
import {useSetActiveWallet} from '@privy-io/wagmi';
import {useAccount} from 'wagmi';

export default function ConnectButton(){
 const {ready,authenticated,login,logout,connectWallet}=usePrivy();
 const {wallets}=useWallets();const {setActiveWallet}=useSetActiveWallet();
 const {address}=useAccount();const [error,setError]=useState('');
 if(!ready)return <button disabled>Getting ready…</button>;
 if(!authenticated)return <button onClick={()=>login()}>Connect with email, Google or a wallet</button>;
 return <section className="space-y-3"><p>{address?`Connected: ${address}`:'Preparing your embedded wallet…'}</p>
  {wallets.length>1&&<label>Active wallet<select value={address||''} onChange={async e=>{const selected=wallets.find(w=>w.address===e.target.value);if(selected){try{await setActiveWallet(selected);setError('');}catch{setError('Wallet switch canceled.');}}}}>{wallets.map(w=><option key={w.address} value={w.address}>{w.walletClientType}: {w.address}</option>)}</select></label>}
  <div className="flex flex-wrap gap-3"><button onClick={()=>connectWallet()}>Add Coinbase or another wallet</button><button onClick={()=>void logout()}>Sign out</button></div><p role="status">{error}</p>
 </section>;
}

```

## src/components/IpfsUpload.tsx

```tsx
'use client';
import {useState,type FormEvent} from 'react';
import {usePrivy} from '@privy-io/react-auth';
export default function IpfsUpload(){
 const {authenticated,getAccessToken}=usePrivy();const [busy,setBusy]=useState(false),[message,setMessage]=useState('');
 async function upload(e:FormEvent<HTMLFormElement>){e.preventDefault();const form=e.currentTarget;setBusy(true);setMessage('');try{
  const token=await getAccessToken();const data=new FormData(form);
  // Do not set Content-Type: fetch adds the multipart boundary for FormData.
  const r=await fetch('/api/upload',{method:'POST',headers:{Authorization:`Bearer ${token}`},body:data});const result=await r.json();if(!r.ok)throw Error(result.error);setMessage(`Uploaded: ${result.uri}`);form.reset();
 }catch(e){setMessage(e instanceof Error?e.message:'Upload could not be completed.');}finally{setBusy(false)}}
 return <form onSubmit={upload} className="space-y-4"><h2>Publish a file to IPFS</h2><p>Public text or JSON, up to 2 MiB. Never upload private keys, identity documents or personal records. Copies may remain accessible even after unpinning.</p><label>File<input type="file" name="file" accept="text/plain,application/json,.txt,.json" required/></label><label className="flex gap-2"><input className="w-auto" type="checkbox" name="publicConsent" value="yes" required/>I own this file and consent to making it publicly accessible.</label><button disabled={!authenticated||busy}>{!authenticated?'Sign in to upload':busy?'Publishing…':'Publish public file'}</button><p className="break-all" role="status">{message}</p></form>;
}

```

## src/components/TokenGate.tsx

```tsx
'use client';
import {useEffect,useState} from 'react';
import {usePrivy} from '@privy-io/react-auth';
import {useAccount,useReadContract} from 'wagmi';
import {erc20Abi,erc721Abi,parseUnits} from 'viem';
import {chain,gateConfig} from '@/lib/chain';

export default function TokenGate(){
 // Configuration errors are rendered separately, rather than a misleading zero balance.
 let gate:ReturnType<typeof gateConfig>;try{gate=gateConfig();}catch{return <p>Configure a gate contract and standard in .env.local to try token access.</p>;}
 return <ConfiguredGate gate={gate}/>;
}
function ConfiguredGate({gate}:{gate:ReturnType<typeof gateConfig>}){
 const {address}=useAccount();const {authenticated,getAccessToken}=usePrivy();
 const [result,setResult]=useState<{address:string;content:string}|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false);
 const balance=useReadContract({chainId:chain.id,address:gate.address,abi:gate.kind==='erc721'?erc721Abi:erc20Abi,functionName:'balanceOf',args:address?[address]:undefined,query:{enabled:!!address&&authenticated,refetchInterval:15000}});
 const decimals=useReadContract({chainId:chain.id,address:gate.address,abi:erc20Abi,functionName:'decimals',query:{enabled:gate.kind==='erc20'&&!!address&&authenticated}});
 let eligible=false;let precisionError=false;
 if(typeof balance.data==='bigint'){
  if(gate.kind==='erc721')eligible=balance.data>0n;
  else if(typeof decimals.data==='number'){precisionError=(gate.minimum.split('.')[1]?.length||0)>decimals.data;if(!precisionError)eligible=balance.data>=parseUnits(gate.minimum,decimals.data);}
 }
 // Clear private UI on logout, wallet change or loss of client eligibility.
 useEffect(()=>{setResult(null);setError('');},[address,authenticated,eligible]);
 async function unlock(){if(!address)return;const requestedAddress=address;setBusy(true);setError('');try{const token=await getAccessToken();const r=await fetch('/api/exclusive',{method:'POST',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},body:JSON.stringify({address:requestedAddress}),cache:'no-store'});const data=await r.json();if(!r.ok)throw Error(data.error);setResult({address:requestedAddress,content:data.content});}catch(e){setError(e instanceof Error?e.message:'Access check failed.');}finally{setBusy(false)}}
 if(!authenticated||!address)return <p>Connect to check token access.</p>;
 if(balance.isError||decimals.isError||precisionError)return <p role="alert">The contract could not be checked. Verify the chain, contract standard and token minimum.</p>;
 if(balance.isPending||(gate.kind==='erc20'&&decimals.isPending))return <p role="status">Reading the contract on {chain.name}…</p>;
 return <section className="space-y-3"><h2>Token-gated content</h2><p>Requirement: {gate.kind==='erc721'?'at least one NFT':gate.minimum+' tokens'} on {chain.name}. Checking is read-only and does not require switching your wallet’s signing network.</p>{eligible?<button disabled={busy} onClick={unlock}>{busy?'Verifying ownership…':'Verify and open exclusive content'}</button>:<p>This address does not meet the access requirement.</p>}<p role="status">{error}</p>{eligible&&authenticated&&result?.address===address&&<article>{result.content}</article>}</section>;
}

```

## src/components/Web3Provider.tsx

```tsx
'use client';
import {useState,type ReactNode} from 'react';
import {PrivyProvider} from '@privy-io/react-auth';
// Privy's wrapper keeps embedded and external wallets synchronized with Wagmi.
import {createConfig,WagmiProvider} from '@privy-io/wagmi';
import {QueryClient,QueryClientProvider} from '@tanstack/react-query';
import {http} from 'wagmi';
import {base,polygon} from 'viem/chains';
import {chain} from '@/lib/chain';

export default function Web3Provider({children}:{children:ReactNode}){
 // Each mounted app gets its own cache. Never share a server-global user cache.
 const [queryClient]=useState(()=>new QueryClient({defaultOptions:{queries:{staleTime:15_000,retry:1}}}));
 const [config]=useState(()=>createConfig({
  chains:[base,polygon],ssr:true,
  transports:{[base.id]:http(process.env.NEXT_PUBLIC_BASE_RPC_URL),[polygon.id]:http(process.env.NEXT_PUBLIC_POLYGON_RPC_URL)}
 }));
 const appId=process.env.NEXT_PUBLIC_PRIVY_APP_ID;
 if(!appId)return <main className="mx-auto max-w-xl p-8"><h1>Web3 starter</h1><p>Set your Privy app ID to enable login. No wallet is connected yet.</p></main>;
 return <PrivyProvider appId={appId} config={{
  loginMethods:['email','google','wallet'],
  appearance:{theme:'dark',accentColor:'#a879e0',showWalletLoginFirst:false,walletList:['coinbase_wallet','metamask','rainbow','wallet_connect']},
  defaultChain:chain,supportedChains:[base,polygon],
  // Current Privy v3 configuration: ethereum.createOnLogin, not the old top-level field.
  embeddedWallets:{ethereum:{createOnLogin:'users-without-wallets'}}
 }}><QueryClientProvider client={queryClient}><WagmiProvider config={config}>{children}</WagmiProvider></QueryClientProvider></PrivyProvider>;
}

```

## src/lib/auth.ts

```ts
import 'server-only';
import {PrivyClient} from '@privy-io/node';
import {isAddress,type Address} from 'viem';

export class HttpError extends Error{constructor(message:string,public status:number){super(message);}}
export function sameOrigin(request:Request){
 // Use a configured origin, not an untrusted forwarded host supplied by a client.
 if(!process.env.APP_ORIGIN||request.headers.get('origin')!==process.env.APP_ORIGIN)throw new HttpError('Invalid origin.',403);
}
export async function authenticatedUser(request:Request){
 const bearer=request.headers.get('authorization');
 if(!bearer?.startsWith('Bearer ')||bearer.length>10000)throw new HttpError('Please sign in again.',401);
 const appId=process.env.NEXT_PUBLIC_PRIVY_APP_ID,appSecret=process.env.PRIVY_APP_SECRET;
 if(!appId||!appSecret)throw new HttpError('Authentication is not configured.',503);
 const privy=new PrivyClient({appId,appSecret});
 try{
  const claims=await privy.utils().auth().verifyAccessToken(bearer.slice(7));
  // Fetch authoritative linked accounts; never accept a client-supplied user object.
  const user=await privy.users()._get(claims.user_id);
  return {id:claims.user_id,user};
 }catch{throw new HttpError('Please sign in again.',401);}
}
export async function ownedWallet(request:Request,input:unknown){
 if(typeof input!=='string'||!isAddress(input))throw new HttpError('Invalid wallet address.',400);
 const {user,id}=await authenticatedUser(request);
 const linked=user.linked_accounts.some(a=>a.type==='wallet'&&a.chain_type==='ethereum'&&a.address.toLowerCase()===input.toLowerCase());
 if(!linked)throw new HttpError('Link and verify this wallet in your Privy account first.',403);
 return {id,address:input as Address};
}
export function failure(error:unknown){return Response.json({error:error instanceof HttpError?error.message:'The request could not be completed.'},{status:error instanceof HttpError?error.status:503,headers:{'Cache-Control':'no-store'}});}

```

## src/lib/chain.ts

```ts
import {base,polygon} from 'viem/chains';
import {isAddress,type Address} from 'viem';

// One chain and gate policy are shared by the UI and the server.
// Changing a NEXT_PUBLIC value requires rebuilding the application.
export const chain=process.env.NEXT_PUBLIC_CHAIN==='polygon'?polygon:base;
export function gateConfig(){
 const address=process.env.NEXT_PUBLIC_GATE_CONTRACT;
 if(!address||!isAddress(address))throw Error('Set a valid gate contract before enabling this feature.');
 const kind=process.env.NEXT_PUBLIC_GATE_STANDARD;
 if(kind!=='erc721'&&kind!=='erc20')throw Error('Gate standard must be erc721 or erc20.');
 const minimum=process.env.NEXT_PUBLIC_GATE_MINIMUM||'1';
 if(!/^\d+(\.\d+)?$/.test(minimum)||Number(minimum)<=0)throw Error('Gate minimum must be positive.');
 return {address:address as Address,kind,minimum};
}

```

## src/lib/gate.ts

```ts
import 'server-only';
import {createPublicClient,http,erc20Abi,erc721Abi,parseUnits,type Address} from 'viem';
import {chain,gateConfig} from './chain';

export async function holdsAccessToken(wallet:Address){
 const gate=gateConfig();const client=createPublicClient({chain,transport:http(process.env.RPC_URL,{timeout:10000,retryCount:1})});
 if(await client.getChainId()!==chain.id)throw Error('The RPC serves a different chain.');
 // Pin all reads to one block so the balance and decimals are consistent.
 const blockNumber=await client.getBlockNumber();
 if(gate.kind==='erc721'){
  const count=await client.readContract({address:gate.address,abi:erc721Abi,functionName:'balanceOf',args:[wallet],blockNumber});
  return {allowed:count>0n,blockNumber};
 }
 const [balance,decimals]=await Promise.all([
  client.readContract({address:gate.address,abi:erc20Abi,functionName:'balanceOf',args:[wallet],blockNumber}),
  client.readContract({address:gate.address,abi:erc20Abi,functionName:'decimals',blockNumber})
 ]);
 // ERC-20 decimals vary: USDC uses 6. Never assume 18 or compare Number balances.
 if((gate.minimum.split('.')[1]?.length||0)>decimals)throw Error('Minimum has more precision than this token.');
 return {allowed:balance>=parseUnits(gate.minimum,decimals),blockNumber};
}

```

## src/lib/upload-quota.ts

```ts
import 'server-only';
import {HttpError} from './auth';

export async function reserveUpload(userId:string){
 const url=process.env.UPSTASH_REDIS_REST_URL,token=process.env.UPSTASH_REDIS_REST_TOKEN;
 // A durable, atomic limit works across server instances. No in-memory production quota.
 if(!url||!token)throw new HttpError('Uploads are not configured.',503);
 const key=`uploads:${userId}:${Math.floor(Date.now()/86400000)}`;
 const script="local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]); end; return n";
 const r=await fetch(url,{method:'POST',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},body:JSON.stringify(['EVAL',script,'1',key,'172800']),signal:AbortSignal.timeout(5000),cache:'no-store'});
 if(!r.ok)throw new HttpError('Upload quota unavailable.',503);
 const data=await r.json() as {result?:number;error?:string};
 if(data.error||typeof data.result!=='number')throw new HttpError('Upload quota unavailable.',503);
 if(data.result>5)throw new HttpError('Five daily upload attempts used. Try again tomorrow (UTC).',429);
}

```

## tsconfig.json

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": [
        "./src/*"
      ]
    },
    "allowJs": true,
    "incremental": true
  },
  "include": [
    "next-env.d.ts",
    "src/**/*.ts",
    "src/**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

```
