Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: CI

on:
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm typecheck

- name: Run API tests
run: pnpm --filter claw-fm-api test

- name: Build web
run: pnpm build:web

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Lint API
run: pnpm --filter claw-fm-api exec tsc --noEmit

- name: Lint Web
run: pnpm --filter claw-fm-web exec tsc --noEmit
5 changes: 4 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"description": "Cloudflare Workers API for Claw FM",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
"deploy": "wrangler deploy",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@claw/shared": "workspace:*",
Expand All @@ -19,6 +21,7 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20250117.0",
"typescript": "^5.7.2",
"vitest": "^3.0.0",
"wrangler": "^3.100.0"
}
}
2 changes: 2 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import claimRoute from './routes/claim'
import statsRoute from './routes/stats'
import discoveryRoute from './routes/discovery'
import royaltiesRoute from './routes/royalties'
import deleteRoute from './routes/delete'

type Bindings = {
DB: D1Database
Expand Down Expand Up @@ -60,6 +61,7 @@ app.route('/api/claim', claimRoute)
app.route('/api/stats', statsRoute)
app.route('/api/royalties', royaltiesRoute)
app.route('/api', discoveryRoute) // Mounts /api/tracks/rising, /api/tracks/recent, /api/artists/verified
app.route('/api/tracks', deleteRoute) // DELETE /api/tracks/:id

// Record a play for a track (called by client on override/direct plays)
app.post('/api/tracks/:id/play', async (c) => {
Expand Down
120 changes: 120 additions & 0 deletions api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { Context, Next } from 'hono'
import { extractWalletFromPaymentHeader } from './x402'

/**
* Middleware to extract and verify wallet authentication from x402 payment header.
*
* This middleware extracts the wallet address from the X-PAYMENT or PAYMENT-SIGNATURE
* header WITHOUT settling any payment. It's used for authentication only.
*
* The wallet address is stored in c.get('walletAddress') for downstream handlers.
*
* Usage:
* app.use('/api/protected/*', requireWalletAuth)
* app.get('/api/protected/resource', (c) => {
* const wallet = c.get('walletAddress')
* // wallet is guaranteed to exist here
* })
*
* Or for optional auth (endpoint works with or without wallet):
* app.use('/api/optional/*', extractWalletOptional)
*/

// Extend Hono context types
declare module 'hono' {
interface ContextVariableMap {
walletAddress: string
}
}

/**
* Require wallet authentication.
* Returns 401 if no valid x402 payment header provided.
*/
export async function requireWalletAuth(c: Context, next: Next) {
const result = await extractWalletFromPaymentHeader(c)

if (!result.valid || !result.walletAddress) {
return result.error || c.json({
error: 'UNAUTHORIZED',
message: 'Valid x402 payment header required'
}, 401)
}

c.set('walletAddress', result.walletAddress)
await next()
}

/**
* Extract wallet optionally.
* Sets walletAddress if provided, continues either way.
* Use for endpoints that work differently for authenticated vs anonymous users.
*/
export async function extractWalletOptional(c: Context, next: Next) {
const result = await extractWalletFromPaymentHeader(c)

if (result.valid && result.walletAddress) {
c.set('walletAddress', result.walletAddress)
}

await next()
}

/**
* Require wallet ownership of a specific resource.
*
* This middleware checks that the authenticated wallet owns a resource
* identified by a database query. Use after requireWalletAuth.
*
* Example:
* app.delete('/api/tracks/:id',
* requireWalletAuth,
* requireOwnership({
* table: 'tracks',
* idParam: 'id',
* ownerColumn: 'wallet'
* }),
* handler
* )
*/
interface OwnershipConfig {
table: string
idParam: string
ownerColumn: string
}

export function requireOwnership(config: OwnershipConfig) {
return async (c: Context<{ Bindings: { DB: D1Database } }>, next: Next) => {
const walletAddress = c.get('walletAddress')

if (!walletAddress) {
return c.json({
error: 'UNAUTHORIZED',
message: 'Authentication required'
}, 401)
}

const resourceId = c.req.param(config.idParam)
const db = c.env.DB

const resource = await db.prepare(`
SELECT ${config.ownerColumn} as owner FROM ${config.table} WHERE id = ?
`).bind(resourceId).first<{ owner: string }>()

if (!resource) {
return c.json({
error: 'NOT_FOUND',
message: 'Resource not found'
}, 404)
}

if (resource.owner.toLowerCase() !== walletAddress.toLowerCase()) {
return c.json({
error: 'FORBIDDEN',
message: 'You do not own this resource'
}, 403)
}

await next()
}
}
Loading