{"items":[{"id":"cmugwhsew01gnqu06au6h66y7","slug":"glitternetwork-pinme-pinme-auth","name":"pinme-auth","description":"Use when a PinMe project (Worker TypeScript) needs to integrate user authentication — creating email/password users, verifying id_tokens, querying user info, or listing users via Identity Platform auth proxy APIs.","authorId":"gh:glitternetwork","authorName":"glitternetwork","version":"0.1.0","category":"Prompt","securityLevel":"Sandbox","downloadsCount":0,"githubStars":3745,"pricePerCall":0,"manifest":{"name":"pinme-auth","tools":[],"category":"Prompt","entrypoint":{"type":"prompt"},"description":"Use when a PinMe project (Worker TypeScript) needs to integrate user authentication — creating email/password users, verifying id_tokens, querying user info, or listing users via Identity Platform auth proxy APIs.","permissions":[],"systemPrompt":"# PinMe Worker Auth API Integration\n\nGuides how to call PinMe platform's Identity Platform auth proxy APIs in a PinMe Worker (TypeScript).\n\n## Environment Variables\n\n```typescript\n// backend/src/worker.ts\nexport interface Env {\n  DB: D1Database;\n  API_KEY: string;       // 项目 API Key — 用于所有 auth 接口认证\n  PROJECT_NAME: string;  // 项目名 — 所有 auth 接口必须同时传递\n  BASE_URL?: string;     // 可选，默认 https://pinme.cloud\n}\n```\n\n> `API_KEY` 和 `PROJECT_NAME` 是所有 auth 接口的必填凭证，缺一不可。\n\n---\n\n## 认证方式（所有接口通用）\n\n| 参数 | 传递方式 | 必填 | 说明 |\n|------|---------|------|------|\n| `X-API-Key` | 请求头 | 是 | 项目 API Key |\n| `project_name` | Query 参数 | 是 | 必须与 `X-API-Key` 对应同一个项目 |\n\n服务端会先校验这两个字段是否匹配同一个项目，再从项目配置中取出 `tenant_id`，然后转调 Identity Platform。\n\n---\n\n## 通用错误\n\n| 场景 | HTTP | `data.error` |\n|------|------|-------------|\n| 缺少 `X-API-Key` | 401 | `X-API-Key header is required` |\n| 缺少 `project_name` | 400 | `project_name is required` |\n| API Key 和项目不匹配 | 401 | `Invalid API key or project name` |\n| 项目未配置认证租户 | 400 | `Auth service not configured for this project` |\n\n---\n\n## 通用 TypeScript 类型\n\n```typescript\ntype ApiEnvelope<T> = {\n  code: number   // 200=成功，其他=失败\n  msg: string    // \"ok\" | \"fail\" | \"invalid param\"\n  data: T\n}\n\ntype ApiErrorData = { error?: string }\n\ntype UserInfo = {\n  uid: string\n  email: string\n  display_name: string\n  photo_url?: string\n  disabled: boolean\n  email_verified: boolean\n}\n```\n\n---\n\n## API 1: 创建用户\n\n**Endpoint:** `POST {BASE_URL}/api/v1/auth/create_user?project_name={project_name}`\n\n仅用于邮箱密码注册。成功时用户已创建且验证邮件已发出；失败时自动回滚，不会留下僵尸账号。\n\n> 创建成功后用户默认仍是\"未验证\"状态，需点击邮件验证链接后，`verify_token` 才能通过校验。\n\n### 请求体\n\n```json\n{ \"email\": \"alice@example.com\", \"password\": \"Test@12345678\", \"display_name\": \"Alice\" }\n```\n\n| 字段 | 类型 | 必填 |\n|------|------|------|\n| `email` | string | 是 |\n| `password` | string | 是 |\n| `display_name` | string | 否 |\n\n### 错误\n\n| 场景 | HTTP | `data.error` |\n|------|------|-------------|\n| 缺少 email/password | 400 | `email and password are required` |\n| 上游创建失败 | 502 | `Failed to create user` |\n| 发送验证邮件失败 | 500 | `Failed to send verification email. Please try again.` |\n\n### TypeScript 示例\n\n```typescript\nasync function createAuthUser(\n  env: Env,\n  payload: { email: string; password: string; display_name?: string }\n): Promise<{ user?: UserInfo; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/auth/create_user?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: { 'X-API-Key': env.API_KEY, 'Content-Type': 'application/json' },\n      body: JSON.stringify(payload),\n    }\n  );\n  const result = await resp.json() as ApiEnvelope<UserInfo | ApiErrorData>;\n  if (!resp.ok || result.code !== 200) {\n    return { error: (result.data as ApiErrorData)?.error ?? result.msg };\n  }\n  return { user: result.data as UserInfo };\n}\n```\n\n---\n\n## API 2: 校验 id_token\n\n**Endpoint:** `POST {BASE_URL}/api/v1/auth/verify_token?project_name={project_name}`\n\n校验前端登录后拿到的 `id_token`（邮箱密码或 Google 登录均适用）。\n\n**注意：** token 合法但邮箱未验证时返回 `403`，不是 `401`。\n\n### 请求体\n\n```json\n{ \"id_token\": \"eyJhbGciOiJSUzI1NiIsImtpZCI6...\" }\n```\n\n### 成功响应 data\n\n```typescript\ntype VerifyTokenData = {\n  uid: string\n  email?: string\n  tenant_id: string\n  claims: Record<string, unknown>\n}\n```\n\n### 错误\n\n| 场景 | HTTP | `data.error` |\n|------|------|-------------|\n| 缺少 `id_token` | 400 | `id_token is required` |\n| token 无效或过期 | 401 | `Invalid or expired token` |\n| 邮箱未验证 | 403 | `Email not verified. Please check your inbox and verify your email address.` |\n\n### TypeScript 示例\n\n```typescript\nasync function verifyAuthToken(\n  env: Env,\n  idToken: string\n): Promise<{ uid?: string; email?: string; error?: string; emailNotVerified?: boolean }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/auth/verify_token?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: { 'X-API-Key': env.API_KEY, 'Content-Type': 'application/json' },\n      body: JSON.stringify({ id_token: idToken }),\n    }\n  );\n  const result = await resp.json() as ApiEnvelope<VerifyTokenData | ApiErrorData>;\n  if (!resp.ok || result.code !== 200) {\n    const error = (result.data as ApiErrorData)?.error ?? result.msg;\n    return { error, emailNotVerified: resp.status === 403 };\n  }\n  const data = result.data as VerifyTokenData;\n  return { uid: data.uid, email: data.email };\n}\n```\n\n---\n\n## API 3: 查询单个用户\n\n**Endpoint:** `GET {BASE_URL}/api/v1/auth/user?project_name={project_name}&uid={uid}`\n\n### 错误\n\n| 场景 | HTTP | `data.error` |\n|------|------|-------------|\n| 缺少 `uid` | 400 | `uid is required` |\n| 用户不存在 | 404 | `User not found` |\n| 上游查询失败 | 502 | `Failed to get user` |\n\n### TypeScript 示例\n\n```typescript\nasync function getAuthUser(env: Env, uid: string): Promise<{ user?: UserInfo; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/auth/user?project_name=${encodeURIComponent(env.PROJECT_NAME)}&uid=${encodeURIComponent(uid)}`,\n    { method: 'GET', headers: { 'X-API-Key': env.API_KEY } }\n  );\n  const result = await resp.json() as ApiEnvelope<UserInfo | ApiErrorData>;\n  if (!resp.ok || result.code !== 200) {\n    return { error: (result.data as ApiErrorData)?.error ?? result.msg };\n  }\n  return { user: result.data as UserInfo };\n}\n```\n\n---\n\n## API 4: 列出用户（分页）\n\n**Endpoint:** `GET {BASE_URL}/api/v1/auth/list_users?project_name={project_name}`\n\n默认 `max_results=100`，最大 `1000`。通过 `next_page_token` 循环翻页。\n\n### Query 参数\n\n| 参数 | 必填 | 说明 |\n|------|------|------|\n| `project_name` | 是 | 项目名 |\n| `page_token` | 否 | 分页游标 |\n| `max_results` | 否 | 每页数量，1–1000 |\n\n### TypeScript 示例\n\n```typescript\nasync function listAuthUsers(\n  env: Env,\n  options: { pageToken?: string; maxResults?: number } = {}\n): Promise<{ users?: UserInfo[]; nextPageToken?: string; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const url = new URL('/api/v1/auth/list_users', baseUrl);\n  url.searchParams.set('project_name', env.PROJECT_NAME);\n  if (options.pageToken) url.searchParams.set('page_token', options.pageToken);\n  if (options.maxResults) url.searchParams.set('max_results', String(options.maxResults));\n\n  const resp = await fetch(url.toString(), { method: 'GET', headers: { 'X-API-Key': env.API_KEY } });\n  const result = await resp.json() as ApiEnvelope<{ users: UserInfo[]; next_page_token?: string } | ApiErrorData>;\n  if (!resp.ok || result.code !== 200) {\n    return { error: (result.data as ApiErrorData)?.error ?? result.msg };\n  }\n  const data = result.data as { users: UserInfo[]; next_page_token?: string };\n  return { users: data.users, nextPageToken: data.next_page_token };\n}\n\n// 批量遍历所有用户示例\nasync function* iterAllUsers(env: Env) {\n  let pageToken: string | undefined;\n  do {\n    const { users, nextPageToken, error } = await listAuthUsers(env, { pageToken, maxResults: 1000 });\n    if (error) throw new Error(error);\n    for (const user of users ?? []) yield user;\n    pageToken = nextPageToken;\n  } while (pageToken);\n}\n```\n\n---\n\n## 前端集成（Firebase Auth）\n\n`create_worker` 响应中包含 `public_client_config`，前端用它初始化 Firebase Auth SDK。\n\n### 两种 api_key 区分\n\n| 字段 | 用途 | 是否可暴露到浏览器 |\n|------|------|-----------------|\n| `data.api_key` | 项目 API Key，调用本文所有代理接口 | **不能**，只给 Worker/服务端 |\n| `data.public_client_config.auth_api_key` | Firebase Web API Key，初始化前端登录 SDK | 可以 |\n\n### public_client_config 字段说明\n\n| 字段 | 前端用途 |\n|------|---------|\n| `public_client_config.auth_api_key` | `initializeApp({ apiKey })` |\n| `public_client_config.auth_domain` | `initializeApp({ authDomain })` |\n| `public_client_config.auth_project_id` | `initializeApp({ projectId })` |\n| `public_client_config.tenant_id` | `auth.tenantId = config.tenant_id`（必须设置，否则 token 归属错误） |\n\n### 前端 TypeScript 示例\n\n```typescript\nimport { initializeApp } from 'firebase/app'\nimport {\n  type Auth,\n  getAuth,\n  GoogleAuthProvider,\n  signInWithEmailAndPassword,\n  signInWithPopup,\n} from 'firebase/auth'\n\ntype PublicClientConfig = {\n  tenant_id: string\n  auth_api_key: string\n  auth_domain: string\n  auth_project_id: string\n}\n\nexport function createProjectAuth(config: PublicClientConfig): Auth {\n  const app = initializeApp({\n    apiKey: config.auth_api_key,\n    authDomain: config.auth_domain,\n    projectId: config.auth_project_id,\n  })\n  const auth = getAuth(app)\n  auth.tenantId = config.tenant_id  // 必须设置，确保 token 归属正确租户\n  return auth\n}\n\n// 邮箱密码登录，返回 id_token\nexport async function loginWithEmail(auth: Auth, email: string, password: string): Promise<string> {\n  const credential = await signInWithEmailAndPassword(auth, email, password)\n  return credential.user.getIdToken()\n}\n\n// Google 登录，返回 id_token\nexport async function loginWithGoogle(auth: Auth): Promise<string> {\n  const credential = await signInWithPopup(auth, new GoogleAuthProvider())\n  return credential.user.getIdToken()\n}\n\n// 用法示例\n// pinme create 会自动将 public_client_config 写入 frontend/src/utils/config.ts\nimport { public_client_config } from '../utils/config'\n\nconst auth = createProjectAuth(public_client_config)\nconst idToken = await loginWithGoogle(auth)\n// 然后把 idToken 发给自己的 Worker，由 Worker 调用 verify_token\n```\n\n> 前端只负责登录和拿 `id_token`，不要直接持有项目 `api_key`。`verify_token` 必须由 Worker/服务端代调。\n> `frontend/src/utils/config.ts` 由 `pinme create` 自动生成，无需手动创建。\n\n---\n\n## 典型调用链路\n\n**邮箱密码注册流程：**\n1. `create_user` → 创建用户并发出验证邮件\n2. 用户点击邮件链接完成验证\n3. 前端登录拿到 `id_token`\n4. `verify_token` → 校验 token，取得 `uid`\n5. 需要时再调 `getAuthUser` 读取完整用户信息\n\n**Google 登录流程：**\n1. 前端完成 Google Sign-In，拿到 `id_token`\n2. `verify_token` → 校验 token（无需调用 `create_user`）\n\n---\n\n## 易错点\n\n| 错误 | 正确做法 |\n|------|---------|\n| 只传 `X-API-Key`，忘记 `project_name` | 每个请求都要同时带 `X-API-Key` header 和 `project_name` query |\n| `verify_token` 返回 403 时当 token 失效处理 | 403 = 邮箱未验证，提示用户检查邮箱；401 才是 token 失效 |\n| `create_user` 成功就认为邮箱已验证 | 创建成功只代表验证邮件已发，用户必须点击后才算验证 |\n| `list_users` 只取第一页 | 有 `next_page_token` 时需继续请求，直到为空 |\n| 成功判断只看 `resp.ok` | 同时判断 `resp.ok && result.code === 200` |","schemaVersion":1},"repoUrl":"https://github.com/glitternetwork/pinme/tree/main/skills/pinme-auth","tags":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy"],"stats":{"installVelocity7d":0,"retentionRate":0,"executions":0,"rating":null},"origin":"github","source":{"repo":"pinme","audit":{"files":["package-lock.json","package.json","pnpm-lock.yaml"],"binaries":[],"findings":[{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Uncontrolled memory allocation via the declared uncompressed size (DoS).","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-7q85-xj36-vmfc · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip extraction follows destination symlinks, allowing arbitrary file overwrite.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-vwc7-r8mq-g2x9 · npm:adm-zip@0.5.17","severity":"medium"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Crafted ZIP file triggers 4GB memory allocation.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-xcpc-8h2w-3j85 · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"uuid@9.0.1 has a known vulnerability: uuid: Missing buffer bounds check in v3/v5/v6 when buf is provided.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-w5hq-g745-h8pq · npm:uuid@9.0.1","severity":"medium"}],"packages":16,"auditedAt":"2026-09-25T11:51:56.672Z","lockfiles":["package-lock.json","pnpm-lock.yaml"]},"forks":277,"owner":"glitternetwork","stars":3745,"topics":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy","static-site-hosting","web-hosting","zero-configuration"],"license":"MIT","fullName":"glitternetwork/pinme","homepage":"https://pinme.eth.limo","language":"TypeScript","pushedAt":"2026-09-12T05:23:49Z","avatarUrl":"https://avatars.githubusercontent.com/u/102277171?v=4","crawledAt":"2026-09-25T11:51:44.477Z","openIssues":7,"manifestFile":"SKILL.md","manifestPath":"skills/pinme-auth/SKILL.md","defaultBranch":"main"},"readme":"# PinMe Worker Auth API Integration\n\nGuides how to call PinMe platform's Identity Platform auth proxy APIs in a PinMe Worker (TypeScript).\n\n## Environment Variables\n\n```typescript\n// backend/src/worker.ts\nexport interface Env {\n  DB: D1Database;\n  API_KEY: string;       // 项目 API Key — 用于所有 auth 接口认证\n  PROJECT_NAME: string;  // 项目名 — 所有 auth 接口必须同时传递\n  BASE_URL?: string;     // 可选，默认 https://pinme.cloud\n}\n```\n\n> `API_KEY` 和 `PROJECT_NAME` 是所有 auth 接口的必填凭证，缺一不可。\n\n---\n\n## 认证方式（所有接口通用）\n\n| 参数 | 传递方式 | 必填 | 说明 |\n|------|---------|------|------|\n| `X-API-Key` | 请求头 | 是 | 项目 API Key |\n| `project_name` | Query 参数 | 是 | 必须与 `X-API-Key` 对应同一个项目 |\n\n服务端会先校验这两个字段是否匹配同一个项目，再从项目配置中取出 `tenant_id`，然后转调 Identity Platform。\n\n---\n\n## 通用错误\n\n| 场景 | HTTP | `data.error` |\n|------|------|-------------|\n| 缺少 `X-API-Key` | 401 | `X-API-Key header is required` |\n| 缺少 `project_name` | 400 | `project_name is required` |\n| API Key 和项目不匹配 | 401 | `Invalid API key or project name` |\n| 项目未配置认证租户 | 400 | `Auth service not configured for this project` |\n\n---\n\n## 通用 TypeScript 类型\n\n```typescript\ntype ApiEnvelope<T> = {\n  code: number   // 200=成功，其他=失败\n  msg: string    // \"ok\" | \"fail\" | \"invalid param\"\n  data: T\n}\n\ntype ApiErrorData = { error?: string }\n\ntype UserInfo = {\n  uid: string\n  email: string\n  display_name: string\n  photo_url?: string\n  disabled: boolean\n  email_verified: boolean\n}\n```\n\n---\n\n## API 1: 创建用户\n\n**Endpoint:** `POST {BASE_URL}/api/v1/auth/create_user?project_name={project_name}`\n\n仅用于邮箱密码注册。成功时用户已创建且验证邮件已发出；失败时自动回滚，不会留下僵尸账号。\n\n> 创建成功后用户默认仍是\"未验证\"状态，需点击邮件验证链接后，`verify_token` 才能通过校验。\n\n### 请求体\n\n```json\n{ \"email\": \"alice@example.com\", \"password\": \"Test@12345678\", \"display_name\": \"Alice\" }\n```\n\n| 字段 | 类型 | 必填 |\n|------|------|------|\n| `email` | string | 是 |\n| `password` | string | 是 |\n| `display_name` | string | 否 |\n\n### 错误\n\n| 场景 | HTTP | `data.error` |\n|------|------|-------------|\n| 缺少 email/password | 400 | `email and password are required` |\n| 上游创建失败 | 502 | `Failed to create user` |\n| 发送验证邮件失败 | 500 | `Failed to send verification email. Please try again.` |\n\n### TypeScript 示例\n\n```typescript\nasync function createAuthUser(\n  env: Env,\n  payload: { email: string; password: string; display_name?: string }\n): Promise<{ user?: UserInfo; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/auth/create_user?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: { 'X-API-Key': env.API_KEY, 'Content-Type': 'application/json' },\n      body: JSON.stringify(payload),\n    }\n  );\n  const result = await resp.json() as ApiEnvelope<UserInfo | ApiErrorData>;\n  if (!resp.ok || result.code !== 200) {\n    return { error: (result.data as ApiErrorData)?.error ?? result.msg };\n  }\n  return { user: result.data as UserInfo };\n}\n```\n\n---\n\n## API 2: 校验 id_token\n\n**Endpoint:** `POST {BASE_URL}/api/v1/auth/verify_token?project_name={project_name}`\n\n校验前端登录后拿到的 `id_token`（邮箱密码或 Google 登录均适用）。\n\n**注意：** token 合法但邮箱未验证时返回 `403`，不是 `401`。\n\n### 请求体\n\n```json\n{ \"id_token\": \"eyJhbGciOiJSUzI1NiIsImtpZCI6...\" }\n```\n\n### 成功响应 data\n\n```typescript\ntype VerifyTokenData = {\n  uid: string\n  email?: string\n  tenant_id: string\n  claims: Record<string, unknown>\n}\n```\n\n### 错误\n\n| 场景 | HTTP | `data.error` |\n|------|------|-------------|\n| 缺少 `id_token` | 400 | `id_token is required` |\n| token 无效或过期 | 401 | `Invalid or expired token` |\n| 邮箱未验证 | 403 | `Email not verified. Please check your inbox and verify your email address.` |\n\n### TypeScript 示例\n\n```typescript\nasync function verifyAuthToken(\n  env: Env,\n  idToken: string\n): Promise<{ uid?: string; email?: string; error?: string; emailNotVerified?: boolean }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/auth/verify_token?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: { 'X-API-Key': env.API_KEY, 'Content-Type': 'applicati","createdAt":"2026-09-25T11:51:56.696Z","updatedAt":"2026-09-25T11:51:56.696Z"},{"id":"cmugwhsf701gqqu06a0r4op12","slug":"glitternetwork-pinme-pinme-email","name":"pinme-email","description":"Use this skill when a PinMe project (Worker TypeScript) needs to integrate email sending (send_email). Guides AI to generate correct Worker TS code.","authorId":"gh:glitternetwork","authorName":"glitternetwork","version":"0.1.0","category":"Prompt","securityLevel":"Sandbox","downloadsCount":0,"githubStars":3745,"pricePerCall":0,"manifest":{"name":"pinme-email","tools":[],"category":"Prompt","entrypoint":{"type":"prompt"},"description":"Use this skill when a PinMe project (Worker TypeScript) needs to integrate email sending (send_email). Guides AI to generate correct Worker TS code.","permissions":[],"systemPrompt":"# PinMe Worker Email API Integration\n\nGuides how to call PinMe platform's email sending API in a PinMe Worker (TypeScript).\n\n## Environment Variables\n\nThe following environment variables are automatically injected when the Worker is created — no manual configuration needed:\n\n```typescript\n// backend/src/worker.ts\nexport interface Env {\n  DB: D1Database;\n  API_KEY: string;      // Project API Key — used for send_email authentication\n  BASE_URL?: string;    // Optional override for PinMe API base URL, defaults to https://pinme.cloud\n}\n```\n\n> `API_KEY` is the sole credential for the Worker to call PinMe platform APIs. When `BASE_URL` is not set, it defaults to `https://pinme.cloud`.\n\n---\n\n## Send Email API\n\n**Endpoint:** `POST {BASE_URL}/api/v4/send_email`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Sender:** Automatically set to `{project_name}@pinme.cloud`\n\n### Request Format\n\n```json\n{\n  \"to\": \"user@example.com\",\n  \"subject\": \"Your verification code\",\n  \"html\": \"<p>Your code is <strong>123456</strong></p>\"\n}\n```\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `to` | string | Yes | Recipient email address |\n| `subject` | string | Yes | Email subject |\n| `html` | string | Yes | HTML body |\n\n### Response Format\n\n**Success (200):**\n```json\n{ \"code\": 200, \"msg\": \"ok\", \"data\": { \"ok\": true } }\n```\n\n**Errors:**\n\n| HTTP Status | Meaning | data.error Example |\n|-------------|---------|-------------------|\n| 401 | API Key missing or invalid | `\"X-API-Key header is required\"` / `\"Invalid API key\"` |\n| 400 | Parameter validation failed | `\"Invalid email address\"` / `\"Subject is required\"` |\n| 500 | Email service error | `\"Failed to send email\"` |\n\n### Worker Example Code\n\n```typescript\nasync function sendEmail(env: Env, to: string, subject: string, html: string): Promise<{ ok: boolean; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(`${baseUrl}/api/v4/send_email`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': env.API_KEY,\n    },\n    body: JSON.stringify({ to, subject, html }),\n  });\n\n  const result = await resp.json() as { code: number; msg: string; data?: { ok?: boolean; error?: string } };\n\n  if (resp.status !== 200 || result.code !== 200) {\n    return { ok: false, error: result.data?.error || result.msg || 'Unknown error' };\n  }\n  return { ok: true };\n}\n\n// Usage in routes\nasync function handleSendVerification(request: Request, env: Env): Promise<Response> {\n  const { email } = await request.json() as { email: string };\n  const code = Math.random().toString().slice(2, 8);\n\n  const result = await sendEmail(env, email, 'Verification Code',\n    `<p>Your code is <strong>${code}</strong></p>`);\n\n  if (!result.ok) {\n    return json({ error: result.error }, 500);\n  }\n  return json({ ok: true });\n}\n```\n\n---\n\n## Error Handling Pattern\n\nPinMe platform API unified response format:\n\n```typescript\ninterface PinmeResponse<T = unknown> {\n  code: number;   // 200=success, other=failure\n  msg: string;    // \"ok\" | \"error\" | \"invalid params\"\n  data?: T;       // Business data on success, may contain { error: string } on failure\n}\n```\n\n### Recommended Unified Error Handler\n\n```typescript\nasync function callPinmeAPI<T>(url: string, apiKey: string, body: unknown): Promise<{ data?: T; error?: string }> {\n  let resp: Response;\n  try {\n    resp = await fetch(url, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },\n      body: JSON.stringify(body),\n    });\n  } catch {\n    return { error: 'Network error' };\n  }\n\n  if (!resp.ok) {\n    try {\n      const err = await resp.json() as PinmeResponse;\n      return { error: err.data && typeof err.data === 'object' && 'error' in err.data\n        ? (err.data as { error: string }).error\n        : err.msg || `HTTP ${resp.status}` };\n    } catch {\n      return { error: `HTTP ${resp.status}` };\n    }\n  }\n\n  const result = await resp.json() as PinmeResponse<T>;\n  if (result.code !== 200) {\n    return { error: result.data && typeof result.data === 'object' && 'error' in result.data\n      ? (result.data as { error: string }).error\n      : result.msg };\n  }\n  return { data: result.data as T };\n}\n```\n\n### Usage Example\n\n```typescript\nconst baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n\n// Send email\nconst emailResult = await callPinmeAPI<{ ok: boolean }>(\n  `${baseUrl}/api/v4/send_email`, env.API_KEY,\n  { to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' },\n);\nif (emailResult.error) return json({ error: emailResult.error }, 500);\n```","schemaVersion":1},"repoUrl":"https://github.com/glitternetwork/pinme/tree/main/skills/pinme-email","tags":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy"],"stats":{"installVelocity7d":0,"retentionRate":0,"executions":0,"rating":null},"origin":"github","source":{"repo":"pinme","audit":{"files":["package-lock.json","package.json","pnpm-lock.yaml"],"binaries":[],"findings":[{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Uncontrolled memory allocation via the declared uncompressed size (DoS).","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-7q85-xj36-vmfc · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip extraction follows destination symlinks, allowing arbitrary file overwrite.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-vwc7-r8mq-g2x9 · npm:adm-zip@0.5.17","severity":"medium"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Crafted ZIP file triggers 4GB memory allocation.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-xcpc-8h2w-3j85 · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"uuid@9.0.1 has a known vulnerability: uuid: Missing buffer bounds check in v3/v5/v6 when buf is provided.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-w5hq-g745-h8pq · npm:uuid@9.0.1","severity":"medium"}],"packages":16,"auditedAt":"2026-09-25T11:51:56.672Z","lockfiles":["package-lock.json","pnpm-lock.yaml"]},"forks":277,"owner":"glitternetwork","stars":3745,"topics":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy","static-site-hosting","web-hosting","zero-configuration"],"license":"MIT","fullName":"glitternetwork/pinme","homepage":"https://pinme.eth.limo","language":"TypeScript","pushedAt":"2026-09-12T05:23:49Z","avatarUrl":"https://avatars.githubusercontent.com/u/102277171?v=4","crawledAt":"2026-09-25T11:51:44.477Z","openIssues":7,"manifestFile":"SKILL.md","manifestPath":"skills/pinme-email/SKILL.md","defaultBranch":"main"},"readme":"# PinMe Worker Email API Integration\n\nGuides how to call PinMe platform's email sending API in a PinMe Worker (TypeScript).\n\n## Environment Variables\n\nThe following environment variables are automatically injected when the Worker is created — no manual configuration needed:\n\n```typescript\n// backend/src/worker.ts\nexport interface Env {\n  DB: D1Database;\n  API_KEY: string;      // Project API Key — used for send_email authentication\n  BASE_URL?: string;    // Optional override for PinMe API base URL, defaults to https://pinme.cloud\n}\n```\n\n> `API_KEY` is the sole credential for the Worker to call PinMe platform APIs. When `BASE_URL` is not set, it defaults to `https://pinme.cloud`.\n\n---\n\n## Send Email API\n\n**Endpoint:** `POST {BASE_URL}/api/v4/send_email`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Sender:** Automatically set to `{project_name}@pinme.cloud`\n\n### Request Format\n\n```json\n{\n  \"to\": \"user@example.com\",\n  \"subject\": \"Your verification code\",\n  \"html\": \"<p>Your code is <strong>123456</strong></p>\"\n}\n```\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `to` | string | Yes | Recipient email address |\n| `subject` | string | Yes | Email subject |\n| `html` | string | Yes | HTML body |\n\n### Response Format\n\n**Success (200):**\n```json\n{ \"code\": 200, \"msg\": \"ok\", \"data\": { \"ok\": true } }\n```\n\n**Errors:**\n\n| HTTP Status | Meaning | data.error Example |\n|-------------|---------|-------------------|\n| 401 | API Key missing or invalid | `\"X-API-Key header is required\"` / `\"Invalid API key\"` |\n| 400 | Parameter validation failed | `\"Invalid email address\"` / `\"Subject is required\"` |\n| 500 | Email service error | `\"Failed to send email\"` |\n\n### Worker Example Code\n\n```typescript\nasync function sendEmail(env: Env, to: string, subject: string, html: string): Promise<{ ok: boolean; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(`${baseUrl}/api/v4/send_email`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': env.API_KEY,\n    },\n    body: JSON.stringify({ to, subject, html }),\n  });\n\n  const result = await resp.json() as { code: number; msg: string; data?: { ok?: boolean; error?: string } };\n\n  if (resp.status !== 200 || result.code !== 200) {\n    return { ok: false, error: result.data?.error || result.msg || 'Unknown error' };\n  }\n  return { ok: true };\n}\n\n// Usage in routes\nasync function handleSendVerification(request: Request, env: Env): Promise<Response> {\n  const { email } = await request.json() as { email: string };\n  const code = Math.random().toString().slice(2, 8);\n\n  const result = await sendEmail(env, email, 'Verification Code',\n    `<p>Your code is <strong>${code}</strong></p>`);\n\n  if (!result.ok) {\n    return json({ error: result.error }, 500);\n  }\n  return json({ ok: true });\n}\n```\n\n---\n\n## Error Handling Pattern\n\nPinMe platform API unified response format:\n\n```typescript\ninterface PinmeResponse<T = unknown> {\n  code: number;   // 200=success, other=failure\n  msg: string;    // \"ok\" | \"error\" | \"invalid params\"\n  data?: T;       // Business data on success, may contain { error: string } on failure\n}\n```\n\n### Recommended Unified Error Handler\n\n```typescript\nasync function callPinmeAPI<T>(url: string, apiKey: string, body: unknown): Promise<{ data?: T; error?: string }> {\n  let resp: Response;\n  try {\n    resp = await fetch(url, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },\n      body: JSON.stringify(body),\n    });\n  } catch {\n    return { error: 'Network error' };\n  }\n\n  if (!resp.ok) {\n    try {\n      const err = await resp.json() as PinmeResponse;\n      return { error: err.data && typeof err.data === 'object' && 'error' in err.data\n        ? (err.data as { error: string }).error\n        : err.msg || `HTTP ${resp.status}` };\n    } catch {\n      return { error: `HTTP ${resp.status}` };\n    }\n  }\n\n  const res","createdAt":"2026-09-25T11:51:56.708Z","updatedAt":"2026-09-25T11:51:56.708Z"},{"id":"cmugwhsfk01gtqu06qllnm31q","slug":"glitternetwork-pinme-pinme-llm","name":"pinme-llm","description":"Use this skill when a PinMe project (Worker TypeScript) needs to call OpenRouter-backed LLM APIs, including models, chat/completions, streaming, or OpenRouter web search. Guides AI to generate correct Worker TS code.","authorId":"gh:glitternetwork","authorName":"glitternetwork","version":"0.1.0","category":"Prompt","securityLevel":"Sandbox","downloadsCount":0,"githubStars":3745,"pricePerCall":0,"manifest":{"name":"pinme-llm","tools":[],"category":"Prompt","entrypoint":{"type":"prompt"},"description":"Use this skill when a PinMe project (Worker TypeScript) needs to call OpenRouter-backed LLM APIs, including models, chat/completions, streaming, or OpenRouter web search. Guides AI to generate correct Worker TS code.","permissions":[],"systemPrompt":"# PinMe Worker OpenRouter API Integration\n\nGuides how to call PinMe platform's OpenRouter proxy APIs in a PinMe Worker (TypeScript). Workers use the PinMe project API key; they never hold the real OpenRouter API key.\n\n## Environment Variables\n\nThe following environment variables are automatically injected when the Worker is created — no manual configuration needed:\n\n```typescript\n// backend/src/worker.ts\nexport interface Env {\n  DB: D1Database;\n  API_KEY: string;       // Project API Key from create_worker\n  PROJECT_NAME: string;  // Actual project_name from create_worker; must match API_KEY\n  BASE_URL?: string;     // Optional override for PinMe API base URL, defaults to https://pinme.cloud\n}\n```\n\n> `API_KEY` authenticates the Worker to PinMe. `PROJECT_NAME` is required for `chat/completions` and must belong to the same project as `API_KEY`. When `BASE_URL` is not set, use `https://pinme.cloud`.\n\n---\n\n## Models API\n\n**Endpoint:** `GET {BASE_URL}/api/v1/models`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Request Body:** none\n\nUse this when the Worker needs to list available OpenRouter models. The response body, status, and headers are passed through from OpenRouter `/models`.\n\n```typescript\nasync function listModels(env: Env): Promise<unknown> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(`${baseUrl}/api/v1/models`, {\n    headers: { 'X-API-Key': env.API_KEY },\n  });\n\n  if (!resp.ok) {\n    throw new Error(await extractPinmeOpenRouterError(resp));\n  }\n\n  return await resp.json();\n}\n```\n\n---\n\n## Chat Completions API\n\n**Endpoint:** `POST {BASE_URL}/api/v1/chat/completions?project_name={project_name}`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Request Body:** OpenRouter chat/completions format, passed through as-is after a 1MB size check\n**Streaming:** Supports SSE (`stream: true`)\n**Web Search:** Supports OpenRouter `openrouter:web_search` server tool via the `tools` array\n\n### Request Format\n\n```json\n{\n  \"model\": \"openai/gpt-4o-mini\",\n  \"messages\": [\n    { \"role\": \"system\", \"content\": \"You are a helpful assistant.\" },\n    { \"role\": \"user\", \"content\": \"Hello!\" }\n  ],\n  \"stream\": true\n}\n```\n\n> Use `env.PROJECT_NAME` from `create_worker`; always URL-encode it in the query string. For available models, call `GET /api/v1/models` or refer to OpenRouter model IDs.\n\n### OpenRouter Web Search\n\nPinMe does not provide a raw search endpoint. To search the web, pass OpenRouter's `openrouter:web_search` server tool to `chat/completions`; the model decides whether and when to search.\n\nAlways set `max_results` and `max_total_results` to keep search volume and cost bounded.\n\n```typescript\nasync function searchWithLLM(env: Env, query: string): Promise<string> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'X-API-Key': env.API_KEY,\n      },\n      body: JSON.stringify({\n        model: 'openai/gpt-5.2',\n        messages: [{ role: 'user', content: query }],\n        tools: [\n          {\n            type: 'openrouter:web_search',\n            parameters: {\n              engine: 'auto',\n              max_results: 5,\n              max_total_results: 10,\n            },\n          },\n        ],\n      }),\n    },\n  );\n\n  if (!resp.ok) {\n    throw new Error(await extractPinmeOpenRouterError(resp));\n  }\n\n  const data = await resp.json() as { choices: Array<{ message?: { content?: string } }> };\n  return data.choices[0]?.message?.content ?? '';\n}\n```\n\n### Response Format\n\nSuccessful requests return OpenRouter's raw response body.\n\n**Non-streaming Success (200):**\n```json\n{\n  \"id\": \"chatcmpl-...\",\n  \"choices\": [{ \"message\": { \"role\": \"assistant\", \"content\": \"Hello!\" }, \"finish_reason\": \"stop\" }],\n  \"usage\": { \"prompt_tokens\": 10, \"completion_tokens\": 5, \"total_tokens\": 15 }\n}\n```\n\n**Streaming Success (200):** SSE format\n```\ndata: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\ndata: {\"choices\":[{\"delta\":{\"content\":\" there\"}}]}\ndata: [DONE]\n```\n\n**Errors:**\n\n| HTTP Status | Meaning | data.error Example |\n|-------------|---------|-------------------|\n| 401 | API Key missing, invalid, or mismatched with project_name | `\"X-API-Key header is required\"` / `\"Invalid API key\"` / `\"Invalid API key or project name\"` |\n| 400 | project_name missing or OpenRouter key not configured | `\"project_name is required\"` / `\"LLM service not configured for this project\"` |\n| 403 | LLM balance insufficient or disabled | `\"Insufficient balance, please recharge to continue using LLM service\"` |\n| 413 | Request body exceeds 1MB | `\"Request body too large (max 1MB)\"` |\n| 500 | Proxy failed before upstream request | `\"Failed to build request\"` |\n| 502 | LLM service unavailable | `\"LLM service unavailable\"` |\n\nIf OpenRouter receives the request and returns a 4xx/5xx, PinMe passes through OpenRouter's status, headers, and response body instead of wrapping it.\n\n### Worker Example Code — Non-streaming\n\n```typescript\nasync function callLLM(\n  env: Env,\n  messages: Array<{ role: string; content: string }>,\n  model = 'openai/gpt-4o-mini',\n): Promise<{ content: string; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'X-API-Key': env.API_KEY,\n      },\n      body: JSON.stringify({ model, messages }),\n    },\n  );\n\n  if (!resp.ok) {\n    return { content: '', error: await extractPinmeOpenRouterError(resp) };\n  }\n\n  const data = await resp.json() as { choices: Array<{ message: { content: string } }> };\n  return { content: data.choices[0]?.message?.content || '' };\n}\n\n// Usage in routes\nasync function handleChat(request: Request, env: Env): Promise<Response> {\n  const { question } = await request.json() as { question: string };\n\n  const result = await callLLM(env, [\n    { role: 'system', content: 'You are a helpful assistant.' },\n    { role: 'user', content: question },\n  ]);\n\n  if (result.error) {\n    return json({ error: result.error }, 502);\n  }\n  return json({ answer: result.content });\n}\n```\n\n### Worker Example Code — Streaming (SSE Passthrough)\n\n```typescript\nasync function handleChatStream(request: Request, env: Env): Promise<Response> {\n  const body = await request.text();\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n\n  // Ensure stream=true in the request\n  let parsed = JSON.parse(body);\n  parsed.stream = true;\n\n  const resp = await fetch(\n    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'X-API-Key': env.API_KEY,\n      },\n      body: JSON.stringify(parsed),\n    },\n  );\n\n  if (!resp.ok) {\n    return json({ error: await extractPinmeOpenRouterError(resp) }, resp.status);\n  }\n\n  // Pass through SSE stream directly\n  return new Response(resp.body, {\n    status: 200,\n    headers: {\n      'Content-Type': 'text/event-stream',\n      'Cache-Control': 'no-cache',\n      'Connection': 'keep-alive',\n      ...CORS_HEADERS,\n    },\n  });\n}\n```\n\n### Frontend SSE Stream Consumer Example\n\n```typescript\nasync function streamChat(question: string, onChunk: (text: string) => void): Promise<void> {\n  const resp = await fetch(getApiUrl('/api/chat/stream'), {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ question }),\n  });\n\n  const reader = resp.body!.getReader();\n  const decoder = new TextDecoder();\n  let buffer = '';\n\n  while (true) {\n    const { done, value } = await reader.read();\n    if (done) break;\n\n    buffer += decoder.decode(value, { stream: true });\n    const lines = buffer.split('\\n');\n    buffer = lines.pop()!; // Keep incomplete line\n\n    for (const line of lines) {\n      if (!line.startsWith('data: ')) continue;\n      const payload = line.slice(6);\n      if (payload === '[DONE]') return;\n\n      const chunk = JSON.parse(payload) as { choices: Array<{ delta: { content?: string } }> };\n      const content = chunk.choices[0]?.delta?.content;\n      if (content) onChunk(content);\n    }\n  }\n}\n```\n\n---\n\n## Error Handling Pattern\n\nFor `/api/v1/models` and `/api/v1/chat/completions`, successful responses are raw OpenRouter responses. Proxy failures before the OpenRouter request use PinMe's wrapped error format:\n\n```typescript\ninterface PinmeResponse<T = unknown> {\n  code: number;   // 200=success, other=failure\n  msg: string;    // \"ok\" | \"error\" | \"invalid params\"\n  data?: T;       // Business data on success, may contain { error: string } on failure\n}\n```\n\n### Recommended Error Extractor\n\n```typescript\nasync function extractPinmeOpenRouterError(resp: Response): Promise<string> {\n  const fallback = `HTTP ${resp.status}`;\n  try {\n    const body = await resp.clone().json() as PinmeResponse | { error?: { message?: string } } | { error?: string };\n    if ('data' in body && body.data && typeof body.data === 'object' && 'error' in body.data) {\n      return String((body.data as { error: unknown }).error);\n    }\n    if ('msg' in body && typeof body.msg === 'string' && body.msg) {\n      return body.msg;\n    }\n    if ('error' in body) {\n      const error = body.error;\n      if (typeof error === 'string') return error;\n      if (error && typeof error === 'object' && 'message' in error) {\n        return String((error as { message: unknown }).message);\n      }\n    }\n  } catch {\n    try {\n      const text = await resp.text();\n      if (text) return text;\n    } catch {\n      // Ignore and return fallback below.\n    }\n  }\n  return fallback;\n}\n```\n\n### Optional JSON Helper\n\nUse this helper for non-streaming `POST` calls. It returns the raw OpenRouter JSON on success.\n\n```typescript\nasync function callOpenRouterJSON<T>(url: string, apiKey: string, body: unknown): Promise<{ data?: T; error?: string }> {\n  let resp: Response;\n  try {\n    resp = await fetch(url, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },\n      body: JSON.stringify(body),\n    });\n  } catch {\n    return { error: 'Network error' };\n  }\n\n  if (!resp.ok) {\n    return { error: await extractPinmeOpenRouterError(resp) };\n  }\n\n  return { data: await resp.json() as T };\n}\n```\n\n### Usage Example\n\n```typescript\nconst baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n\n// Call LLM (non-streaming)\nconst llmResult = await callOpenRouterJSON<{ choices: Array<{ message: { content: string } }> }>(\n  `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`, env.API_KEY,\n  { model: 'openai/gpt-4o-mini', messages: [{ role: 'user', content: 'Hi' }] },\n);\nif (llmResult.error) return json({ error: llmResult.error }, 502);\n```","schemaVersion":1},"repoUrl":"https://github.com/glitternetwork/pinme/tree/main/skills/pinme-llm","tags":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy"],"stats":{"installVelocity7d":0,"retentionRate":0,"executions":0,"rating":null},"origin":"github","source":{"repo":"pinme","audit":{"files":["package-lock.json","package.json","pnpm-lock.yaml"],"binaries":[],"findings":[{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Uncontrolled memory allocation via the declared uncompressed size (DoS).","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-7q85-xj36-vmfc · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip extraction follows destination symlinks, allowing arbitrary file overwrite.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-vwc7-r8mq-g2x9 · npm:adm-zip@0.5.17","severity":"medium"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Crafted ZIP file triggers 4GB memory allocation.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-xcpc-8h2w-3j85 · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"uuid@9.0.1 has a known vulnerability: uuid: Missing buffer bounds check in v3/v5/v6 when buf is provided.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-w5hq-g745-h8pq · npm:uuid@9.0.1","severity":"medium"}],"packages":16,"auditedAt":"2026-09-25T11:51:56.672Z","lockfiles":["package-lock.json","pnpm-lock.yaml"]},"forks":277,"owner":"glitternetwork","stars":3745,"topics":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy","static-site-hosting","web-hosting","zero-configuration"],"license":"MIT","fullName":"glitternetwork/pinme","homepage":"https://pinme.eth.limo","language":"TypeScript","pushedAt":"2026-09-12T05:23:49Z","avatarUrl":"https://avatars.githubusercontent.com/u/102277171?v=4","crawledAt":"2026-09-25T11:51:44.477Z","openIssues":7,"manifestFile":"SKILL.md","manifestPath":"skills/pinme-llm/SKILL.md","defaultBranch":"main"},"readme":"# PinMe Worker OpenRouter API Integration\n\nGuides how to call PinMe platform's OpenRouter proxy APIs in a PinMe Worker (TypeScript). Workers use the PinMe project API key; they never hold the real OpenRouter API key.\n\n## Environment Variables\n\nThe following environment variables are automatically injected when the Worker is created — no manual configuration needed:\n\n```typescript\n// backend/src/worker.ts\nexport interface Env {\n  DB: D1Database;\n  API_KEY: string;       // Project API Key from create_worker\n  PROJECT_NAME: string;  // Actual project_name from create_worker; must match API_KEY\n  BASE_URL?: string;     // Optional override for PinMe API base URL, defaults to https://pinme.cloud\n}\n```\n\n> `API_KEY` authenticates the Worker to PinMe. `PROJECT_NAME` is required for `chat/completions` and must belong to the same project as `API_KEY`. When `BASE_URL` is not set, use `https://pinme.cloud`.\n\n---\n\n## Models API\n\n**Endpoint:** `GET {BASE_URL}/api/v1/models`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Request Body:** none\n\nUse this when the Worker needs to list available OpenRouter models. The response body, status, and headers are passed through from OpenRouter `/models`.\n\n```typescript\nasync function listModels(env: Env): Promise<unknown> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(`${baseUrl}/api/v1/models`, {\n    headers: { 'X-API-Key': env.API_KEY },\n  });\n\n  if (!resp.ok) {\n    throw new Error(await extractPinmeOpenRouterError(resp));\n  }\n\n  return await resp.json();\n}\n```\n\n---\n\n## Chat Completions API\n\n**Endpoint:** `POST {BASE_URL}/api/v1/chat/completions?project_name={project_name}`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Request Body:** OpenRouter chat/completions format, passed through as-is after a 1MB size check\n**Streaming:** Supports SSE (`stream: true`)\n**Web Search:** Supports OpenRouter `openrouter:web_search` server tool via the `tools` array\n\n### Request Format\n\n```json\n{\n  \"model\": \"openai/gpt-4o-mini\",\n  \"messages\": [\n    { \"role\": \"system\", \"content\": \"You are a helpful assistant.\" },\n    { \"role\": \"user\", \"content\": \"Hello!\" }\n  ],\n  \"stream\": true\n}\n```\n\n> Use `env.PROJECT_NAME` from `create_worker`; always URL-encode it in the query string. For available models, call `GET /api/v1/models` or refer to OpenRouter model IDs.\n\n### OpenRouter Web Search\n\nPinMe does not provide a raw search endpoint. To search the web, pass OpenRouter's `openrouter:web_search` server tool to `chat/completions`; the model decides whether and when to search.\n\nAlways set `max_results` and `max_total_results` to keep search volume and cost bounded.\n\n```typescript\nasync function searchWithLLM(env: Env, query: string): Promise<string> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'X-API-Key': env.API_KEY,\n      },\n      body: JSON.stringify({\n        model: 'openai/gpt-5.2',\n        messages: [{ role: 'user', content: query }],\n        tools: [\n          {\n            type: 'openrouter:web_search',\n            parameters: {\n              engine: 'auto',\n              max_results: 5,\n              max_total_results: 10,\n            },\n          },\n        ],\n      }),\n    },\n  );\n\n  if (!resp.ok) {\n    throw new Error(await extractPinmeOpenRouterError(resp));\n  }\n\n  const data = await resp.json() as { choices: Array<{ message?: { content?: string } }> };\n  return data.choices[0]?.message?.content ?? '';\n}\n```\n\n### Response Format\n\nSuccessful requests return OpenRouter's raw response body.\n\n**Non-streaming Success (200):**\n```json\n{\n  \"id\": \"chatcmpl-...\",\n  \"choices\": [{ \"message\": { \"role\": \"assistant\", \"content\": \"Hello!\" }, \"finish_reason\": \"stop\" }],\n  \"usage\": { \"prompt_tokens\": 10, \"completion_tokens\": 5, \"total","createdAt":"2026-09-25T11:51:56.720Z","updatedAt":"2026-09-25T11:51:56.720Z"},{"id":"cmugwhsfv01gwqu06caq63e28","slug":"glitternetwork-pinme-pinme-r2","name":"pinme-r2","description":"Use when a PinMe Cloudflare Worker needs R2 object storage, including secure file or image upload, streaming download, metadata lookup, deletion, listing, Range requests, or R2+D1 coordination. Guides AI to use PinMe's automatically injected env.R2 binding without R2 credentials or manual Wrangler configuration.","authorId":"gh:glitternetwork","authorName":"glitternetwork","version":"0.1.0","category":"Prompt","securityLevel":"Sandbox","downloadsCount":0,"githubStars":3745,"pricePerCall":0,"manifest":{"name":"pinme-r2","tools":[],"category":"Prompt","entrypoint":{"type":"prompt"},"description":"Use when a PinMe Cloudflare Worker needs R2 object storage, including secure file or image upload, streaming download, metadata lookup, deletion, listing, Range requests, or R2+D1 coordination. Guides AI to use PinMe's automatically injected env.R2 binding without R2 credentials or manual Wrangler configuration.","permissions":[],"systemPrompt":"# PinMe Worker R2 Storage\n\nUse the project-scoped R2 bucket that PinMe binds to every deployed Worker as `env.R2`. Do not create credentials, choose a bucket name, or edit generated Wrangler configuration.\n\n## Runtime Contract\n\nPinMe rebuilds trusted Worker metadata on create, save, and update. Client metadata cannot replace the R2 binding.\n\n| Binding | TypeScript type | Availability |\n| --- | --- | --- |\n| `DB` | `D1Database` | Always injected |\n| `R2` | `R2Bucket` | Always injected; current project's bucket |\n| `API_KEY` | `string` | Always injected |\n| `LLM_API_KEY` | `string` | Always injected |\n| `BASE_URL` | `string` | Always injected |\n| `WORKER_URL` | `string` | Always injected |\n| `PROJECT_NAME` | `string` | Always injected |\n\nPayment-specific bindings such as `UNIWEB_SECRET` are conditional and unrelated to R2 access.\n\nDeclare only the bindings used by the Worker module. R2 code normally starts with:\n\n```typescript\nexport interface Env {\n  R2: R2Bucket;\n  PROJECT_NAME: string;\n  WORKER_URL: string;\n}\n```\n\nWhen the same module coordinates file metadata in D1, also declare `DB: D1Database` as a required field.\n\n## Choose R2 or D1\n\n- Use R2 for file bodies, images, attachments, media, exports, and other objects addressed by key.\n- Use D1 for searchable business metadata, ownership, relations, status, and audit fields.\n- For managed files, store the body in R2 and store only its key and business metadata in D1.\n- Never use Worker local filesystem state for persistence and never store complete files or base64 payloads in D1.\n\n## Required Security Workflow\n\nApply this sequence to every upload, download, metadata, delete, and list route:\n\n```text\nauthenticate request\n→ authorize the project/user action\n→ validate size and media policy\n→ generate or normalize a scoped object key\n→ call env.R2\n→ return a sanitized response\n```\n\nUse the application's existing authentication. The examples below accept a trusted `userId` that the route must obtain from verified identity claims, never from an untrusted request body or query parameter.\n\nKeep object keys server-controlled. Prefer opaque IDs under an owner prefix:\n\n```typescript\nconst FILE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\n\nfunction ownerPrefix(userId: string): string {\n  if (!userId) throw new Error('Authenticated user id is required');\n  return `users/${encodeURIComponent(userId)}/files/`;\n}\n\nfunction objectKey(userId: string, fileId: string): string {\n  if (!FILE_ID_RE.test(fileId)) throw new Error('Invalid file id');\n  return `${ownerPrefix(userId)}${fileId}`;\n}\n```\n\nNever accept a complete object key from the client. Reject empty identifiers, `.` or `..` segments, backslashes, control characters, and any attempt to access another user's prefix.\n\n## Shared Helpers\n\nUse small helpers and explicit business limits. Adapt the allowlist to the product rather than accepting every client-supplied media type.\n\n```typescript\nconst MAX_UPLOAD_BYTES = 25 * 1024 * 1024;\nconst ALLOWED_CONTENT_TYPES = new Set([\n  'image/jpeg',\n  'image/png',\n  'image/webp',\n  'application/pdf',\n]);\n\nfunction json(data: unknown, status = 200): Response {\n  return Response.json(data, { status });\n}\n\nfunction safeDownloadName(value: string | null): string {\n  const cleaned = (value || 'download')\n    .replace(/[\\r\\n\"\\\\]/g, '_')\n    .replace(/[\\x00-\\x1f\\x7f]/g, '')\n    .trim();\n  return (cleaned || 'download').slice(0, 128);\n}\n\nfunction requestedFileId(request: Request): string | null {\n  const url = new URL(request.url);\n  const value = url.pathname.split('/').filter(Boolean).at(-1) || '';\n  return FILE_ID_RE.test(value) ? value : null;\n}\n```\n\nClient filenames and `Content-Type` are hints, not proof of content. For sensitive formats, inspect magic bytes or send the object through an asynchronous validation/scanning workflow before marking it ready.\n\n## Stream an Upload\n\nRequire authentication before calling this handler. Pass `request.body` directly to R2; do not call `arrayBuffer()`, `text()`, `json()`, `formData()`, or base64 conversion first.\n\n```typescript\nasync function handleUpload(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  if (!request.body) return json({ error: 'File body is required' }, 400);\n\n  const lengthHeader = request.headers.get('content-length');\n  if (!lengthHeader) return json({ error: 'Content-Length is required' }, 411);\n\n  const declaredSize = Number(lengthHeader);\n  if (!Number.isSafeInteger(declaredSize) || declaredSize < 0) {\n    return json({ error: 'Invalid Content-Length' }, 400);\n  }\n  if (declaredSize > MAX_UPLOAD_BYTES) {\n    return json({ error: 'File is too large' }, 413);\n  }\n\n  const contentType = (request.headers.get('content-type') || '')\n    .split(';', 1)[0]\n    .trim()\n    .toLowerCase();\n  if (!ALLOWED_CONTENT_TYPES.has(contentType)) {\n    return json({ error: 'Unsupported media type' }, 400);\n  }\n\n  const fileId = crypto.randomUUID();\n  const key = objectKey(userId, fileId);\n  const filename = safeDownloadName(request.headers.get('x-file-name'));\n\n  const object = await env.R2.put(key, request.body, {\n    httpMetadata: {\n      contentType,\n      contentDisposition: `attachment; filename=\"${filename}\"`,\n    },\n    customMetadata: { ownerId: userId },\n  });\n\n  if (object === null) return json({ error: 'Upload precondition failed' }, 412);\n\n  // Content-Length is only a precheck. Enforce the actual stored size too.\n  if (object.size > MAX_UPLOAD_BYTES) {\n    await env.R2.delete(key);\n    return json({ error: 'File is too large' }, 413);\n  }\n\n  return json({ id: fileId, size: object.size, etag: object.httpEtag }, 201);\n}\n```\n\nDo not return the bucket name or internal object-key layout. Return an opaque file ID that later routes resolve under the authenticated owner's prefix.\n\n## Stream a Download\n\nValidate a single Range header before passing it to R2. R2 may return `null` when the object does not exist, or metadata without a body when a conditional request fails.\n\n```typescript\nfunction validRangeHeader(value: string | null): boolean {\n  if (!value) return true;\n  const match = /^bytes=(\\d*)-(\\d*)$/.exec(value);\n  return Boolean(match && (match[1] || match[2]));\n}\n\nasync function handleDownload(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  const fileId = requestedFileId(request);\n  if (!fileId) return json({ error: 'Invalid file id' }, 400);\n  if (!validRangeHeader(request.headers.get('range'))) {\n    return json({ error: 'Invalid Range header' }, 416);\n  }\n\n  const object = await env.R2.get(objectKey(userId, fileId), {\n    onlyIf: request.headers,\n    range: request.headers,\n  });\n  if (object === null) return json({ error: 'Not found' }, 404);\n  if (!('body' in object)) return new Response(null, { status: 412 });\n\n  const headers = new Headers();\n  object.writeHttpMetadata(headers);\n  headers.set('etag', object.httpEtag);\n  headers.set('accept-ranges', 'bytes');\n  if (object.range) {\n    const { offset, length } = object.range;\n    headers.set(\n      'content-range',\n      `bytes ${offset}-${offset + length - 1}/${object.size}`,\n    );\n    headers.set('content-length', String(length));\n  } else {\n    headers.set('content-length', String(object.size));\n  }\n\n  return new Response(object.body, {\n    status: object.range ? 206 : 200,\n    headers,\n  });\n}\n```\n\nFor routes backed by D1 metadata, authorize the D1 row's owner before calling `env.R2.get`. Do not infer ownership only from a client-provided path.\n\n## Read Metadata with HEAD\n\n```typescript\nasync function handleHead(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  const fileId = requestedFileId(request);\n  if (!fileId) return json({ error: 'Invalid file id' }, 400);\n\n  const object = await env.R2.head(objectKey(userId, fileId));\n  if (object === null) return new Response(null, { status: 404 });\n\n  const headers = new Headers();\n  object.writeHttpMetadata(headers);\n  headers.set('etag', object.httpEtag);\n  headers.set('content-length', String(object.size));\n  return new Response(null, { status: 200, headers });\n}\n```\n\nUse `head()` when only size, ETag, upload time, or metadata is needed. Do not download the body to answer metadata requests.\n\n## Delete an Object\n\n```typescript\nasync function handleDelete(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  const fileId = requestedFileId(request);\n  if (!fileId) return json({ error: 'Invalid file id' }, 400);\n\n  const key = objectKey(userId, fileId);\n  const object = await env.R2.head(key);\n  if (object === null) return json({ error: 'Not found' }, 404);\n\n  await env.R2.delete(key);\n  return new Response(null, { status: 204 });\n}\n```\n\nR2 can delete up to 1000 keys in one `delete([...keys])` call. Batch deletion must still derive and authorize every key server-side.\n\n## List an Owner's Objects\n\nNever list the whole bucket for an end-user request. Derive the prefix from verified identity and treat the cursor as opaque.\n\n```typescript\nasync function handleList(\n  request: Request,\n  env: Env,\n  userId: string,\n): Promise<Response> {\n  const cursor = new URL(request.url).searchParams.get('cursor');\n  if (cursor && cursor.length > 2048) {\n    return json({ error: 'Invalid cursor' }, 400);\n  }\n\n  const page = await env.R2.list({\n    prefix: ownerPrefix(userId),\n    cursor: cursor || undefined,\n    limit: 100,\n    include: ['httpMetadata', 'customMetadata'],\n  });\n\n  return json({\n    objects: page.objects.map((object) => ({\n      id: object.key.slice(ownerPrefix(userId).length),\n      size: object.size,\n      uploaded: object.uploaded.toISOString(),\n      etag: object.httpEtag,\n      contentType: object.httpMetadata?.contentType,\n    })),\n    nextCursor: page.truncated ? page.cursor : null,\n  });\n}\n```\n\nAn R2 list call returns at most 1000 entries and may return fewer than the requested limit when metadata is included. Continue only when `page.truncated` is true; never use `objects.length === limit` as the pagination condition.\n\n## Route and Error Semantics\n\nAuthenticate once in the router, derive a trusted `userId`, then pass it to the handlers. Return an `Allow` header for unsupported methods.\n\n| Status | Meaning |\n| --- | --- |\n| 400 | Invalid file ID, body, cursor, or media type |\n| 401 | Missing or invalid authentication |\n| 403 | Authenticated but not allowed to access the object |\n| 404 | Object or owned metadata record not found |\n| 411 | A capped upload route requires `Content-Length` but it is absent |\n| 412 | Conditional R2 operation failed |\n| 413 | Business or platform upload limit exceeded |\n| 416 | Invalid or unsatisfiable Range request |\n| 500 | Sanitized internal storage failure |\n\nCatch storage failures at the route boundary, log only non-sensitive context, and return a generic error. Never return a raw provider error, bucket name, credential, or internal object key.\nTranslate a valid-but-unsatisfiable R2 Range failure to `416` without returning the provider error text.\n\n## Coordinate R2 with D1\n\nR2 and D1 do not share a transaction. Use an explicit state transition when business metadata is required:\n\n```text\ninsert D1 row with status=pending\n→ stream body to R2\n→ update D1 row to status=ready\n```\n\n- If upload fails, delete the pending row or mark it failed.\n- If the final D1 update fails, delete the newly uploaded object or retain a durable pending state for a compensation job.\n- Store at least: public file ID, internal object key, owner ID, original name, size, MIME, status, and timestamps.\n- For download and delete, load the row by public file ID and owner ID before touching R2.\n- Delete the R2 object and D1 row with an explicit retry/compensation policy; do not pretend the two operations are atomic.\n\n## Large Files\n\nUse `request.body → env.R2.put` for small and medium uploads. Streaming avoids Worker memory amplification but does not bypass the Cloudflare request-body limit for the account plan.\n\nUse multipart only when the object exceeds that request limit or resumability is an explicit product requirement. A multipart API must:\n\n- authenticate every create, upload-part, complete, resume, and abort action;\n- bind the object key and upload ID to an owner in durable state;\n- validate part number, part size, total size, and declared content type;\n- make completion idempotent and abort stale uploads;\n- avoid accepting an arbitrary key or upload ID from an untrusted client.\n\nDo not generate a public multipart controller by default. Multipart state and security are substantially more complex than a single streaming upload.\n\n## Local Development\n\n- Do not edit PinMe-generated `backend/wrangler.toml` to add an R2 binding.\n- Unit-test key generation, authorization, routing, and failure handling with a narrow `R2Bucket` mock.\n- Verify real metadata, Range, conditional requests, and streaming after `pinme update-worker` or `pinme save`.\n- Treat the mock as a logic test, not proof of production R2 behavior.\n\n## Anti-Patterns\n\n| Do not | Use instead |\n| --- | --- |\n| Expose an unauthenticated upload route | Authenticate and authorize before every mutation |\n| Accept a complete object key from the client | Generate an opaque ID under a server-derived owner prefix |\n| Trust a user ID from JSON or query parameters | Derive identity from verified claims |\n| Read a large body into an ArrayBuffer or base64 string | Stream `request.body` directly into `env.R2.put` |\n| Store file bodies or base64 in D1 | Store bodies in R2 and searchable metadata in D1 |\n| List the whole bucket | Restrict with an owner prefix and paginate |\n| Stop pagination based on returned object count | Check `page.truncated` and return `page.cursor` |\n| Drop response metadata | Apply `writeHttpMetadata`, `httpEtag`, length, and Range headers |\n| Persist with `fs` or local directories | Use the injected R2 binding |\n| Add R2 keys or secrets to source/config | Use `env.R2`; PinMe owns the binding |\n| Edit generated Wrangler binding configuration | Deploy through `pinme save` or `pinme update-worker` |","schemaVersion":1},"repoUrl":"https://github.com/glitternetwork/pinme/tree/main/skills/pinme-r2","tags":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy"],"stats":{"installVelocity7d":0,"retentionRate":0,"executions":0,"rating":null},"origin":"github","source":{"repo":"pinme","audit":{"files":["package-lock.json","package.json","pnpm-lock.yaml"],"binaries":[],"findings":[{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Uncontrolled memory allocation via the declared uncompressed size (DoS).","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-7q85-xj36-vmfc · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip extraction follows destination symlinks, allowing arbitrary file overwrite.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-vwc7-r8mq-g2x9 · npm:adm-zip@0.5.17","severity":"medium"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Crafted ZIP file triggers 4GB memory allocation.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-xcpc-8h2w-3j85 · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"uuid@9.0.1 has a known vulnerability: uuid: Missing buffer bounds check in v3/v5/v6 when buf is provided.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-w5hq-g745-h8pq · npm:uuid@9.0.1","severity":"medium"}],"packages":16,"auditedAt":"2026-09-25T11:51:56.672Z","lockfiles":["package-lock.json","pnpm-lock.yaml"]},"forks":277,"owner":"glitternetwork","stars":3745,"topics":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy","static-site-hosting","web-hosting","zero-configuration"],"license":"MIT","fullName":"glitternetwork/pinme","homepage":"https://pinme.eth.limo","language":"TypeScript","pushedAt":"2026-09-12T05:23:49Z","avatarUrl":"https://avatars.githubusercontent.com/u/102277171?v=4","crawledAt":"2026-09-25T11:51:44.477Z","openIssues":7,"manifestFile":"SKILL.md","manifestPath":"skills/pinme-r2/SKILL.md","defaultBranch":"main"},"readme":"# PinMe Worker R2 Storage\n\nUse the project-scoped R2 bucket that PinMe binds to every deployed Worker as `env.R2`. Do not create credentials, choose a bucket name, or edit generated Wrangler configuration.\n\n## Runtime Contract\n\nPinMe rebuilds trusted Worker metadata on create, save, and update. Client metadata cannot replace the R2 binding.\n\n| Binding | TypeScript type | Availability |\n| --- | --- | --- |\n| `DB` | `D1Database` | Always injected |\n| `R2` | `R2Bucket` | Always injected; current project's bucket |\n| `API_KEY` | `string` | Always injected |\n| `LLM_API_KEY` | `string` | Always injected |\n| `BASE_URL` | `string` | Always injected |\n| `WORKER_URL` | `string` | Always injected |\n| `PROJECT_NAME` | `string` | Always injected |\n\nPayment-specific bindings such as `UNIWEB_SECRET` are conditional and unrelated to R2 access.\n\nDeclare only the bindings used by the Worker module. R2 code normally starts with:\n\n```typescript\nexport interface Env {\n  R2: R2Bucket;\n  PROJECT_NAME: string;\n  WORKER_URL: string;\n}\n```\n\nWhen the same module coordinates file metadata in D1, also declare `DB: D1Database` as a required field.\n\n## Choose R2 or D1\n\n- Use R2 for file bodies, images, attachments, media, exports, and other objects addressed by key.\n- Use D1 for searchable business metadata, ownership, relations, status, and audit fields.\n- For managed files, store the body in R2 and store only its key and business metadata in D1.\n- Never use Worker local filesystem state for persistence and never store complete files or base64 payloads in D1.\n\n## Required Security Workflow\n\nApply this sequence to every upload, download, metadata, delete, and list route:\n\n```text\nauthenticate request\n→ authorize the project/user action\n→ validate size and media policy\n→ generate or normalize a scoped object key\n→ call env.R2\n→ return a sanitized response\n```\n\nUse the application's existing authentication. The examples below accept a trusted `userId` that the route must obtain from verified identity claims, never from an untrusted request body or query parameter.\n\nKeep object keys server-controlled. Prefer opaque IDs under an owner prefix:\n\n```typescript\nconst FILE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\n\nfunction ownerPrefix(userId: string): string {\n  if (!userId) throw new Error('Authenticated user id is required');\n  return `users/${encodeURIComponent(userId)}/files/`;\n}\n\nfunction objectKey(userId: string, fileId: string): string {\n  if (!FILE_ID_RE.test(fileId)) throw new Error('Invalid file id');\n  return `${ownerPrefix(userId)}${fileId}`;\n}\n```\n\nNever accept a complete object key from the client. Reject empty identifiers, `.` or `..` segments, backslashes, control characters, and any attempt to access another user's prefix.\n\n## Shared Helpers\n\nUse small helpers and explicit business limits. Adapt the allowlist to the product rather than accepting every client-supplied media type.\n\n```typescript\nconst MAX_UPLOAD_BYTES = 25 * 1024 * 1024;\nconst ALLOWED_CONTENT_TYPES = new Set([\n  'image/jpeg',\n  'image/png',\n  'image/webp',\n  'application/pdf',\n]);\n\nfunction json(data: unknown, status = 200): Response {\n  return Response.json(data, { status });\n}\n\nfunction safeDownloadName(value: string | null): string {\n  const cleaned = (value || 'download')\n    .replace(/[\\r\\n\"\\\\]/g, '_')\n    .replace(/[\\x00-\\x1f\\x7f]/g, '')\n    .trim();\n  return (cleaned || 'download').slice(0, 128);\n}\n\nfunction requestedFileId(request: Request): string | null {\n  const url = new URL(request.url);\n  const value = url.pathname.split('/').filter(Boolean).at(-1) || '';\n  return FILE_ID_RE.test(value) ? value : null;\n}\n```\n\nClient filenames and `Content-Type` are hints, not proof of content. For sensitive formats, inspect magic bytes or send the object through an asynchronous validation/scanning workflow before marking it ready.\n\n## Stream an Upload\n\nRequire authentication before calling this handler. Pass `request.body` directly to R2; do not c","createdAt":"2026-09-25T11:51:56.731Z","updatedAt":"2026-09-25T11:51:56.731Z"},{"id":"cmugwhsg601gzqu06q48km47z","slug":"glitternetwork-pinme-pinme-share","name":"pinme-share","description":"Use this skill when the user wants to share, publish, or upload a static result through PinMe, especially by generating a static HTML share page for a PinMe project link, deployed full-stack app, Codex conversation summary, report, file, demo, or any 分享/发布/上传分享页 request that should end with `pinme upload`.","authorId":"gh:glitternetwork","authorName":"glitternetwork","version":"0.1.0","category":"Prompt","securityLevel":"Sandbox","downloadsCount":0,"githubStars":3745,"pricePerCall":0,"manifest":{"name":"pinme-share","tools":[],"category":"Prompt","entrypoint":{"type":"prompt"},"description":"Use this skill when the user wants to share, publish, or upload a static result through PinMe, especially by generating a static HTML share page for a PinMe project link, deployed full-stack app, Codex conversation summary, report, file, demo, or any 分享/发布/上传分享页 request that should end with `pinme upload`.","permissions":[],"systemPrompt":"# PinMe Share\n\nCreate a polished static share artifact, upload it with `pinme upload`, and return the final URL.\n\n## When to Use\n\nUse this skill when the user asks to:\n\n- Share or publish a result using PinMe.\n- Create a static page that wraps a deployed project link, demo, report, or artifact.\n- Summarize a Codex conversation/session and share it as a page.\n- Turn a project handoff into a public landing or summary page.\n- Upload an existing static file or folder for lightweight distribution.\n\nIf the user needs a backend, database, auth, email, or LLM functionality, use the main `pinme` skill and any relevant PinMe integration skill first. Use `pinme-share` at the end to package and publish the result.\n\n## Core Workflow\n\n1. Identify what is being shared:\n   - **PinMe/full-stack project**: deployed URL, short description, key features, tech stack, usage notes.\n   - **Codex conversation**: goal, decisions, implementation summary, important outputs, next steps.\n   - **Static file/report/demo**: title, purpose, file contents or preview, context for the recipient.\n2. Create a static share artifact:\n   - Prefer a single self-contained `index.html`.\n   - Use `share/<slug>/index.html` in the current workspace unless the repo has an existing output/share convention.\n   - Keep CSS inline for portability.\n   - Do not require JavaScript unless interaction is valuable.\n3. Sanitize before publishing:\n   - Remove secrets, tokens, API keys, `.env` values, internal-only URLs, private user data, and unrelated logs.\n   - For conversation summaries, summarize rather than dumping raw transcript unless the user explicitly asks for verbatim sharing.\n   - Make links explicit and clickable.\n4. Upload with PinMe:\n   ```bash\n   pinme upload share/<slug>\n   ```\n5. Return the URL printed by PinMe. If PinMe outputs multiple URLs, prefer DNS domain, then PinMe subdomain, then short URL, then full preview URL. Never truncate hash fragments.\n\n## Share Page Content\n\nFor a project share page, include:\n\n- Project name and one-sentence description.\n- Primary launch/demo link as the first action.\n- What it does, who it is for, and why it matters.\n- Feature list focused on user-visible behavior.\n- Build/deploy details only when useful to the recipient.\n- Date and provenance such as \"Created with Codex\" only if appropriate.\n\nFor a conversation share page, include:\n\n- Conversation title.\n- Initial goal or question.\n- Key context and constraints.\n- Decisions made.\n- Work completed or answer summary.\n- Files changed, commands run, links produced, or artifacts created when relevant.\n- Follow-up items.\n\nFor a file/report share page, include:\n\n- Clear title and short abstract.\n- Download/open link to the uploaded artifact if there is a separate file.\n- Important excerpts or generated summary.\n- Source/context notes.\n\n## HTML Guidelines\n\n- Build a real share page, not a generic placeholder.\n- Make the most important link visible in the first viewport.\n- Use clean, responsive HTML/CSS that works as a standalone static file.\n- Keep the design restrained and readable; avoid overdecorated marketing layouts for technical handoffs.\n- Use semantic sections, accessible contrast, descriptive link text, and sensible mobile spacing.\n- Escape user-provided text before inserting it into HTML.\n- If showing code or command output, wrap it in `<pre><code>` and keep it short.\n\n## PinMe Upload Checklist\n\nBefore upload:\n\n```bash\npinme --version\n```\n\nIf PinMe is missing or stale, install or update it according to the main `pinme` skill. Authentication is required for upload:\n\n```bash\npinme login\n# or: pinme set-appkey <AppKey>\n```\n\nUpload examples:\n\n```bash\npinme upload share/my-project\npinme upload share/conversation-summary\npinme upload ./report.html\npinme upload ./dist\n```\n\nDo not upload:\n\n- `.env`, `.git`, `node_modules`, source trees, private datasets, raw logs with credentials, or unrelated build cache.\n- Raw conversation transcripts that may include secrets or private context unless the user explicitly approves the exact content.\n\n## Final Response\n\nTell the user:\n\n- What share artifact was created.\n- The PinMe URL returned by upload.\n- Any important caveat, such as skipped upload because PinMe was not authenticated or unavailable.\n\nKeep the response short. The URL is the main deliverable.","schemaVersion":1},"repoUrl":"https://github.com/glitternetwork/pinme/tree/main/skills/pinme-share","tags":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy"],"stats":{"installVelocity7d":0,"retentionRate":0,"executions":0,"rating":null},"origin":"github","source":{"repo":"pinme","audit":{"files":["package-lock.json","package.json","pnpm-lock.yaml"],"binaries":[],"findings":[{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Uncontrolled memory allocation via the declared uncompressed size (DoS).","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-7q85-xj36-vmfc · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip extraction follows destination symlinks, allowing arbitrary file overwrite.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-vwc7-r8mq-g2x9 · npm:adm-zip@0.5.17","severity":"medium"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Crafted ZIP file triggers 4GB memory allocation.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-xcpc-8h2w-3j85 · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"uuid@9.0.1 has a known vulnerability: uuid: Missing buffer bounds check in v3/v5/v6 when buf is provided.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-w5hq-g745-h8pq · npm:uuid@9.0.1","severity":"medium"}],"packages":16,"auditedAt":"2026-09-25T11:51:56.672Z","lockfiles":["package-lock.json","pnpm-lock.yaml"]},"forks":277,"owner":"glitternetwork","stars":3745,"topics":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy","static-site-hosting","web-hosting","zero-configuration"],"license":"MIT","fullName":"glitternetwork/pinme","homepage":"https://pinme.eth.limo","language":"TypeScript","pushedAt":"2026-09-12T05:23:49Z","avatarUrl":"https://avatars.githubusercontent.com/u/102277171?v=4","crawledAt":"2026-09-25T11:51:44.477Z","openIssues":7,"manifestFile":"SKILL.md","manifestPath":"skills/pinme-share/SKILL.md","defaultBranch":"main"},"readme":"# PinMe Share\n\nCreate a polished static share artifact, upload it with `pinme upload`, and return the final URL.\n\n## When to Use\n\nUse this skill when the user asks to:\n\n- Share or publish a result using PinMe.\n- Create a static page that wraps a deployed project link, demo, report, or artifact.\n- Summarize a Codex conversation/session and share it as a page.\n- Turn a project handoff into a public landing or summary page.\n- Upload an existing static file or folder for lightweight distribution.\n\nIf the user needs a backend, database, auth, email, or LLM functionality, use the main `pinme` skill and any relevant PinMe integration skill first. Use `pinme-share` at the end to package and publish the result.\n\n## Core Workflow\n\n1. Identify what is being shared:\n   - **PinMe/full-stack project**: deployed URL, short description, key features, tech stack, usage notes.\n   - **Codex conversation**: goal, decisions, implementation summary, important outputs, next steps.\n   - **Static file/report/demo**: title, purpose, file contents or preview, context for the recipient.\n2. Create a static share artifact:\n   - Prefer a single self-contained `index.html`.\n   - Use `share/<slug>/index.html` in the current workspace unless the repo has an existing output/share convention.\n   - Keep CSS inline for portability.\n   - Do not require JavaScript unless interaction is valuable.\n3. Sanitize before publishing:\n   - Remove secrets, tokens, API keys, `.env` values, internal-only URLs, private user data, and unrelated logs.\n   - For conversation summaries, summarize rather than dumping raw transcript unless the user explicitly asks for verbatim sharing.\n   - Make links explicit and clickable.\n4. Upload with PinMe:\n   ```bash\n   pinme upload share/<slug>\n   ```\n5. Return the URL printed by PinMe. If PinMe outputs multiple URLs, prefer DNS domain, then PinMe subdomain, then short URL, then full preview URL. Never truncate hash fragments.\n\n## Share Page Content\n\nFor a project share page, include:\n\n- Project name and one-sentence description.\n- Primary launch/demo link as the first action.\n- What it does, who it is for, and why it matters.\n- Feature list focused on user-visible behavior.\n- Build/deploy details only when useful to the recipient.\n- Date and provenance such as \"Created with Codex\" only if appropriate.\n\nFor a conversation share page, include:\n\n- Conversation title.\n- Initial goal or question.\n- Key context and constraints.\n- Decisions made.\n- Work completed or answer summary.\n- Files changed, commands run, links produced, or artifacts created when relevant.\n- Follow-up items.\n\nFor a file/report share page, include:\n\n- Clear title and short abstract.\n- Download/open link to the uploaded artifact if there is a separate file.\n- Important excerpts or generated summary.\n- Source/context notes.\n\n## HTML Guidelines\n\n- Build a real share page, not a generic placeholder.\n- Make the most important link visible in the first viewport.\n- Use clean, responsive HTML/CSS that works as a standalone static file.\n- Keep the design restrained and readable; avoid overdecorated marketing layouts for technical handoffs.\n- Use semantic sections, accessible contrast, descriptive link text, and sensible mobile spacing.\n- Escape user-provided text before inserting it into HTML.\n- If showing code or command output, wrap it in `<pre><code>` and keep it short.\n\n## PinMe Upload Checklist\n\nBefore upload:\n\n```bash\npinme --version\n```\n\nIf PinMe is missing or stale, install or update it according to the main `pinme` skill. Authentication is required for upload:\n\n```bash\npinme login\n# or: pinme set-appkey <AppKey>\n```\n\nUpload examples:\n\n```bash\npinme upload share/my-project\npinme upload share/conversation-summary\npinme upload ./report.html\npinme upload ./dist\n```\n\nDo not upload:\n\n- `.env`, `.git`, `node_modules`, source trees, private datasets, raw logs with credentials, or unrelated build cache.\n- Raw conversation transcripts that may include secrets or private context unless th","createdAt":"2026-09-25T11:51:56.743Z","updatedAt":"2026-09-25T11:51:56.743Z"},{"id":"cmugwhsgg01h2qu06355ifb4x","slug":"glitternetwork-pinme-pinme-uniwebpay","name":"pinme-uniwebpay","description":"Use when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout sessions, payment status reads, refunds, subscriptions, or handling UniwebPay webhooks with @uniwebpay/sdk in a PinMe project.","authorId":"gh:glitternetwork","authorName":"glitternetwork","version":"0.1.0","category":"Prompt","securityLevel":"Sandbox","downloadsCount":0,"githubStars":3745,"pricePerCall":0,"manifest":{"name":"pinme-uniwebpay","tools":[],"category":"Prompt","entrypoint":{"type":"prompt"},"description":"Use when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout sessions, payment status reads, refunds, subscriptions, or handling UniwebPay webhooks with @uniwebpay/sdk in a PinMe project.","permissions":[],"systemPrompt":"# PinMe UniwebPay Payment Integration\n\nGuides writing payment services in a PinMe Worker (Cloudflare Worker TypeScript) that call UniwebPay directly through `@uniwebpay/sdk`.\n\nCore model: PinMe provisions the UniwebPay wallet and keys per **PinMe user** (not per project) and injects `UNIWEB_*` environment bindings at Worker deploy time; Worker code calls UniwebPay **directly with the SDK** — it does not go through PinMe payment proxy routes, and it must not call the legacy VibeCash APIs.\n\n## Environment Binding Contract\n\n```typescript\nexport interface Env {\n  UNIWEB_SECRET: string;           // PinMe-provisioned sk_server_ key (server-side only)\n  UNIWEB_WEBHOOK_SECRET?: string;  // wallet-level whsec_, used to verify webhook signatures\n  UNIWEB_API_URL?: string;         // UniwebPay API endpoint override (default https://apiskill.uniwebpay.com)\n  UNIWEB_PAY_URL?: string;         // UniwebPay checkout host override (default https://skill.uniwebpay.com)\n  UNIWEB_WALLET_ID?: string;       // user-level wallet id (wal_), diagnostics/reconciliation only\n  WORKER_URL?: string;             // this project's public URL: https://{projectName}.{platform api domain}\n  PROJECT_NAME?: string;           // PinMe project name\n  DB?: D1Database;                 // project D1 (if enabled)\n}\n```\n\nInjection rules (metadata is rebuilt server-side by PinMe at deploy time; client-supplied bindings are ignored):\n\n- The `UNIWEB_*` bindings are injected only after the user's UniwebPay credentials have been provisioned. Newly created projects are provisioned automatically and get them immediately; **existing projects must be redeployed after enabling UniwebPay or rotating keys** to pick up new bindings.\n- `WORKER_URL`, `PROJECT_NAME`, `API_KEY`, `DB` and other base bindings are injected on every deploy, independent of UniwebPay.\n- All projects owned by the same PinMe user share one wallet, one `sk_server_`, and one `whsec_`.\n- PinMe never gives the full wallet secret (`sk_live_`) to a Worker. Do not ask the user for it, and do not put it in code, `wrangler.toml`, `.dev.vars`, responses, logs, D1, or frontend bundles.\n- If `UNIWEB_SECRET` is missing at runtime, the user has not enabled UniwebPay or has not redeployed — tell the user to enable it and redeploy; never fabricate a value.\n\n## SDK Client\n\nAlways instantiate on the server side (the Worker); the SDK throws when run in a browser:\n\n```typescript\nimport Uniweb from \"@uniwebpay/sdk\";\n\nfunction uniwebClient(env: Env): Uniweb {\n  return new Uniweb(env.UNIWEB_SECRET, {\n    baseUrl: env.UNIWEB_API_URL,\n    payUrl: env.UNIWEB_PAY_URL,\n  });\n}\n```\n\n- The constructor's first positional argument is the key (must have an `sk_server_` or `sk_live_` prefix); the second is optional options: `{ baseUrl?, payUrl?, timeout? (default 30s), maxRetries? (default 2) }`.\n- The SDK auto-retries only GET/DELETE on 429/5xx; POST/PATCH are never retried (avoids duplicate charges).\n- Install `@uniwebpay/sdk` only when Worker code imports it; pick the package manager from the project's existing lockfile.\n\n## Choosing an Integration Path\n\n| Scenario | Approach | Returns |\n|------|------|------|\n| Fixed-amount one-time collection | `uniweb.links.create(...)` | Permanent, reusable `/p/` link (one-time payments only) |\n| Stable product catalog | `products.create` + `prices.create` once, store the `priceId` | Price carries a permanent `paymentUrl` (`/buy/` link) |\n| Dynamic cart/order | Reuse or create a price, then `uniweb.checkout.create(...)` | `session.url` — **one-time, expires in 24 hours** |\n| Subscriptions | Recurring price + `checkout.create({ mode: \"subscription\" })` or `subscriptions.create` | Same as above |\n| Server-side payment status checks | `payments.get / list` | Server routes only |\n\nAmounts are always **integer minor units** (cents). Default currency convention is `SGD` unless the app has a stronger existing convention. Do not create a new product/price on every page view — create stable catalog items once and persist the `priceId`.\n\n## Payment Methods and Currency Rules\n\n| Method | Supported currencies |\n|------|---------|\n| `card` | SGD, USD, EUR, GBP, JPY, CNY, HKD, AUD, MYR, THB (minimum 10 minor units) |\n| `wechat` | SGD only |\n| `alipay` | SGD only |\n| `paynow` | SGD only |\n\n- The QR methods (wechat/alipay/paynow) **all support SGD only** — never generate \"CNY via WeChat/Alipay\" code.\n- Subscriptions (recurring / `mode: \"subscription\"`) use `card` only.\n- When `paymentMethodTypes` is omitted, the server picks sensible defaults for the currency; when passed explicitly, validate user input against the table above first.\n\n## SDK Surface Quick Reference\n\nThe surface below is verified against source. All parameter fields are camelCase (`priceId`, `webhookUrl`, `startingAfter`, …); the SDK handles wire-level conversion itself. `list()` returns `{ data: T[], hasMore: boolean }`; `listAll()` is an async generator available on products, prices, payments, customers, subscriptions, and links (not on checkout or refunds).\n\nProducts (`webhookUrl` is the per-product callback override):\n\n```typescript\nawait uniweb.products.create({ name, description?, webhookUrl?, metadata? });\nawait uniweb.products.list({ limit?, startingAfter? });\nawait uniweb.products.get(productId);\nawait uniweb.products.update(productId, { name?, description?, webhookUrl?, active?, metadata? });\nawait uniweb.products.del(productId);\nfor await (const product of uniweb.products.listAll()) {}\n```\n\nPrices (the returned price carries a permanent `paymentUrl`; `deactivate` takes it off sale):\n\n```typescript\nawait uniweb.prices.create({\n  productId,\n  amount,        // integer minor units\n  currency,      // e.g. \"SGD\"\n  type,          // \"one_time\" | \"recurring\"\n  interval?,     // \"day\" | \"week\" | \"month\" | \"year\"; recurring only\n  intervalCount?,\n  trialPeriodDays?,\n  metadata?,\n});\nawait uniweb.prices.list({ productId?, limit?, startingAfter? });\nawait uniweb.prices.get(priceId);\nawait uniweb.prices.update(priceId, { active });\nawait uniweb.prices.activate(priceId);\nawait uniweb.prices.deactivate(priceId);\nfor await (const price of uniweb.prices.listAll({ productId? })) {}\n```\n\nCheckout sessions (**do not accept `webhookUrl`** — events resolve through the price → product → wallet chain; the URL is one-time and expires after 24 hours):\n\n```typescript\nawait uniweb.checkout.create({\n  mode,           // \"payment\" | \"subscription\"\n  lineItems: [{ priceId, quantity }],\n  successUrl?,\n  cancelUrl?,\n  customerEmail?,\n  customerId?,\n  trialPeriodDays?,\n  paymentMethodTypes?, // [\"card\", \"wechat\", \"alipay\", \"paynow\"]\n  metadata?,\n});\nawait uniweb.checkout.list({ limit?, startingAfter? });\nawait uniweb.checkout.get(checkoutSessionId);\n```\n\nPayments (for server-side status checks; only mark a local order paid when amount, currency, metadata, and order state all match expectations):\n\n```typescript\nawait uniweb.payments.create({ amount, currency, customerId?, metadata? });\nawait uniweb.payments.list({ status?, customerId?, limit?, startingAfter? });\nawait uniweb.payments.get(paymentId, { gateway? });\nawait uniweb.payments.listRefunds(paymentId);\nawait uniweb.payments.sync(paymentId);\nawait uniweb.payments.void(paymentId);\nfor await (const payment of uniweb.payments.listAll({ status?, customerId? })) {}\n```\n\nRefunds (no list/listAll — use `payments.listRefunds`):\n\n```typescript\nawait uniweb.refunds.create({ paymentId, amount?, reason?, offlineRefundFlag? });\nawait uniweb.refunds.get(refundId, { gateway? });\n```\n\nCustomers:\n\n```typescript\nawait uniweb.customers.create({ email, name?, metadata? });\nawait uniweb.customers.list({ email?, limit?, startingAfter? });\nawait uniweb.customers.get(customerId);\nawait uniweb.customers.update(customerId, { email?, name?, metadata? });\nawait uniweb.customers.del(customerId);\nfor await (const customer of uniweb.customers.listAll({ email? })) {}\n```\n\nSubscriptions (states include `trialing` / `active` / `past_due` / `unpaid` / `canceled`; update access only from verified webhooks or a trusted server-side reconciliation job):\n\n```typescript\nawait uniweb.subscriptions.create({ customerId, priceId, paymentMethodId?, trialPeriodDays?, metadata? });\nawait uniweb.subscriptions.list({ customerId?, status?, limit?, startingAfter? });\nawait uniweb.subscriptions.get(subscriptionId);\nawait uniweb.subscriptions.update(subscriptionId, { cancelAtPeriodEnd? });\nawait uniweb.subscriptions.cancel(subscriptionId); // cancel immediately\nawait uniweb.subscriptions.resume(subscriptionId); // undo cancelAtPeriodEnd\nfor await (const subscription of uniweb.subscriptions.listAll({ customerId?, status? })) {}\n```\n\nPayment links (permanent reusable `/p/` links, one-time collection only; `webhookUrl` is the per-link callback override):\n\n```typescript\nawait uniweb.links.create({\n  amount,\n  currency,\n  name?,\n  description?,\n  successUrl?,\n  cancelUrl?,\n  webhookUrl?,\n  paymentMethodTypes?,\n  metadata?,\n});\nawait uniweb.links.list({ limit?, startingAfter? });\nawait uniweb.links.get(paymentLinkId);\nawait uniweb.links.update(paymentLinkId, { name?, description?, successUrl?, cancelUrl?, webhookUrl?, active? });\nawait uniweb.links.deactivate(paymentLinkId);\nfor await (const link of uniweb.links.listAll()) {}\n```\n\nWallet and wallet-level webhook configuration (**danger zone**: affects the wallet shared by ALL of the user's projects):\n\n```typescript\nawait uniweb.wallet.current();\nawait uniweb.wallet.update({ merchantName?, merchantCity?, merchantCountry?, webhookUrl? });\nawait uniweb.webhooks.set(url);       // overwrites the wallet-level callback URL\nawait uniweb.webhooks.info();\nawait uniweb.webhooks.remove();\nawait uniweb.webhooks.rollSecret();   // rotates the shared whsec_\n```\n\nOrdinary project routes must **not** call `webhooks.set / remove / rollSecret` or `wallet.update` — they mutate the wallet callback fallback and signing secret shared across all of the user's projects. Generate them only when the user explicitly asks for wallet administration and the route has project/admin-level authorization. Same for refunds, subscription mutations, payouts, KYC, and bank account APIs: generate only when the user explicitly requests that business flow and the code has validation, persistence, and authorization.\n\n## Webhook Integration\n\n### Callback URL: set it on the link/product, pointing at this Worker\n\nEvent delivery precedence: **per-link `webhookUrl` > per-product `webhookUrl` > wallet-level fallback**. The signing secret is always the wallet-level `whsec_` (i.e. `env.UNIWEB_WEBHOOK_SECRET`).\n\nPinMe sets a managed fallback callback URL on the wallet, but it exists only to obtain and preserve the signing secret — **PinMe's server discards events it receives there (204); it never forwards them to the Worker**. Business events must therefore set this project's `webhookUrl` explicitly on the resource that creates the payment:\n\n- Payment links: pass `webhookUrl` on `links.create`.\n- Checkout sessions: `checkout.create` has no `webhookUrl` field; events route through the price's product — set `webhookUrl` on `products.create` (or on the reused product).\n\nRules for building the `webhookUrl`:\n\n- Keep the callback path in a single constant (e.g. `const WEBHOOK_PATH = \"/api/pay/webhook\"`) shared by the router and the `webhookUrl` construction, so a path mismatch can't 404 the callbacks and leave orders stuck in pending.\n- Use `env.WORKER_URL` as the base: `new URL(WEBHOOK_PATH, env.WORKER_URL).toString()`. It is the only public address available at runtime (the platform subdomain); the user's custom domain is not in `env`. Prefer it over `request.url` (the current request's host is not necessarily the deployed address), and non-HTTP contexts (cron/queue) have no request at all — fail loudly if it's missing rather than emitting a broken URL.\n- Local dev has no `WORKER_URL`; a `request.url` fallback resolves to localhost, which UniwebPay cannot reach. To test webhooks locally, expose the Worker through a tunnel (cloudflared / ngrok).\n- `webhookUrl` must be HTTPS. Put no secrets or trust-bearing data in the URL query — carry correlation like `orderId` in `metadata`, and verify identity from the signature plus `metadata` (a query string can be forged).\n\n### Verification and Handling\n\n```typescript\nimport { verifyWebhook } from \"@uniwebpay/sdk\";\n\nconst rawBody = await request.text();              // read the raw body exactly once\nconst event = await verifyWebhook(                  // note: async\n  rawBody,\n  request.headers.get(\"uniweb-Signature\") || \"\",   // format: t=<unix>,v1=<hex>\n  env.UNIWEB_WEBHOOK_SECRET,\n);\n```\n\n- `verifyWebhook(rawBody, signature, secret)` returns `Promise<WebhookEvent>`; signature timestamp tolerance is ±5 minutes; it throws on failure.\n- Event shape: `{ id: \"evt_...\", type, created, data: { object, productId?, priceId?, productName? } }`. The business object is in `event.data.object`; correlation like `orderId` is in `event.data.object.metadata`.\n- Event types verified as actually delivered: `payment.succeeded` / `payment.failed` / `payment.refunded` / `payment.partially_refunded`, `refund.succeeded` / `refund.failed` / `refund.abandoned`, `checkout.session.completed` / `checkout.session.expired`, `subscription.created` / `subscription.renewed` / `subscription.past_due` / `subscription.unpaid` / `subscription.trial_ending` / `subscription.canceled`.\n\nHandling rules:\n\n- **The webhook route must bypass the project's own auth.** UniwebPay callbacks carry only `uniweb-Signature` (plus `uniweb-Event-Id` / `uniweb-Timestamp`), never the project `API_KEY`. If a global auth guard wraps all routes, exempt `WEBHOOK_PATH` — trust comes solely from signature verification. Otherwise callbacks get 401/403 and orders never fulfill.\n- Return 400 for invalid signatures (so UniwebPay stops pointless retries); return 500 for temporary processing failures (so it retries).\n- Enforce idempotency with `event.id` (combined with payment id / checkout session id / local order id).\n- Before fulfillment, verify amount, currency, metadata, and current order state.\n- Respond within 10 seconds; delivery does not follow redirects, so do not put the webhook route behind a redirect. The first delivery is immediate; on failure, retries follow at 5-minute, 30-minute, 2-hour, and 12-hour intervals, up to 6 attempts before the event is marked failed.\n- Keep `UNIWEB_WEBHOOK_SECRET` optional in TypeScript — a project can exist before provisioning or redeploy; return 501 with a hint when it's missing at runtime.\n\n## Security Rules\n\n- No Uniweb secret (`UNIWEB_SECRET`, `UNIWEB_WEBHOOK_SECRET`) may appear in responses, logs, metadata, test snapshots, D1, source code, committed `wrangler.toml` / `.dev.vars`, or browser code. For local dev, use an uncommitted `.dev.vars` only.\n- Do not import the SDK in browser-side code; do not use `process.env` in Cloudflare Workers — use the `env` argument.\n- Do not call legacy VibeCash APIs or PinMe payment proxy routes; do not call PinMe payment APIs with `X-API-Key` for UniwebPay collection — the Worker calls UniwebPay directly through the SDK.\n- `successUrl` / `cancelUrl` are UX redirects only. **Never fulfill based on a browser redirect**; grant access only after verified webhook processing (or another explicit server-side verification).\n- Validate user input before SDK calls: amount (integer minor units), currency (ISO 4217 and within the payment-method constraints), quantity, product/price IDs, payment method types, order ownership, and metadata shape.\n\n## Persistence Guidance (D1)\n\nAdd tables/migrations only when the project already uses D1 or the user asks for persistence. For order flows, store at least: local order id, the corresponding Uniweb link/session/payment/subscription id, amount and currency, status, timestamps, and processed webhook event ids (for idempotency). Never store secrets.\n\n```sql\nCREATE TABLE IF NOT EXISTS orders (\n  order_id TEXT PRIMARY KEY,\n  checkout_session_id TEXT UNIQUE,\n  status TEXT NOT NULL DEFAULT 'pending',\n  amount_cents INTEGER NOT NULL,\n  currency TEXT NOT NULL,\n  paid_at INTEGER,\n  created_at INTEGER NOT NULL,\n  updated_at INTEGER\n);\n\nCREATE TABLE IF NOT EXISTS payment_events (\n  event_id TEXT PRIMARY KEY,\n  event_type TEXT NOT NULL,\n  order_id TEXT,\n  raw_payload TEXT NOT NULL,\n  created_at INTEGER NOT NULL\n);\n```\n\n## Worker Reference Implementation\n\n```typescript\nimport Uniweb, { verifyWebhook } from \"@uniwebpay/sdk\";\n\nexport interface Env {\n  UNIWEB_SECRET: string;\n  UNIWEB_WEBHOOK_SECRET?: string;\n  UNIWEB_API_URL?: string;\n  UNIWEB_PAY_URL?: string;\n  UNIWEB_WALLET_ID?: string;\n  WORKER_URL?: string;\n  PROJECT_NAME?: string;\n  DB?: D1Database;\n}\n\ntype PaymentMethod = \"card\" | \"wechat\" | \"alipay\" | \"paynow\";\n\nconst VALID_PAYMENT_METHODS = new Set<PaymentMethod>([\"card\", \"wechat\", \"alipay\", \"paynow\"]);\nconst CARD_CURRENCIES = new Set([\"SGD\", \"USD\", \"EUR\", \"GBP\", \"JPY\", \"CNY\", \"HKD\", \"AUD\", \"MYR\", \"THB\"]);\n\n// Single source of truth for the webhook path: shared by the router and the\n// webhookUrl construction so the address sent to UniwebPay always matches the\n// route the Worker actually serves.\nconst WEBHOOK_PATH = \"/api/pay/webhook\";\n\nfunction uniwebClient(env: Env): Uniweb {\n  return new Uniweb(env.UNIWEB_SECRET, {\n    baseUrl: env.UNIWEB_API_URL,\n    payUrl: env.UNIWEB_PAY_URL,\n  });\n}\n\nfunction json(data: unknown, init: ResponseInit = {}): Response {\n  return Response.json(data, init);\n}\n\nfunction assertAmountCents(value: unknown): number {\n  const amount = Number(value);\n  if (!Number.isInteger(amount) || amount < 10) {\n    throw new Error(\"amountCents must be an integer minor-unit amount >= 10\");\n  }\n  return amount;\n}\n\nfunction normalizeCurrency(value: unknown): string {\n  const currency = String(value || \"SGD\").toUpperCase();\n  if (!/^[A-Z]{3}$/.test(currency)) throw new Error(\"currency must be an ISO 4217 code\");\n  return currency;\n}\n\n// QR methods (wechat/alipay/paynow) support SGD only; card supports 10 currencies.\nfunction defaultPaymentMethods(currency: string): PaymentMethod[] {\n  if (currency === \"SGD\") return [\"card\", \"wechat\", \"alipay\", \"paynow\"];\n  if (CARD_CURRENCIES.has(currency)) return [\"card\"];\n  throw new Error(`unsupported currency: ${currency}`);\n}\n\nfunction normalizePaymentMethods(value: unknown, currency: string): PaymentMethod[] {\n  const requested = Array.isArray(value) && value.length > 0 ? value : defaultPaymentMethods(currency);\n  const methods = requested.map((m) => String(m).toLowerCase() as PaymentMethod);\n  for (const method of methods) {\n    if (!VALID_PAYMENT_METHODS.has(method)) {\n      throw new Error(\"paymentMethodTypes contains an unsupported method\");\n    }\n    if (method !== \"card\" && currency !== \"SGD\") {\n      throw new Error(`${method} only supports SGD payments`);\n    }\n    if (method === \"card\" && !CARD_CURRENCIES.has(currency)) {\n      throw new Error(`card does not support ${currency}`);\n    }\n  }\n  return Array.from(new Set(methods));\n}\n\n// Prefer the PinMe-injected WORKER_URL (the project's platform subdomain, the\n// only public address available at runtime). request.url is only a fallback for\n// older deploys; cron/queue contexts have no request, so WORKER_URL is required.\nfunction projectWebhookUrl(env: Env, request?: Request): string {\n  const base = env.WORKER_URL || request?.url;\n  if (!base) throw new Error(\"WORKER_URL binding is missing\");\n  return new URL(WEBHOOK_PATH, base).toString();\n}\n\n// ---- One-time collection: payment link ----\n\nasync function createPaymentLink(request: Request, env: Env): Promise<Response> {\n  if (request.method !== \"POST\") return json({ error: \"method not allowed\" }, { status: 405 });\n\n  let input: any;\n  try {\n    input = await request.json();\n  } catch {\n    return json({ error: \"invalid JSON body\" }, { status: 400 });\n  }\n\n  let amount: number, currency: string, paymentMethodTypes: PaymentMethod[];\n  try {\n    amount = assertAmountCents(input.amountCents);\n    currency = normalizeCurrency(input.currency);\n    paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency);\n  } catch (err) {\n    return json({ error: (err as Error).message }, { status: 400 });\n  }\n\n  const orderId = input.orderId || crypto.randomUUID();\n  const uniweb = uniwebClient(env);\n\n  try {\n    const link = await uniweb.links.create({\n      amount,\n      currency,\n      name: input.name || \"Payment\",\n      description: input.description,\n      successUrl: input.successUrl,\n      cancelUrl: input.cancelUrl,\n      webhookUrl: projectWebhookUrl(env, request),\n      paymentMethodTypes,\n      metadata: { orderId, projectName: env.PROJECT_NAME },\n    });\n    return json({ orderId, linkId: link.id, url: link.url });\n  } catch {\n    return json({ error: \"failed to create payment link\" }, { status: 502 });\n  }\n}\n\n// ---- Dynamic orders: checkout session ----\n// Note: checkout.create has no webhookUrl; the callback is set on the product.\n// Stable catalog items should create the product/price once and persist the\n// priceId — do not create new ones on every request.\n\nasync function createCheckoutSession(request: Request, env: Env): Promise<Response> {\n  if (request.method !== \"POST\") return json({ error: \"method not allowed\" }, { status: 405 });\n\n  let input: any;\n  try {\n    input = await request.json();\n  } catch {\n    return json({ error: \"invalid JSON body\" }, { status: 400 });\n  }\n\n  let amount: number, currency: string, paymentMethodTypes: PaymentMethod[];\n  try {\n    amount = assertAmountCents(input.amountCents);\n    currency = normalizeCurrency(input.currency);\n    paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency);\n  } catch (err) {\n    return json({ error: (err as Error).message }, { status: 400 });\n  }\n\n  const orderId = input.orderId || crypto.randomUUID();\n  const quantity = Math.max(1, Math.floor(Number(input.quantity || 1)));\n  const uniweb = uniwebClient(env);\n\n  try {\n    const product = await uniweb.products.create({\n      name: input.productName,\n      webhookUrl: projectWebhookUrl(env, request),\n      metadata: { orderId, projectName: env.PROJECT_NAME },\n    });\n    const price = await uniweb.prices.create({\n      productId: product.id,\n      amount,\n      currency,\n      type: \"one_time\",\n      metadata: { orderId, projectName: env.PROJECT_NAME },\n    });\n    const session = await uniweb.checkout.create({\n      mode: \"payment\",\n      lineItems: [{ priceId: price.id, quantity }],\n      successUrl: input.successUrl,\n      cancelUrl: input.cancelUrl,\n      customerEmail: input.customerEmail,\n      paymentMethodTypes,\n      metadata: { orderId, projectName: env.PROJECT_NAME },\n    });\n\n    if (env.DB) {\n      await env.DB.prepare(\n        `INSERT INTO orders(order_id, checkout_session_id, status, amount_cents, currency, created_at)\n         VALUES (?, ?, 'pending', ?, ?, ?)\n         ON CONFLICT(order_id) DO UPDATE SET checkout_session_id = excluded.checkout_session_id`,\n      )\n        .bind(orderId, session.id, amount * quantity, currency, Math.floor(Date.now() / 1000))\n        .run();\n    }\n\n    return json({ orderId, checkoutSessionId: session.id, url: session.url });\n  } catch {\n    return json({ error: \"failed to create checkout session\" }, { status: 502 });\n  }\n}\n\n// ---- Webhook handling ----\n\nasync function handleUniwebWebhook(request: Request, env: Env): Promise<Response> {\n  if (request.method !== \"POST\") return json({ error: \"method not allowed\" }, { status: 405 });\n  if (!env.UNIWEB_WEBHOOK_SECRET) {\n    return json({ error: \"webhook verification is not configured\" }, { status: 501 });\n  }\n\n  const rawBody = await request.text();\n  let event: Awaited<ReturnType<typeof verifyWebhook>>;\n  try {\n    event = await verifyWebhook(\n      rawBody,\n      request.headers.get(\"uniweb-Signature\") || \"\",\n      env.UNIWEB_WEBHOOK_SECRET,\n    );\n  } catch {\n    return json({ error: \"invalid signature\" }, { status: 400 });\n  }\n\n  try {\n    if (env.DB) {\n      const object = event.data.object as { metadata?: Record<string, unknown> };\n      const orderId = String(object?.metadata?.orderId || \"\");\n      const now = Math.floor(Date.now() / 1000);\n\n      // event.id idempotency: duplicate deliveries become no-ops\n      await env.DB.prepare(\n        `INSERT INTO payment_events(event_id, event_type, order_id, raw_payload, created_at)\n         VALUES (?, ?, ?, ?, ?)\n         ON CONFLICT(event_id) DO NOTHING`,\n      )\n        .bind(event.id, event.type, orderId, rawBody, now)\n        .run();\n\n      if (event.type === \"payment.succeeded\" && orderId) {\n        // Production code should verify amount/currency/order state here before fulfilling\n        await env.DB.prepare(\n          `UPDATE orders SET status = 'paid', paid_at = ?, updated_at = ? WHERE order_id = ? AND status != 'paid'`,\n        )\n          .bind(now, now, orderId)\n          .run();\n      }\n    }\n    return json({ ok: true });\n  } catch {\n    // 500 makes UniwebPay retry on the 5m/30m/2h/12h schedule\n    return json({ error: \"processing error\" }, { status: 500 });\n  }\n}\n\n// ---- Router ----\n\nexport default {\n  async fetch(request: Request, env: Env): Promise<Response> {\n    const url = new URL(request.url);\n\n    // The webhook must come before any project auth guard: callbacks carry only\n    // uniweb-Signature, never the project API_KEY.\n    if (url.pathname === WEBHOOK_PATH) return handleUniwebWebhook(request, env);\n\n    // The project's own auth guard goes after this point, on the business routes.\n    if (url.pathname === \"/api/pay/link\") return createPaymentLink(request, env);\n    if (url.pathname === \"/api/pay/checkout\") return createCheckoutSession(request, env);\n\n    return json({ error: \"not found\" }, { status: 404 });\n  },\n};\n```\n\n## Common Mistakes\n\n| Mistake | Consequence / fix |\n|------|----------|\n| Webhook route blocked by the project's `API_KEY` / bearer auth guard | Callbacks get 401/403 and orders stay pending forever. Exempt `WEBHOOK_PATH`; trust comes solely from signature verification |\n| Forgetting to set `webhookUrl` on the link/product | Events fall through to PinMe's managed fallback and are discarded; the Worker never sees them. Business events must point per-link/per-product at this Worker |\n| Passing `webhookUrl` to `checkout.create` | The field does not exist. Set it on the backing product |\n| Fulfilling on a `successUrl` redirect | Forgeable. Fulfill only from verified webhooks or server-side verification |\n| Hardcoding the callback host or relying only on `request.url` | Build from `env.WORKER_URL`; `request.url` is a fallback only |\n| Pairing CNY with wechat/alipay | Source limits QR methods to SGD only; CNY can only use card |\n| Creating a new product/price on every request | Create stable catalog items once, persist and reuse the `priceId` |\n| Calling `webhooks.set/remove/rollSecret` or `wallet.update` in ordinary business flows | Mutates/rotates the wallet callback and `whsec_` shared by ALL of the user's projects. Generate only for explicit wallet-administration requests with admin authorization |\n| Using floating-point major units for amounts | Always integer minor units |\n| Using `process.env` in the Worker or importing the SDK in the browser | Use the `env` argument; the SDK rejects browser environments |\n| Putting `UNIWEB_SECRET` / `whsec_` in `wrangler.toml`, source, logs, or D1 | PinMe injects them at deploy time; locally use an uncommitted `.dev.vars` only |\n| Reading the body more than once, or as JSON, before verification | Read with `request.text()` exactly once and pass the raw string to `verifyWebhook` |\n\n## Finish Checklist\n\n- [ ] `@uniwebpay/sdk` is installed only when Worker code imports it; package manager follows the lockfile.\n- [ ] `Env` includes the PinMe-injected bindings the code uses; `UNIWEB_WEBHOOK_SECRET` / `WORKER_URL` stay optional and their absence is handled.\n- [ ] The client is instantiated with `new Uniweb(env.UNIWEB_SECRET, { baseUrl: env.UNIWEB_API_URL, payUrl: env.UNIWEB_PAY_URL })`.\n- [ ] No VibeCash or PinMe payment proxy routes are used; no secret appears in source, responses, logs, D1, tests, or docs.\n- [ ] Amounts are validated integer minor units; payment methods and currencies follow the constraint table (QR methods SGD only).\n- [ ] Every payment creation that needs events carries a per-link/per-product `webhookUrl` built from `env.WORKER_URL`.\n- [ ] Webhook: raw body read exactly once, `verifyWebhook` verification, correct 400/500 semantics, `event.id` idempotency, route bypasses project auth, responds within 10 seconds.\n- [ ] Fulfillment does not rely on browser redirects; amount, currency, metadata, and order state are verified before granting access.","schemaVersion":1},"repoUrl":"https://github.com/glitternetwork/pinme/tree/main/skills/pinme-uniwebpay","tags":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy"],"stats":{"installVelocity7d":0,"retentionRate":0,"executions":0,"rating":null},"origin":"github","source":{"repo":"pinme","audit":{"files":["package-lock.json","package.json","pnpm-lock.yaml"],"binaries":[],"findings":[{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Uncontrolled memory allocation via the declared uncompressed size (DoS).","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-7q85-xj36-vmfc · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip extraction follows destination symlinks, allowing arbitrary file overwrite.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-vwc7-r8mq-g2x9 · npm:adm-zip@0.5.17","severity":"medium"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Crafted ZIP file triggers 4GB memory allocation.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-xcpc-8h2w-3j85 · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"uuid@9.0.1 has a known vulnerability: uuid: Missing buffer bounds check in v3/v5/v6 when buf is provided.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-w5hq-g745-h8pq · npm:uuid@9.0.1","severity":"medium"}],"packages":16,"auditedAt":"2026-09-25T11:51:56.672Z","lockfiles":["package-lock.json","pnpm-lock.yaml"]},"forks":277,"owner":"glitternetwork","stars":3745,"topics":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy","static-site-hosting","web-hosting","zero-configuration"],"license":"MIT","fullName":"glitternetwork/pinme","homepage":"https://pinme.eth.limo","language":"TypeScript","pushedAt":"2026-09-12T05:23:49Z","avatarUrl":"https://avatars.githubusercontent.com/u/102277171?v=4","crawledAt":"2026-09-25T11:51:44.477Z","openIssues":7,"manifestFile":"SKILL.md","manifestPath":"skills/pinme-uniwebpay/SKILL.md","defaultBranch":"main"},"readme":"# PinMe UniwebPay Payment Integration\n\nGuides writing payment services in a PinMe Worker (Cloudflare Worker TypeScript) that call UniwebPay directly through `@uniwebpay/sdk`.\n\nCore model: PinMe provisions the UniwebPay wallet and keys per **PinMe user** (not per project) and injects `UNIWEB_*` environment bindings at Worker deploy time; Worker code calls UniwebPay **directly with the SDK** — it does not go through PinMe payment proxy routes, and it must not call the legacy VibeCash APIs.\n\n## Environment Binding Contract\n\n```typescript\nexport interface Env {\n  UNIWEB_SECRET: string;           // PinMe-provisioned sk_server_ key (server-side only)\n  UNIWEB_WEBHOOK_SECRET?: string;  // wallet-level whsec_, used to verify webhook signatures\n  UNIWEB_API_URL?: string;         // UniwebPay API endpoint override (default https://apiskill.uniwebpay.com)\n  UNIWEB_PAY_URL?: string;         // UniwebPay checkout host override (default https://skill.uniwebpay.com)\n  UNIWEB_WALLET_ID?: string;       // user-level wallet id (wal_), diagnostics/reconciliation only\n  WORKER_URL?: string;             // this project's public URL: https://{projectName}.{platform api domain}\n  PROJECT_NAME?: string;           // PinMe project name\n  DB?: D1Database;                 // project D1 (if enabled)\n}\n```\n\nInjection rules (metadata is rebuilt server-side by PinMe at deploy time; client-supplied bindings are ignored):\n\n- The `UNIWEB_*` bindings are injected only after the user's UniwebPay credentials have been provisioned. Newly created projects are provisioned automatically and get them immediately; **existing projects must be redeployed after enabling UniwebPay or rotating keys** to pick up new bindings.\n- `WORKER_URL`, `PROJECT_NAME`, `API_KEY`, `DB` and other base bindings are injected on every deploy, independent of UniwebPay.\n- All projects owned by the same PinMe user share one wallet, one `sk_server_`, and one `whsec_`.\n- PinMe never gives the full wallet secret (`sk_live_`) to a Worker. Do not ask the user for it, and do not put it in code, `wrangler.toml`, `.dev.vars`, responses, logs, D1, or frontend bundles.\n- If `UNIWEB_SECRET` is missing at runtime, the user has not enabled UniwebPay or has not redeployed — tell the user to enable it and redeploy; never fabricate a value.\n\n## SDK Client\n\nAlways instantiate on the server side (the Worker); the SDK throws when run in a browser:\n\n```typescript\nimport Uniweb from \"@uniwebpay/sdk\";\n\nfunction uniwebClient(env: Env): Uniweb {\n  return new Uniweb(env.UNIWEB_SECRET, {\n    baseUrl: env.UNIWEB_API_URL,\n    payUrl: env.UNIWEB_PAY_URL,\n  });\n}\n```\n\n- The constructor's first positional argument is the key (must have an `sk_server_` or `sk_live_` prefix); the second is optional options: `{ baseUrl?, payUrl?, timeout? (default 30s), maxRetries? (default 2) }`.\n- The SDK auto-retries only GET/DELETE on 429/5xx; POST/PATCH are never retried (avoids duplicate charges).\n- Install `@uniwebpay/sdk` only when Worker code imports it; pick the package manager from the project's existing lockfile.\n\n## Choosing an Integration Path\n\n| Scenario | Approach | Returns |\n|------|------|------|\n| Fixed-amount one-time collection | `uniweb.links.create(...)` | Permanent, reusable `/p/` link (one-time payments only) |\n| Stable product catalog | `products.create` + `prices.create` once, store the `priceId` | Price carries a permanent `paymentUrl` (`/buy/` link) |\n| Dynamic cart/order | Reuse or create a price, then `uniweb.checkout.create(...)` | `session.url` — **one-time, expires in 24 hours** |\n| Subscriptions | Recurring price + `checkout.create({ mode: \"subscription\" })` or `subscriptions.create` | Same as above |\n| Server-side payment status checks | `payments.get / list` | Server routes only |\n\nAmounts are always **integer minor units** (cents). Default currency convention is `SGD` unless the app has a stronger existing convention. Do not create a new product/price on every page view — create stable catalog items once a","createdAt":"2026-09-25T11:51:56.752Z","updatedAt":"2026-09-25T11:51:56.752Z"},{"id":"cmugwhsgt01h5qu061uk1kqqe","slug":"glitternetwork-pinme-pinme","name":"pinme","description":"Use this skill when the user mentions \"pinme\", or needs to upload files, store to IPFS, create/publish/deploy websites or full-stack services (including frontend pages, backend APIs, database storage, email sending, etc.), or any feature requiring backend database/server support.","authorId":"gh:glitternetwork","authorName":"glitternetwork","version":"0.1.0","category":"Prompt","securityLevel":"Sandbox","downloadsCount":0,"githubStars":3745,"pricePerCall":0,"manifest":{"name":"pinme","tools":[],"category":"Prompt","entrypoint":{"type":"prompt"},"description":"Use this skill when the user mentions \"pinme\", or needs to upload files, store to IPFS, create/publish/deploy websites or full-stack services (including frontend pages, backend APIs, database storage, email sending, etc.), or any feature requiring backend database/server support.","permissions":[],"systemPrompt":"# PinMe\n\nZero-config deployment tool: upload static files to IPFS, or create and deploy full-stack web projects (React+Vite + Cloudflare Worker + D1 database). Workers also support sending emails via the PinMe platform API.\n\n## When to Use\n\n```dot\ndigraph pinme_decision {\n    \"User Request\" [shape=doublecircle];\n    \"Needs backend API or database?\" [shape=diamond];\n    \"Upload Files (Path 1)\" [shape=box];\n    \"Full-Stack Project (Path 2)\" [shape=box];\n\n    \"User Request\" -> \"Needs backend API or database?\";\n    \"Needs backend API or database?\" -> \"Upload Files (Path 1)\" [label=\"No\"];\n    \"Needs backend API or database?\" -> \"Full-Stack Project (Path 2)\" [label=\"Yes\"];\n}\n```\n\n## Path 1: Upload Files / Static Sites\n\n> Login required. Use `pinme login` or `pinme set-appkey <AppKey>` before `pinme upload` or `pinme import`.\n\n```dot\ndigraph upload_flow {\n    \"Install/update pinme to latest\" [shape=box];\n    \"Authenticate\" [shape=box];\n    \"Determine build artifacts\" [shape=box];\n    \"pinme upload <path>\" [shape=box];\n    \"Return preview URL\" [shape=doublecircle];\n\n    \"Install/update pinme to latest\" -> \"Authenticate\";\n    \"Authenticate\" -> \"Determine build artifacts\";\n    \"Determine build artifacts\" -> \"pinme upload <path>\";\n    \"pinme upload <path>\" -> \"Return preview URL\";\n}\n```\n\n**1. Check installation and update to latest:**\n```bash\nLOCAL=$(pinme --version 2>/dev/null || echo \"0.0.0\")\nLATEST=$(npm view pinme version)\n[ \"$LOCAL\" != \"$LATEST\" ] && npm install -g pinme@latest || echo \"pinme is up to date ($LOCAL)\"\n```\n\n**2. Authenticate:**\n```bash\npinme login\n# or: pinme set-appkey <AppKey>\n```\n\n**3. Determine upload target** (priority order):\n1. `dist/` — Vite / Vue / React\n2. `build/` — Create React App\n3. `out/` — Next.js static export\n4. `public/` — Plain static files\n\n**4. Upload:**\n```bash\npinme upload <path>\npinme upload ./dist --domain my-site  # Optional: bind subdomain (wallet balance required)\n```\n\n**5. Return** the final URL printed by PinMe to the user. URL priority is: DNS domain > PinMe subdomain > short URL > preview URL. If it falls back to preview, return the **full URL** including all hash characters — do not truncate.\n\n### Common Examples\n\n```bash\npinme upload ./document.pdf          # Single file\npinme upload ./my-folder             # Folder\npinme upload dist                    # Vite/Vue build artifacts\npinme upload build                   # CRA build artifacts\npinme upload out                     # Next.js static export\npinme upload ./dist --domain my-site # Bind PinMe subdomain (wallet balance required)\npinme import ./my-archive.car        # Import CAR file\n```\n\n### Do NOT Upload\n- `node_modules/`, `.env`, `.git/`, `src/`\n- Only upload build artifacts, never upload source code\n\n---\n\n## Path 2: Full-Stack Project\n\n> Login required. Uses React+Vite frontend + Cloudflare Worker backend + D1 SQLite database.\n> When designing frontend projects, use Ant Design as the primary design reference, and prioritize following its conventions for layout, components, spacing, and interaction patterns.\n\n```dot\ndigraph fullstack_flow {\n    \"Install/update pinme to latest\" [shape=box];\n    \"pinme login\" [shape=box];\n    \"pinme create <name>\" [shape=box];\n    \"Modify template code\" [shape=box];\n    \"pinme save\" [shape=box];\n    \"Return preview URL\" [shape=doublecircle];\n\n    \"Install/update pinme to latest\" -> \"pinme login\";\n    \"pinme login\" -> \"pinme create <name>\";\n    \"pinme create <name>\" -> \"Modify template code\";\n    \"Modify template code\" -> \"pinme save\";\n    \"pinme save\" -> \"Return preview URL\";\n}\n```\n\n### Architecture\n\n| Layer | Tech Stack | Deploy Target |\n|-------|-----------|---------------|\n| Frontend | React + Vite (`frontend/`) | IPFS |\n| Backend | Cloudflare Worker (`backend/src/worker.ts`) | `{name}.pinme.pro` |\n| Database | D1 SQLite (`db/*.sql`) | Cloudflare D1 |\n| Object storage | R2 (`env.R2`) | Cloudflare R2 |\n\n### Capability-Specific Skills\n\n- For Worker file uploads, downloads, images, attachments, media, or object storage, use the `pinme-r2` skill. PinMe injects the project bucket as `env.R2`; do not replace it with D1 BLOBs or Worker filesystem state.\n\n### Core Commands\n\n```bash\npinme login                  # Login (only needed once)\npinme create <dirName>       # Clone template and create project (auto-fills API URL)\npinme save                   # First deploy / full update (frontend + backend + database, single command)\npinme update-worker          # Update backend only (when only backend/src/worker.ts was modified)\npinme update-web             # Update frontend only (when only frontend/src/ was modified)\npinme update-db              # Run SQL migrations only (when only db/ was modified)\n```\n\n> `pinme save` deploys frontend + backend + database all at once. Only use `pinme update-*` when you're certain only one part was modified.\n\n### Project Structure\n\n```\n{project}/\n├── pinme.toml              # Root config (auto-generated, do not modify)\n├── package.json            # Monorepo root (workspaces: frontend + backend)\n├── backend/\n│   ├── wrangler.toml       # Worker config (auto-generated, do not modify)\n│   ├── package.json\n│   └── src/\n│       └── worker.ts       # Backend entry — primarily used for JSON APIs in this template\n├── db/\n│   └── 001_init.sql        # SQL table definitions\n├── frontend/\n│   ├── package.json\n│   ├── vite.config.ts      # Dev proxy: /api → localhost:8787\n│   ├── index.html\n│   ├── .env                # Auto-generated: VITE_API_URL (do not modify)\n│   └── src/\n│       ├── main.tsx\n│       ├── App.tsx\n│       ├── utils/\n│       │   ├── api.ts      # export const API = import.meta.env.VITE_WORKER_URL || ''\n│       │   └── config.ts   # Auto-generated: public_client_config (only when auth is enabled)\n│       └── pages/\n│           └── Home/\n│               └── index.tsx\n└── .gitignore\n```\n\n### First Deployment\n\n```bash\nLOCAL=$(pinme --version 2>/dev/null || echo \"0.0.0\")\nLATEST=$(npm view pinme version)\n[ \"$LOCAL\" != \"$LATEST\" ] && npm install -g pinme@latest\npinme login\npinme create my-app\ncd my-app\n```\n\n`pinme create` generates a working Hello World template (includes frontend page + backend API routes + database schema). **Modify the template** to match the user's business logic — do not write from scratch:\n\n- Modify `backend/src/worker.ts` — replace API routes\n- Modify `frontend/src/pages/` — replace page components\n- Modify `db/001_init.sql` — replace table definitions\n\n```bash\npinme save\n# Single command deploys frontend + backend + database\n# Outputs preview URL: https://pinme.eth.limo/#/preview/{CID}\n```\n\n**Return** the preview URL to the user. Note: return the **full URL** including all hash characters — do not truncate.\n\nThe backend Worker is deployed at `https://{name}.pinme.pro`. Frontend API requests are automatically configured to point to that address — no manual setup needed.\n\n### Subsequent Updates\n\n| Changes | Command | Notes |\n|---------|---------|-------|\n| Backend only (`backend/src/worker.ts`) | `pinme update-worker` | Faster |\n| Frontend only (`frontend/src/`) | `pinme update-web` | Generates new CID |\n| Database only (`db/`) | `pinme update-db` | Runs new migrations |\n| Multiple changes or uncertain | `pinme save` | Safe full deployment |\n\n> Each frontend deployment generates a new CID and preview URL. Old URLs remain accessible.\n\n---\n\n## Worker Code Patterns (`backend/src/worker.ts`)\n\nIn this template, the Worker backend is primarily used for JSON APIs. Prefer standard Web APIs and simple manual routing by default. Worker-compatible libraries can be added when needed, but the default template does not rely on extra frameworks. Avoid packages that depend on a full Node.js runtime, a persistent local filesystem, native binaries, or child processes.\n\n```typescript\nexport interface Env {\n  DB: D1Database;           // When using database\n  R2: R2Bucket;             // Project object storage; injected by PinMe\n  API_KEY?: string;         // When using email sending\n  JWT_SECRET: string;       // When using JWT auth\n  ADMIN_PASSWORD: string;   // When using password auth\n}\n\nconst CORS_HEADERS = {\n  'Access-Control-Allow-Origin': '*',\n  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',\n  'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',\n};\n\nfunction json(data: unknown, status = 200): Response {\n  return Response.json(data, { status, headers: CORS_HEADERS });\n}\n\nexport default {\n  async fetch(request: Request, env: Env): Promise<Response> {\n    const { pathname } = new URL(request.url);\n    const method = request.method;\n\n    if (method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS_HEADERS });\n\n    try {\n      if (pathname === '/api/items' && method === 'GET')  return handleGetItems(env);\n      if (pathname === '/api/items' && method === 'POST') return handleCreateItem(request, env);\n      return json({ error: 'Not found' }, 404);\n    } catch {\n      return json({ error: 'Internal server error' }, 500);\n    }\n  },\n};\n```\n\n### Worker Constraints and Default Conventions\n\n| Item | Notes |\n|------|------|\n| Dependency choice | Prefer standard Web APIs and simple manual routing by default. If extra dependencies are needed, prefer Worker-compatible libraries. |\n| Node.js capability | Workers now support part of Node.js compatibility, but they are not a full Node.js runtime. Do not assume all Node.js built-in modules are available or behave exactly the same. |\n| Filesystem | Do not treat a Worker like a server with a persistent local disk. Even if some `fs` capabilities are available, do not rely on persistence across requests. |\n| Response types | This template mainly uses the Worker for JSON APIs. If there is a clear need, it can also be adapted to return HTML or other content. |\n| Password storage | Never store passwords in plaintext. Use a dedicated password hashing algorithm such as bcrypt, scrypt, or Argon2. |\n| SQL | Do not build SQL by string concatenation. Use parameterized queries such as `.bind()`. |\n\n### Email API Reference (for Worker Backend)\n\nWhen the backend needs email sending, use the PinMe platform API (`https://pinme.cloud/api/v4/send_email`).\n\n**1. Configure API_KEY**\n\nAdd to the `Env` interface:\n\n```typescript\nexport interface Env {\n  DB: D1Database;\n  API_KEY?: string;  // Required for email sending\n}\n```\n\n**2. Email Handler Code**\n\n```typescript\nasync function handleSendEmail(request: Request, env: Env): Promise<Response> {\n  const apiKey = env.API_KEY;\n  if (!apiKey) {\n    return json({ error: 'API_KEY not configured' }, 500);\n  }\n\n  const body = await request.json() as {\n    to?: string;\n    subject?: string;\n    html?: string;\n  };\n\n  if (!body.to) return json({ error: 'Email address is required' }, 400);\n  if (!body.subject) return json({ error: 'Subject is required' }, 400);\n  if (!body.html) return json({ error: 'HTML content is required' }, 400);\n\n  const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n  if (!emailRegex.test(body.to)) {\n    return json({ error: 'Invalid email address' }, 400);\n  }\n\n  const response = await fetch('https://pinme.cloud/api/v4/send_email', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': apiKey,\n    },\n    body: JSON.stringify({\n      to: body.to,\n      subject: body.subject,\n      html: body.html,\n    }),\n  });\n\n  const result = await response.json();\n  return json(result);\n}\n```\n\n## Frontend API Utility (frontend/src/utils/api.ts)\n\n```typescript\n// Development: Vite proxies /api to localhost:8787\n// Production: VITE_API_URL is auto-injected by pinme create\nexport const API = import.meta.env.VITE_API_URL || '';\n\nexport function getApiUrl(path: string): string {\n  return API ? `${API}${path}` : path;\n}\n```\n\n## D1 Database Operations\n\n```typescript\n// Query multiple rows\nconst { results } = await env.DB.prepare('SELECT * FROM t WHERE x = ?').bind(val).all();\n\n// Query single row (returns null if not found)\nconst row = await env.DB.prepare('SELECT * FROM t WHERE id = ?').bind(id).first();\n\n// Insert and return new row\nconst row = await env.DB.prepare('INSERT INTO t (a, b) VALUES (?, ?) RETURNING *').bind(a, b).first();\n\n// Update\nawait env.DB.prepare('UPDATE t SET a = ? WHERE id = ?').bind(val, id).run();\n\n// Delete (check if affected)\nconst { meta } = await env.DB.prepare('DELETE FROM t WHERE id = ?').bind(id).run();\nif (meta.changes === 0) return json({ error: 'Not found' }, 404);\n```\n\n### SQL Migration Files\n\n**Format:** `db/NNN_description.sql` (for example, `001_init.sql`). Files are executed in filename order.\n\n**SQLite Type Constraints:**\n\n| Do Not Use | Alternative |\n|-----------|-------------|\n| `BOOLEAN` | `INTEGER` (0 = false, 1 = true) |\n| `DATETIME` / `TIMESTAMP` | `TEXT`, stored as ISO 8601 (default: `datetime('now')`) |\n| `JSON` type | `TEXT`, using `JSON.stringify()` / `JSON.parse()` |\n| `VARCHAR(n)` | `TEXT` |\n\n## Template Architecture Suggestions\n\n| Scenario | Default Suggestion |\n|-----------|-------------|\n| File storage (images, attachments, media) | Use the project R2 binding through `env.R2`; use the `pinme-r2` skill for secure routes and metadata patterns |\n| Real-time communication | This template defaults to regular HTTP APIs. If there is no clear real-time requirement, start with polling |\n| Multiple Workers | This template defaults to combining functionality into a single Worker and separating routes by prefix |\n| Multiple databases | This template defaults to combining data into one D1 database and only splitting when isolation is truly needed |\n\n## Important Notes\n\n- `pinme.toml`, `backend/wrangler.toml`, and `frontend/.env` are generated by PinMe. Do not edit them manually by default. If extra runtime configuration is truly needed, prefer doing it through PinMe-supported mechanisms.\n- Obtain the frontend API URL from the `VITE_API_URL` environment variable. Do not hardcode it.\n- Passwords, tokens, and API keys must be stored in secrets. Never put them in config files.\n\n## Common Errors\n\n| Error | Solution |\n|-------|----------|\n| `command not found: pinme` | `npm install -g pinme` |\n| `No such file or directory` | Verify that the path exists |\n| `Permission denied` | Check file or directory permissions |\n| Upload failed | Check the network connection and retry |\n| Not logged in | Run `pinme login` first |\n\n## Other Commands\n\n```bash\npinme list / pinme ls -l 5     # View upload history\npinme list -c                  # Clear upload history\npinme rm <hash>                # Delete uploaded content\npinme bind <path> --domain <domain>  # Bind domain (VIP + AppKey required)\npinme export <CID>             # Export as CAR file\npinme set-appkey               # Set/view AppKey\npinme my-domains               # List bound domains\npinme delete <project>          # Delete project (Worker + domain + D1)\npinme logout                   # Log out\n```","schemaVersion":1},"repoUrl":"https://github.com/glitternetwork/pinme/tree/main/skills/pinme","tags":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy"],"stats":{"installVelocity7d":0,"retentionRate":0,"executions":0,"rating":null},"origin":"github","source":{"repo":"pinme","audit":{"files":["package-lock.json","package.json","pnpm-lock.yaml"],"binaries":[],"findings":[{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Uncontrolled memory allocation via the declared uncompressed size (DoS).","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-7q85-xj36-vmfc · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip extraction follows destination symlinks, allowing arbitrary file overwrite.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-vwc7-r8mq-g2x9 · npm:adm-zip@0.5.17","severity":"medium"},{"kind":"dependency","rule":"DP-01","message":"adm-zip@0.5.17 has a known vulnerability: adm-zip: Crafted ZIP file triggers 4GB memory allocation.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-xcpc-8h2w-3j85 · npm:adm-zip@0.5.17","severity":"high"},{"kind":"dependency","rule":"DP-01","message":"uuid@9.0.1 has a known vulnerability: uuid: Missing buffer bounds check in v3/v5/v6 when buf is provided.","surface":"package-lock.json, package.json, pnpm-lock.yaml","evidence":"GHSA-w5hq-g745-h8pq · npm:uuid@9.0.1","severity":"medium"}],"packages":16,"auditedAt":"2026-09-25T11:51:56.672Z","lockfiles":["package-lock.json","pnpm-lock.yaml"]},"forks":277,"owner":"glitternetwork","stars":3745,"topics":["ai-tools","claude-code-skill","claude-skills","deployment","deployment-tools","frontend","frontend-deployment","hosting","serverless","skills","static-site","static-site-deploy","static-site-hosting","web-hosting","zero-configuration"],"license":"MIT","fullName":"glitternetwork/pinme","homepage":"https://pinme.eth.limo","language":"TypeScript","pushedAt":"2026-09-12T05:23:49Z","avatarUrl":"https://avatars.githubusercontent.com/u/102277171?v=4","crawledAt":"2026-09-25T11:51:44.477Z","openIssues":7,"manifestFile":"SKILL.md","manifestPath":"skills/pinme/SKILL.md","defaultBranch":"main"},"readme":"# PinMe\n\nZero-config deployment tool: upload static files to IPFS, or create and deploy full-stack web projects (React+Vite + Cloudflare Worker + D1 database). Workers also support sending emails via the PinMe platform API.\n\n## When to Use\n\n```dot\ndigraph pinme_decision {\n    \"User Request\" [shape=doublecircle];\n    \"Needs backend API or database?\" [shape=diamond];\n    \"Upload Files (Path 1)\" [shape=box];\n    \"Full-Stack Project (Path 2)\" [shape=box];\n\n    \"User Request\" -> \"Needs backend API or database?\";\n    \"Needs backend API or database?\" -> \"Upload Files (Path 1)\" [label=\"No\"];\n    \"Needs backend API or database?\" -> \"Full-Stack Project (Path 2)\" [label=\"Yes\"];\n}\n```\n\n## Path 1: Upload Files / Static Sites\n\n> Login required. Use `pinme login` or `pinme set-appkey <AppKey>` before `pinme upload` or `pinme import`.\n\n```dot\ndigraph upload_flow {\n    \"Install/update pinme to latest\" [shape=box];\n    \"Authenticate\" [shape=box];\n    \"Determine build artifacts\" [shape=box];\n    \"pinme upload <path>\" [shape=box];\n    \"Return preview URL\" [shape=doublecircle];\n\n    \"Install/update pinme to latest\" -> \"Authenticate\";\n    \"Authenticate\" -> \"Determine build artifacts\";\n    \"Determine build artifacts\" -> \"pinme upload <path>\";\n    \"pinme upload <path>\" -> \"Return preview URL\";\n}\n```\n\n**1. Check installation and update to latest:**\n```bash\nLOCAL=$(pinme --version 2>/dev/null || echo \"0.0.0\")\nLATEST=$(npm view pinme version)\n[ \"$LOCAL\" != \"$LATEST\" ] && npm install -g pinme@latest || echo \"pinme is up to date ($LOCAL)\"\n```\n\n**2. Authenticate:**\n```bash\npinme login\n# or: pinme set-appkey <AppKey>\n```\n\n**3. Determine upload target** (priority order):\n1. `dist/` — Vite / Vue / React\n2. `build/` — Create React App\n3. `out/` — Next.js static export\n4. `public/` — Plain static files\n\n**4. Upload:**\n```bash\npinme upload <path>\npinme upload ./dist --domain my-site  # Optional: bind subdomain (wallet balance required)\n```\n\n**5. Return** the final URL printed by PinMe to the user. URL priority is: DNS domain > PinMe subdomain > short URL > preview URL. If it falls back to preview, return the **full URL** including all hash characters — do not truncate.\n\n### Common Examples\n\n```bash\npinme upload ./document.pdf          # Single file\npinme upload ./my-folder             # Folder\npinme upload dist                    # Vite/Vue build artifacts\npinme upload build                   # CRA build artifacts\npinme upload out                     # Next.js static export\npinme upload ./dist --domain my-site # Bind PinMe subdomain (wallet balance required)\npinme import ./my-archive.car        # Import CAR file\n```\n\n### Do NOT Upload\n- `node_modules/`, `.env`, `.git/`, `src/`\n- Only upload build artifacts, never upload source code\n\n---\n\n## Path 2: Full-Stack Project\n\n> Login required. Uses React+Vite frontend + Cloudflare Worker backend + D1 SQLite database.\n> When designing frontend projects, use Ant Design as the primary design reference, and prioritize following its conventions for layout, components, spacing, and interaction patterns.\n\n```dot\ndigraph fullstack_flow {\n    \"Install/update pinme to latest\" [shape=box];\n    \"pinme login\" [shape=box];\n    \"pinme create <name>\" [shape=box];\n    \"Modify template code\" [shape=box];\n    \"pinme save\" [shape=box];\n    \"Return preview URL\" [shape=doublecircle];\n\n    \"Install/update pinme to latest\" -> \"pinme login\";\n    \"pinme login\" -> \"pinme create <name>\";\n    \"pinme create <name>\" -> \"Modify template code\";\n    \"Modify template code\" -> \"pinme save\";\n    \"pinme save\" -> \"Return preview URL\";\n}\n```\n\n### Architecture\n\n| Layer | Tech Stack | Deploy Target |\n|-------|-----------|---------------|\n| Frontend | React + Vite (`frontend/`) | IPFS |\n| Backend | Cloudflare Worker (`backend/src/worker.ts`) | `{name}.pinme.pro` |\n| Database | D1 SQLite (`db/*.sql`) | Cloudflare D1 |\n| Object storage | R2 (`env.R2`) | Cloudflare R2 |\n\n### Capability-Specific Skills\n\n- For Worker file uploads, downloads, images, attachments, medi","createdAt":"2026-09-25T11:51:56.766Z","updatedAt":"2026-09-25T11:51:56.766Z"}],"total":7,"limit":24,"offset":0}