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
250 changes: 250 additions & 0 deletions docs/BEARER_TOKEN_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
# API and CLI Integration - Bearer Token Auth

## Summary of Changes

Successfully integrated Bearer token authentication between the HPL CLI and API, enabling the CLI's write operations to work with the admin endpoints.

## Changes Made

### API Changes (lab-api)

**File: `src/routes/adminRoutes.ts`**

1. **Added import** for `requireAuth` middleware:
```typescript
import { requireAuth } from "../middleware/requireAuth.js";
```

2. **Updated POST /admin/notes endpoint** to accept Bearer tokens:
```typescript
// Changed from:
app.post("/admin/notes", requireAdmin, ...)

// To:
app.post("/admin/notes", requireAuth(db), ...)
```

**Why this works:**
- `requireAuth` middleware supports BOTH session auth (humans in browser) AND Bearer token auth (CLI)
- Checks session first, then falls back to Bearer token
- Returns 401 if neither auth method succeeds
- No breaking changes - browser users still work with sessions

### CLI Changes (the-human-pattern-lab-cli)

**File: `src/types/labNotes.ts`**

Changed field name in `LabNoteUpsertSchema` to match API:
```typescript
// Changed from:
markdown: z.string().min(1),

// To:
content_markdown: z.string().min(1),
```

**File: `src/commands/notes/create.ts`**

1. Changed payload field name:
```typescript
content_markdown: markdown // was: markdown: markdown
```

2. Changed endpoint:
```typescript
"/admin/notes" // was: "/lab-notes/upsert"
```

**File: `src/commands/notes/update.ts`**

Same changes as create.ts (field name and endpoint).

**File: `src/commands/notes/notesSync.ts`**

Same changes (field name and endpoint) to keep sync working.

## How Authentication Works Now

### For CLI (Bearer Token)
```bash
export HPL_TOKEN="hpl_test_xxxxx"
hpl notes create --title "Test" --slug "test" --file note.md
```

Request flow:
1. CLI sends: `Authorization: Bearer hpl_test_xxxxx`
2. API `requireAuth` middleware:
- Checks session (not present)
- Checks Bearer token (present!)
- Validates token hash in database
- Checks token is active and not expired
- Sets `req.auth = { kind: "token", ... }`
3. Route handler executes

### For Browser (Session)
User logs in via GitHub OAuth → Session cookie is set → Works as before

Both methods work on the same endpoint!

## Testing the Integration

### 1. Generate an API Token

First, you need a valid Bearer token. This requires session auth to create:

```bash
# Via API (need to be logged in with session):
curl -X POST http://127.0.0.1:8001/admin/tokens \
-H "Cookie: hpl.sid=..." \
-H "Content-Type: application/json" \
-d '{
"label": "CLI Testing",
"scopes": ["admin"],
"expires_at": null
}'
```

Or use the browser UI to mint a token.

### 2. Test CLI Create

```bash
# Set token
export HPL_TOKEN="hpl_test_YOUR_TOKEN_HERE"

# Override API base to local
export HPL_BASE_URL="http://127.0.0.1:8001"

# Create a test note
echo "# Test Note" > test.md
npm run dev -- notes create \
--title "Test Note" \
--slug "cli-test-$(date +%s)" \
--file test.md \
--status draft
```

Expected: Success! Note created.

### 3. Test CLI Update

```bash
npm run dev -- notes update cli-test-123456789 \
--title "Updated Title" \
--markdown "# Updated content"
```

### 4. Test JSON Output

```bash
npm run dev -- notes create \
--title "JSON Test" \
--slug "json-test" \
--markdown "# Content" \
--json
```

Should return valid JSON envelope.

## Token Management

### Creating Tokens

Tokens are created via `/admin/tokens` POST endpoint (requires session auth):

```json
{
"label": "My CLI Token",
"scopes": ["admin"],
"expires_at": null
}
```

Returns:
```json
{
"ok": true,
"data": {
"token": "hpl_test_xxxxx", // Raw token (shown once!)
"id": "uuid"
}
}
```

### Token Security

- Raw tokens are never stored (only SHA-256 hash + pepper)
- Tokens include prefix: `hpl_test_` (dev) or `hpl_live_` (prod)
- Configurable expiration
- Can be revoked via `/admin/tokens/:id/revoke`
- Scoped permissions (currently just "admin")

### Token Storage

**In API database** (`api_tokens` table):
- id
- label
- token_hash (SHA-256 of pepper:token)
- scopes_json
- is_active
- expires_at
- created_by_user
- last_used_at
- created_at

**In CLI config** (`~/.humanpatternlab/hpl.json`):
```json
{
"apiBaseUrl": "https://api.thehumanpatternlab.com",
"token": "hpl_test_xxxxx"
}
```

Or via environment variable:
```bash
export HPL_TOKEN="hpl_test_xxxxx"
```

## Error Handling

### 401 Unauthorized
- No token provided
- Invalid token (not in database)
- Expired token
- Inactive token

### 403 Forbidden
- Token lacks required scopes (future feature)

### 400 Bad Request
- Missing required fields (title, slug)
- Invalid data

## Benefits of This Approach

✅ **No Breaking Changes**: Browser users still use sessions
✅ **Secure**: Tokens are hashed, can be revoked, can expire
✅ **Flexible**: Both auth methods work on same endpoint
✅ **Clean**: No duplicate endpoints needed
✅ **Auditable**: Tokens track `created_by_user` and `last_used_at`

## Next Steps

1. Test the integration end-to-end
2. Create a token via the browser UI or API
3. Test CLI create/update operations
4. Verify sync still works
5. Consider adding token management commands to CLI (`hpl tokens list`, `hpl tokens create`, etc.)

## Files Modified

### API (1 file)
- `src/routes/adminRoutes.ts` - Updated to use `requireAuth`

### CLI (4 files)
- `src/types/labNotes.ts` - Changed `markdown` to `content_markdown`
- `src/commands/notes/create.ts` - Updated endpoint and field name
- `src/commands/notes/update.ts` - Updated endpoint and field name
- `src/commands/notes/notesSync.ts` - Updated endpoint and field name

Total: 5 files modified! 🦊
2 changes: 1 addition & 1 deletion jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default {
setupFiles: ["<rootDir>/tests/jest.setup.ts"],

moduleNameMapper: {
"^@/(.*)$": path.join(__dirname, "src/$1"),
"^@/(.*)$": "<rootDir>/src/$1",
"^(\\.{1,2}/.*)\\.js$": "$1",
},
};
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "the-human-pattern-lab-api",
"version": "0.2.0",
"version": "0.8.0",
"type": "module",
"private": true,
"description": "API backend for The Human Pattern Lab",
Expand Down
90 changes: 90 additions & 0 deletions scripts/create-test-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env tsx
/**
* Quick utility to create a test Bearer token
*
* Requirements:
* - API must be running on http://127.0.0.1:8001
* - ADMIN_DEV_BYPASS=1 must be set (dev mode)
*
* Usage:
* npx tsx scripts/create-test-token.ts
*
* This will:
* 1. Create a token via POST /admin/tokens
* 2. Print the raw token (only shown once!)
* 3. Show you how to use it with the CLI
*/

const API_BASE = "http://127.0.0.1:8001";

async function createTestToken() {
console.log("🔑 Creating test Bearer token...\n");

// In dev mode with ADMIN_DEV_BYPASS=1, we can call admin endpoints
// The server will treat us as authenticated
const response = await fetch(`${API_BASE}/admin/tokens`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
label: `CLI Test Token (${new Date().toISOString()})`,
scopes: ["admin"],
expires_at: null, // Never expires
}),
});

if (!response.ok) {
const text = await response.text();
console.error("❌ Failed to create token:");
console.error(`Status: ${response.status}`);
console.error(`Response: ${text}`);
console.error("\n💡 Make sure:");
console.error(" 1. API is running on http://127.0.0.1:8001");
console.error(" 2. ADMIN_DEV_BYPASS=1 is set in your .env");
console.error(" 3. You've restarted the API after changing .env");
process.exit(1);
}

const result = await response.json();

if (!result.ok || !result.data?.token) {
console.error("❌ Unexpected response:", result);
process.exit(1);
}

const token = result.data.token;
const tokenId = result.data.id;

console.log("✅ Token created successfully!\n");
console.log("📋 Token Details:");
console.log(` ID: ${tokenId}`);
console.log(` Label: CLI Test Token`);
console.log(` Scopes: admin`);
console.log(` Expires: Never\n`);

console.log("🔐 Your Bearer Token (save this!):");
console.log(` ${token}\n`);

console.log("🚀 How to use with CLI:\n");
console.log(" # Export as environment variable:");
console.log(` export HPL_TOKEN="${token}"`);
console.log(` export HPL_BASE_URL="${API_BASE}"\n`);

console.log(" # Or add to ~/.humanpatternlab/hpl.json:");
console.log(` {`);
console.log(` "apiBaseUrl": "${API_BASE}",`);
console.log(` "token": "${token}"`);
console.log(` }\n`);

console.log(" # Test it:");
console.log(` cd ../the-human-pattern-lab-cli`);
console.log(` npm run dev -- notes create --title "Test" --slug "test-$(date +%s)" --markdown "# Hello!"\n`);

console.log("✨ Ready to test! 🦊");
}

createTestToken().catch((err) => {
console.error("❌ Error:", err.message);
process.exit(1);
});
11 changes: 9 additions & 2 deletions src/middleware/requireAuth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Request, Response, NextFunction } from "express";
import type Database from "better-sqlite3";
import { verifyApiToken } from "@/auth/tokens.js";
import { getGithubLogin } from "@/auth.js";
import { verifyApiToken } from "../auth/tokens.js";
import { getGithubLogin } from "../auth.js";

export type AuthContext =
| { kind: "session"; login: string; scopes: string[] }
Expand All @@ -24,6 +24,13 @@ function getAuthToken(req: Request): string | null {

export function requireAuth(db: Database.Database) {
return (req: Request, res: Response, next: NextFunction) => {
// 0) Dev bypass (for testing)
const devBypass = process.env.ADMIN_DEV_BYPASS === "1" || process.env.ADMIN_DEV_BYPASS === "true";
if (devBypass && process.env.NODE_ENV !== "production") {
req.auth = { kind: "session", login: "dev-bypass", scopes: ["admin"] };
return next();
}

// 1) Human session auth
if (req.isAuthenticated?.() && (req as any).user) {
const login = getGithubLogin((req as any).user);
Expand Down
Loading