diff --git a/docs/integration-guide.md b/docs/integration-guide.md index c18dd92..e42aa92 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -257,3 +257,92 @@ Invalid cache configuration is rejected at construction time with a clear error: new GuildPassClient({ apiUrl: '...', cacheTtl: -1 }); new GuildPassClient({ apiUrl: '...', cache: { get: 'nope' } }); // missing methods ``` + +## 8. Cloudflare Workers (Edge Runtime) + +The SDK runs on V8-isolate edge runtimes (Cloudflare Workers, Vercel Edge Functions, etc.) without any additional configuration — it uses the global `fetch` API already available in those environments. + +A complete, runnable example lives in [`examples/cloudflare-worker/`](../examples/cloudflare-worker/). It demonstrates: + +- **Module-scope client**: `GuildPassClient` is instantiated once and reused across all requests in the same isolate, per the [Workers best-practice for shared resources](https://developers.cloudflare.com/workers/reference/security-model/). +- **KV-backed `CacheAdapter`**: A custom `KVCacheAdapter` class wires Workers KV (`env.GUILDPASS_KV`) into the SDK's pluggable caching interface so responses are cached across isolate instances. +- **Access-check endpoint**: `GET /check-access?wallet=…&guild=…&resource=…` returns a JSON `AccessCheckResult`. + +### Quick-start + +```bash +# 1. Create the KV namespace (one-time) +npx wrangler kv:namespace create GUILDPASS_KV + +# 2. Paste the returned id into examples/cloudflare-worker/wrangler.toml + +# 3. Add your API key as a secret (never commit it) +npx wrangler secret put GUILDPASS_API_KEY + +# 4. Start the local dev server +cd examples/cloudflare-worker +npx wrangler dev +``` + +Test it: + +```bash +curl "http://localhost:8787/check-access?wallet=0x1234…&guild=prime-guild&resource=premium-docs" +# → { "hasAccess": true, "matchedRoles": ["member"], ... } +``` + +### Key pattern — module-scope client + +```typescript +import { GuildPassClient, CacheAdapter } from '@guildpass/sdk'; + +// Initialised once per isolate — reused across every request. +let _client: GuildPassClient | null = null; + +function getClient(env: Env): GuildPassClient { + if (_client) return _client; + _client = new GuildPassClient({ + apiUrl: env.GUILDPASS_API_URL, + apiKey: env.GUILDPASS_API_KEY, + chainId: parseInt(env.GUILDPASS_CHAIN_ID, 10), + cache: new KVCacheAdapter(env.GUILDPASS_KV), + cacheTtl: parseInt(env.GUILDPASS_CACHE_TTL, 10), + }); + return _client; +} +``` + +### Key pattern — KV cache adapter + +```typescript +class KVCacheAdapter implements CacheAdapter { + constructor(private readonly kv: KVNamespace) {} + + async get(key: string): Promise { + try { + const raw = await this.kv.get(key, 'text'); + return raw ? (JSON.parse(raw) as T) : null; + } catch { return null; } + } + + async set(key: string, value: T, ttl?: number): Promise { + try { + // KV expirationTtl is in seconds; SDK passes milliseconds. + const opts = ttl ? { expirationTtl: Math.max(60, Math.ceil(ttl / 1000)) } : undefined; + await this.kv.put(key, JSON.stringify(value), opts); + } catch { /* swallowed — SDK falls back to network */ } + } + + async delete(key: string): Promise { + try { await this.kv.delete(key); } catch { /* swallowed */ } + } + + async clear(): Promise { /* no-op — KV has no flush API from Workers */ } +} +``` + +> **KV TTL note**: Workers KV enforces a minimum `expirationTtl` of 60 seconds. +> For sub-minute freshness, pair the KV adapter with an `InMemoryCacheAdapter` +> in front of it, or handle short-lived data inside the isolate only. + +See the [full example](../examples/cloudflare-worker/) and the [Cache Adapters Guide](./cache-adapters.md) for more detail. diff --git a/examples/cloudflare-worker/README.md b/examples/cloudflare-worker/README.md new file mode 100644 index 0000000..b01a164 --- /dev/null +++ b/examples/cloudflare-worker/README.md @@ -0,0 +1,233 @@ +# GuildPass SDK — Cloudflare Workers Example + +A minimal Cloudflare Worker that demonstrates how to integrate the GuildPass SDK in an edge runtime. + +## What this example shows + +| Feature | Where | +|:--------|:------| +| Module-scope `GuildPassClient` (reused across requests) | `src/index.ts` — `getClient()` | +| KV-backed `CacheAdapter` wiring | `src/index.ts` — `KVCacheAdapter` class | +| Access-check endpoint (`GET /check-access`) | `src/index.ts` — `handleCheckAccess()` | +| Wrangler config with KV binding and env var declarations | `wrangler.toml` | + +The example is intentionally minimal — no extra dependencies beyond `@guildpass/sdk` and Wrangler. + +--- + +## Prerequisites + +- [Node.js](https://nodejs.org) 18+ +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) v3+ + +```bash +# Install Wrangler globally (or use npx) +npm install -g wrangler + +# Authenticate with your Cloudflare account +wrangler login +``` + +--- + +## Setup + +### 1. Install dependencies + +From the **repository root**: + +```bash +pnpm install +``` + +Or from this example directory directly (requires the SDK to be built first): + +```bash +# Build the SDK +pnpm --filter @guildpass/sdk build + +# Install example deps +cd examples/cloudflare-worker +npm install +``` + +### 2. Create the KV namespace + +Workers KV is used as the cache backend. Create a namespace once: + +```bash +npx wrangler kv:namespace create GUILDPASS_KV +``` + +Copy the `id` printed to the terminal and paste it into `wrangler.toml`: + +```toml +[[kv_namespaces]] +binding = "GUILDPASS_KV" +id = "paste-your-id-here" +``` + +For local development, also create a preview namespace: + +```bash +npx wrangler kv:namespace create GUILDPASS_KV --preview +``` + +Uncomment and fill in `preview_id` in `wrangler.toml`. + +### 3. Configure environment variables + +Non-secret variables (`GUILDPASS_API_URL`, `GUILDPASS_CHAIN_ID`, `GUILDPASS_CACHE_TTL`) are already set in `wrangler.toml`. Adjust them for your deployment target. + +The API key is a **secret** and must never be committed to source control. + +**For local development**, create a `.dev.vars` file in this directory (it is gitignored): + +``` +GUILDPASS_API_KEY=your-dev-api-key +``` + +**For deployed Workers**, use the Wrangler CLI: + +```bash +npx wrangler secret put GUILDPASS_API_KEY +# Enter the value when prompted +``` + +Or set it in the [Workers dashboard](https://dash.cloudflare.com) under **Settings → Variables → Secret variables**. + +### Environment variable reference + +| Variable | Type | Required | Description | +|:---------|:-----|:--------:|:------------| +| `GUILDPASS_API_URL` | `string` | ✅ | GuildPass API base URL (e.g. `https://api.guildpass.xyz`) | +| `GUILDPASS_API_KEY` | `string` (secret) | ✅ | Your GuildPass API key | +| `GUILDPASS_CHAIN_ID` | `string` | ✅ | Numeric chain ID (e.g. `8453` for Base Mainnet, `1` for Ethereum) | +| `GUILDPASS_CACHE_TTL` | `string` | ✅ | Response cache TTL in **milliseconds** (e.g. `30000` = 30 s) | +| `GUILDPASS_KV` | `KVNamespace` | ✅ | Workers KV binding — configured via `wrangler.toml`, not a plain env var | + +--- + +## Running locally + +```bash +npx wrangler dev +``` + +Wrangler starts a local dev server (default `http://localhost:8787`). Test the endpoint: + +```bash +# Access check +curl "http://localhost:8787/check-access?wallet=0x1234567890123456789012345678901234567890&guild=prime-guild&resource=premium-docs" + +# Health probe +curl "http://localhost:8787/health" +``` + +### Example responses + +**Access granted:** +```json +{ + "hasAccess": true, + "reason": null, + "matchedRoles": ["member"], + "requiredRoles": ["member"] +} +``` + +**Access denied:** +```json +{ + "hasAccess": false, + "reason": "No matching role", + "matchedRoles": [], + "requiredRoles": ["member"] +} +``` + +**Missing parameters (400):** +```json +{ "error": "Missing required query parameters: guild, resource" } +``` + +--- + +## Deploying + +```bash +# Deploy to the default environment +npx wrangler deploy + +# Deploy to the production environment +npx wrangler deploy --env production +``` + +--- + +## How it works + +### Module-scope client + +```typescript +let _client: GuildPassClient | null = null; + +function getClient(env: Env): GuildPassClient { + if (_client) return _client; + _client = new GuildPassClient({ ... }); + return _client; +} +``` + +Cloudflare Workers reuse the same V8 isolate — and therefore the same module +scope — across multiple requests until the isolate is evicted. Initialising +the client once at module scope avoids redundant config validation and +connection overhead on every request. This is a standard Workers best +practice for any long-lived resource. + +### KV-backed CacheAdapter + +```typescript +class KVCacheAdapter implements CacheAdapter { + constructor(private readonly kv: KVNamespace) {} + + async get(key: string): Promise { /* ... */ } + async set(key: string, value: T, ttl?: number): Promise { /* ... */ } + async delete(key: string): Promise { /* ... */ } + async clear(): Promise { /* no-op — KV doesn't support flush from Workers */ } +} +``` + +The adapter implements the [`CacheAdapter`](../../docs/cache-adapters.md) interface. All methods swallow errors silently so a KV failure never prevents a live access check — the SDK falls through to the network automatically. + +**KV TTL note:** Workers KV enforces a minimum `expirationTtl` of 60 seconds. If your `GUILDPASS_CACHE_TTL` is shorter (e.g. for local dev), the adapter clamps to 60 s. For sub-minute freshness, consider pairing the KV adapter with an in-process `InMemoryCacheAdapter` in front of it. + +### Cache invalidation + +Use the built-in client helpers when you need to evict stale entries: + +```typescript +// Evict all entries for a specific guild (e.g. after an admin config change) +await client.invalidateGuildCache('prime-guild'); + +// Evict all entries for a wallet +await client.invalidateWalletCache('0x1234...5678'); + +// Full cache wipe (no-op on KV adapter — use Wrangler CLI instead) +await client.clearCache(); +``` + +Because Workers KV does not expose a prefix-delete API from within Workers code, +`invalidateGuildCache` and `invalidateWalletCache` fall back to deleting known +exact keys. For production use, consider adding a management endpoint that calls +the [KV REST API](https://developers.cloudflare.com/api/resources/kv/subresources/namespaces/subresources/keys/) to bulk-delete by prefix. + +--- + +## Further reading + +- [Cache Adapters Guide](../../docs/cache-adapters.md) +- [Integration Guide](../../docs/integration-guide.md) +- [SDK Usage Guide](../../docs/sdk-guide.md) +- [Cloudflare Workers KV documentation](https://developers.cloudflare.com/kv/) +- [Wrangler CLI documentation](https://developers.cloudflare.com/workers/wrangler/) diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts new file mode 100644 index 0000000..52ff7a3 --- /dev/null +++ b/examples/cloudflare-worker/src/index.ts @@ -0,0 +1,238 @@ +/** + * GuildPass SDK — Cloudflare Workers example + * + * Demonstrates: + * 1. Module-scope client instantiation (reused across requests in the same isolate) + * 2. A KV-backed CacheAdapter that satisfies the SDK's CacheAdapter interface + * 3. An access-check endpoint that reads walletAddress / guildId / resourceId + * from query parameters and returns a JSON AccessCheckResult + * + * Required environment bindings (set in wrangler.toml or the Workers dashboard): + * - GUILDPASS_API_URL : string — e.g. "https://api.guildpass.xyz" + * - GUILDPASS_API_KEY : string — your GuildPass API key (keep secret) + * - GUILDPASS_CHAIN_ID : string — numeric chain ID, e.g. "8453" (Base Mainnet) + * - GUILDPASS_CACHE_TTL : string — TTL in milliseconds, e.g. "30000" + * - GUILDPASS_KV : KVNamespace — Workers KV binding for response caching + */ + +import { GuildPassClient, CacheAdapter } from '@guildpass/sdk'; + +// --------------------------------------------------------------------------- +// KV-backed CacheAdapter +// --------------------------------------------------------------------------- + +/** + * A CacheAdapter backed by Cloudflare Workers KV. + * + * TTL is forwarded to KV's native `expirationTtl` (seconds). The SDK passes + * values in **milliseconds**, so we convert before storing. + * + * Per the CacheAdapter contract, every method silently swallows errors so + * that a KV failure never breaks a live access check. + */ +class KVCacheAdapter implements CacheAdapter { + constructor(private readonly kv: KVNamespace) {} + + async get(key: string): Promise { + try { + const raw = await this.kv.get(key, 'text'); + if (raw === null) return null; + return JSON.parse(raw) as T; + } catch { + return null; + } + } + + async set(key: string, value: T, ttl?: number): Promise { + try { + const serialised = JSON.stringify(value); + // KV expirationTtl is in seconds (minimum 60 s per KV docs). + // If the SDK requests a shorter TTL we still store it — KV will evict + // it at the earliest opportunity (60 s). For production use, consider + // skipping the KV write when ttl < 60_000 ms and relying on per-isolate + // in-memory caching instead. + if (ttl !== undefined && ttl > 0) { + const ttlSeconds = Math.max(60, Math.ceil(ttl / 1000)); + await this.kv.put(key, serialised, { expirationTtl: ttlSeconds }); + } else { + // No TTL — store indefinitely until explicitly deleted. + await this.kv.put(key, serialised); + } + } catch { + // swallowed — SDK falls back to network + } + } + + async delete(key: string): Promise { + try { + await this.kv.delete(key); + } catch { + // swallowed + } + } + + async clear(): Promise { + // Workers KV does not expose a "flush namespace" API from Workers code. + // A full clear requires the REST API or Wrangler CLI. This is a no-op + // that the SDK handles gracefully (it will continue without eviction). + } + + /** + * KV does not support prefix-range deletes from within a Worker. + * Omitting this optional method lets the SDK fall back to its own strategy + * (deleting known exact keys, or falling back to clear()). + */ + // deleteByPrefix is intentionally omitted — KV doesn't support it natively. +} + +// --------------------------------------------------------------------------- +// Env interface — typed bindings declared in wrangler.toml +// --------------------------------------------------------------------------- + +export interface Env { + GUILDPASS_API_URL: string; + GUILDPASS_API_KEY: string; + GUILDPASS_CHAIN_ID: string; + GUILDPASS_CACHE_TTL: string; + GUILDPASS_KV: KVNamespace; +} + +// --------------------------------------------------------------------------- +// Module-scope client (lazily initialised once per isolate) +// --------------------------------------------------------------------------- + +/** + * We keep the client reference at module scope so it is reused across every + * request handled by the same isolate — avoiding the overhead of recreating + * HTTP connections, re-validating config, and warming up caches on every + * request. This is the recommended Workers pattern for shared resources. + * + * The client is initialised on the first request (lazy init) so that `env` + * bindings are available when we need them. + */ +let _client: GuildPassClient | null = null; + +function getClient(env: Env): GuildPassClient { + if (_client) return _client; + + const cacheTtl = parseInt(env.GUILDPASS_CACHE_TTL ?? '30000', 10); + + _client = new GuildPassClient({ + apiUrl: env.GUILDPASS_API_URL, + apiKey: env.GUILDPASS_API_KEY, + chainId: parseInt(env.GUILDPASS_CHAIN_ID ?? '1', 10), + // Plug in the KV-backed cache adapter for cross-isolate response caching. + cache: new KVCacheAdapter(env.GUILDPASS_KV), + cacheTtl: Number.isFinite(cacheTtl) && cacheTtl >= 0 ? cacheTtl : 30_000, + }); + + return _client; +} + +// --------------------------------------------------------------------------- +// Fetch handler +// --------------------------------------------------------------------------- + +export default { + async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise { + const url = new URL(request.url); + + // Route: GET /check-access?wallet=0x…&guild=…&resource=… + if (request.method === 'GET' && url.pathname === '/check-access') { + return handleCheckAccess(request, env, url); + } + + // Route: GET /health — simple liveness probe + if (request.method === 'GET' && url.pathname === '/health') { + return jsonResponse({ status: 'ok' }); + } + + return jsonResponse({ error: 'Not Found' }, 404); + }, +}; + +// --------------------------------------------------------------------------- +// Route handlers +// --------------------------------------------------------------------------- + +/** + * GET /check-access + * + * Query parameters: + * - wallet (required) — EIP-55 checksummed or lowercase wallet address + * - guild (required) — guild slug / ID registered in GuildPass + * - resource (required) — resource ID to gate + * + * Response (200): + * ```json + * { + * "hasAccess": true, + * "matchedRoles": ["member"], + * "cached": false + * } + * ``` + * + * Response (400) on missing params: + * ```json + * { "error": "Missing required query parameters: wallet, guild, resource" } + * ``` + */ +async function handleCheckAccess( + _request: Request, + env: Env, + url: URL, +): Promise { + const walletAddress = url.searchParams.get('wallet'); + const guildId = url.searchParams.get('guild'); + const resourceId = url.searchParams.get('resource'); + + // Validate required parameters + const missing: string[] = []; + if (!walletAddress) missing.push('wallet'); + if (!guildId) missing.push('guild'); + if (!resourceId) missing.push('resource'); + + if (missing.length > 0) { + return jsonResponse( + { error: `Missing required query parameters: ${missing.join(', ')}` }, + 400, + ); + } + + const client = getClient(env); + + try { + const result = await client.access.checkAccess({ + walletAddress: walletAddress!, + guildId: guildId!, + resourceId: resourceId!, + }); + + return jsonResponse({ + hasAccess: result.hasAccess, + reason: result.reason ?? null, + matchedRoles: result.matchedRoles ?? [], + requiredRoles: result.requiredRoles ?? [], + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Internal error'; + console.error('[GuildPass] checkAccess failed:', message); + return jsonResponse({ error: 'Failed to perform access check', detail: message }, 502); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json', + // Prevent caching of access-check responses at the CDN layer — + // freshness is handled by the SDK's KV cache adapter above. + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/examples/cloudflare-worker/wrangler.toml b/examples/cloudflare-worker/wrangler.toml new file mode 100644 index 0000000..97fd967 --- /dev/null +++ b/examples/cloudflare-worker/wrangler.toml @@ -0,0 +1,68 @@ +# wrangler.toml — Cloudflare Workers configuration for the GuildPass example +# +# Prerequisites: +# 1. Install Wrangler: pnpm add -D wrangler (or npm/yarn) +# 2. Authenticate: npx wrangler login +# 3. Create the KV namespace (one-time): +# npx wrangler kv:namespace create GUILDPASS_KV +# Copy the returned `id` into the [[kv_namespaces]] block below. +# +# Environment variables that contain secrets (GUILDPASS_API_KEY) should be +# set via the Wrangler CLI or the Workers dashboard — never committed to source +# control. The values below are placeholders for local development only. + +name = "guildpass-worker-example" +main = "src/index.ts" +compatibility_date = "2024-09-23" + +# --------------------------------------------------------------------------- +# Workers KV namespace binding +# Replace the `id` value with the output of: +# npx wrangler kv:namespace create GUILDPASS_KV +# --------------------------------------------------------------------------- +[[kv_namespaces]] +binding = "GUILDPASS_KV" +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" +# Uncomment the line below for local `wrangler dev` — create a preview +# namespace with: npx wrangler kv:namespace create GUILDPASS_KV --preview +# preview_id = "REPLACE_WITH_YOUR_KV_PREVIEW_NAMESPACE_ID" + +# --------------------------------------------------------------------------- +# Non-secret environment variables +# These are safe to commit. Adjust values for your deployment target. +# --------------------------------------------------------------------------- +[vars] +GUILDPASS_API_URL = "https://api.guildpass.xyz" +GUILDPASS_CHAIN_ID = "8453" # Base Mainnet — change to 1 for Ethereum +GUILDPASS_CACHE_TTL = "30000" # 30 s in milliseconds + +# --------------------------------------------------------------------------- +# Secret binding — DO NOT set the real value here. +# Set it via the CLI instead: +# npx wrangler secret put GUILDPASS_API_KEY +# For local dev you can add it to a .dev.vars file (gitignored): +# echo 'GUILDPASS_API_KEY=your-dev-key' >> .dev.vars +# --------------------------------------------------------------------------- +# GUILDPASS_API_KEY is intentionally absent from [vars] — it is a secret. + +# --------------------------------------------------------------------------- +# Local development overrides (applied only during `wrangler dev`) +# --------------------------------------------------------------------------- +[env.development] +name = "guildpass-worker-example-dev" + +[env.development.vars] +GUILDPASS_API_URL = "https://api.guildpass.xyz" +GUILDPASS_CHAIN_ID = "8453" +GUILDPASS_CACHE_TTL = "5000" # Shorter TTL during local dev + +# --------------------------------------------------------------------------- +# Production overrides (applied during `wrangler deploy --env production`) +# --------------------------------------------------------------------------- +[env.production] +name = "guildpass-worker-example" + +[env.production.vars] +GUILDPASS_API_URL = "https://api.guildpass.xyz" +GUILDPASS_CHAIN_ID = "8453" +GUILDPASS_CACHE_TTL = "60000" # 60 s TTL in production