feat: Sprint 2 — Wursor-side pairing flow + signed plugin client
Mirror to GitHub / mirror (push) Canceled after 0s

- session store + requireSession middleware (signup persists session)
- PairingService (8-char code, 5-min TTL, 5-attempt lockout, single-use, https check)
- POST /sites/pair, /sites/redeem, /sites/:id/confirm, GET /sites/:id
- PluginClient signs HMAC (timestamp+method+path+body-hash), token in Authorization
- ADR 0016; 58 api tests green
This commit is contained in:
SinachPat
2026-08-15 23:10:19 +01:00
parent 02f55b4543
commit 9801b9475b
15 changed files with 619 additions and 5 deletions
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { generateHmacSecret, generatePairingCode, generateToken, isHttpsUrl } from '../../src/lib/codes.ts';
describe('generatePairingCode', () => {
it('returns an 8-char A-Z0-9 code', () => {
expect(generatePairingCode()).toMatch(/^[A-Z0-9]{8}$/);
});
it('returns distinct codes across calls', () => {
const seen = new Set(Array.from({ length: 50 }, () => generatePairingCode()));
expect(seen.size).toBeGreaterThan(40);
});
});
describe('generateToken / generateHmacSecret', () => {
it('returns a 32-byte base64url token', () => {
const token = generateToken();
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(token).not.toEqual(generateToken());
});
it('returns a distinct hmac secret', () => {
expect(generateHmacSecret()).not.toEqual(generateHmacSecret());
});
});
describe('isHttpsUrl', () => {
it('accepts https URLs', () => {
expect(isHttpsUrl('https://example.com')).toBe(true);
});
it('rejects http and malformed URLs', () => {
expect(isHttpsUrl('http://example.com')).toBe(false);
expect(isHttpsUrl('not-a-url')).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../../src/app.ts';
async function signup(app: FastifyInstance): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/auth/signup',
payload: { email: `a-${Date.now()}@example.com`, password: 'password123' },
});
return res.json().sessionToken as string;
}
describe('sites pairing flow', () => {
it('requires a session to pair', async () => {
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/sites/pair' });
expect(res.statusCode).toBe(401);
});
it('redeems a code once, binds site_url, and returns tokens', async () => {
const app = await buildApp();
const token = await signup(app);
const pair = await app.inject({
method: 'POST',
url: '/sites/pair',
headers: { authorization: `Bearer ${token}` },
});
expect(pair.statusCode).toBe(201);
const { code } = pair.json();
const redeem = await app.inject({
method: 'POST',
url: '/sites/redeem',
payload: { code, siteUrl: 'https://example.com' },
});
expect(redeem.statusCode).toBe(201);
const body = redeem.json();
expect(body.siteId).toBeTruthy();
expect(body.readToken).toBeTruthy();
expect(body.deployToken).toBeTruthy();
expect(body.hmacSecret).toBeTruthy();
const second = await app.inject({
method: 'POST',
url: '/sites/redeem',
payload: { code, siteUrl: 'https://example.com' },
});
expect(second.statusCode).toBe(400);
});
it('does not mark the site connected until confirmed', async () => {
const app = await buildApp();
const token = await signup(app);
const pair = await app.inject({
method: 'POST',
url: '/sites/pair',
headers: { authorization: `Bearer ${token}` },
});
const { code } = pair.json();
const redeem = await app.inject({
method: 'POST',
url: '/sites/redeem',
payload: { code, siteUrl: 'https://example.com' },
});
const { siteId } = redeem.json();
const before = await app.inject({
method: 'GET',
url: `/sites/${siteId}`,
headers: { authorization: `Bearer ${token}` },
});
expect(before.json().connected).toBe(false);
const confirm = await app.inject({
method: 'POST',
url: `/sites/${siteId}/confirm`,
headers: { authorization: `Bearer ${token}` },
});
expect(confirm.statusCode).toBe(200);
const after = await app.inject({
method: 'GET',
url: `/sites/${siteId}`,
headers: { authorization: `Bearer ${token}` },
});
expect(after.json().connected).toBe(true);
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { PairingService } from '../../src/services/pairing-service.ts';
describe('PairingService', () => {
it('issues an 8-char code with a 5-minute expiry', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code, expiresAt } = svc.issue('acct-1');
expect(code).toMatch(/^[A-Z0-9]{8}$/);
expect(expiresAt).toBe(1_000_000 + 5 * 60 * 1000);
});
it('redeems a valid code once and returns distinct scoped tokens', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code } = svc.issue('acct-1');
const result = svc.redeem(code, 'https://example.com');
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.accountId).toBe('acct-1');
expect(result.readToken).toBeTruthy();
expect(result.deployToken).toBeTruthy();
expect(result.hmacSecret).toBeTruthy();
expect(result.readToken).not.toBe(result.deployToken);
}
});
it('rejects a second redeem of the same code', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code } = svc.issue('acct-1');
svc.redeem(code, 'https://example.com');
expect(svc.redeem(code, 'https://example.com')).toEqual({ ok: false, error: 'consumed' });
});
it('expires a code after five minutes', () => {
let t = 1_000_000;
const svc = new PairingService({ now: () => t });
const { code } = svc.issue('acct-1');
t += 5 * 60 * 1000 + 1;
expect(svc.redeem(code, 'https://example.com')).toEqual({ ok: false, error: 'expired' });
});
it('locks a code after five failed attempts', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code } = svc.issue('acct-1');
for (let i = 0; i < 5; i += 1) {
svc.redeem(code, 'http://insecure.example.com');
}
expect(svc.redeem(code, 'https://example.com')).toEqual({ ok: false, error: 'locked' });
});
it('rejects a non-https site URL as an invalid attempt', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code } = svc.issue('acct-1');
expect(svc.redeem(code, 'http://insecure.example.com')).toEqual({ ok: false, error: 'invalid' });
});
it('returns invalid for an unknown code', () => {
expect(new PairingService().redeem('NOPE0000', 'https://example.com')).toEqual({ ok: false, error: 'invalid' });
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createHash, createHmac } from 'node:crypto';
import { PluginClient } from '../../src/services/plugin-client.ts';
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const creds = { siteUrl: 'https://example.com', readToken: 'r-token', hmacSecret: 'h-secret' };
function expectedSignature(secret: string, timestamp: string, method: string, path: string, body: string): string {
const canonical = `${timestamp}\n${method}\n${path}\n${createHash('sha256').update(body).digest('hex')}`;
return createHmac('sha256', secret).update(canonical).digest('hex');
}
describe('PluginClient', () => {
it('signs requests and sends the token in Authorization, never the URL', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ theme: 'twentytwentyfour' }) });
const client = new PluginClient(creds);
await client.get('/site-info');
const [url, init] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string> }];
expect(url).toBe('https://example.com/wp-json/wursor/v1/site-info');
expect(String(url)).not.toContain('r-token');
expect(init.headers.Authorization).toBe('Bearer r-token');
expect(init.headers['X-Wursor-Timestamp']).toBeTruthy();
expect(init.headers['X-Wursor-Signature']).toMatch(/^[0-9a-f]{64}$/);
});
it('computes the HMAC over timestamp + method + path + body hash', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
const client = new PluginClient(creds);
await client.post('/files', { a: 1 });
const [, init] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string> }];
const ts = init.headers['X-Wursor-Timestamp'];
expect(init.headers['X-Wursor-Signature']).toBe(expectedSignature('h-secret', ts, 'POST', '/files', '{"a":1}'));
});
it('maps a 401 to Authentication failed', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 401 });
const client = new PluginClient(creds);
await expect(client.get('/site-info')).rejects.toThrow('Authentication failed');
});
it('refuses to construct a client with an http:// site URL', () => {
expect(() => new PluginClient({ ...creds, siteUrl: 'http://example.com' })).toThrow();
});
});
+14 -2
View File
@@ -1,19 +1,31 @@
import Fastify from 'fastify';
import { authRoutes } from './routes/auth.ts';
import { sessionRoutes } from './routes/sessions.ts';
import { siteRoutes } from './routes/sites.ts';
import { PairingService } from './services/pairing-service.ts';
import { InMemorySessionStore, type SessionStore } from './services/session-store.ts';
import { InMemorySiteStore, type SiteStore } from './services/site-store.ts';
import { InMemoryUserStore, type UserStore } from './services/user-store.ts';
import type { SandboxManager } from './services/sandbox-manager.ts';
export type BuildAppOptions = {
userStore?: UserStore;
sessionStore?: SessionStore;
siteStore?: SiteStore;
pairingService?: PairingService;
sandboxManager?: SandboxManager;
};
export async function buildApp(opts: BuildAppOptions = {}) {
const app = Fastify();
const store = opts.userStore ?? new InMemoryUserStore();
const userStore = opts.userStore ?? new InMemoryUserStore();
const sessionStore = opts.sessionStore ?? new InMemorySessionStore();
const siteStore = opts.siteStore ?? new InMemorySiteStore();
const pairingService = opts.pairingService ?? new PairingService();
app.get('/health', async () => ({ ok: true }));
await authRoutes(app, store);
await authRoutes(app, userStore, sessionStore);
await sessionRoutes(app, opts.sandboxManager);
await siteRoutes(app, { sessionStore, siteStore, pairingService });
return app;
}
+28
View File
@@ -0,0 +1,28 @@
import { randomBytes, randomInt } from 'node:crypto';
const CODE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const CODE_LENGTH = 8;
export function generatePairingCode(): string {
let code = '';
for (let i = 0; i < CODE_LENGTH; i += 1) {
code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
}
return code;
}
export function generateToken(): string {
return randomBytes(32).toString('base64url');
}
export function generateHmacSecret(): string {
return randomBytes(32).toString('base64url');
}
export function isHttpsUrl(value: string): boolean {
try {
return new URL(value).protocol === 'https:';
} catch {
return false;
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { SessionStore } from '../services/session-store.ts';
declare module 'fastify' {
interface FastifyRequest {
userId?: string;
}
}
export function requireSession(store: SessionStore) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
const header = request.headers.authorization;
const token = header !== undefined && header.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined;
if (token === undefined) {
await reply.status(401).send({ error: 'unauthorized' });
return;
}
const session = await store.findByToken(token);
if (session === undefined) {
await reply.status(401).send({ error: 'unauthorized' });
return;
}
request.userId = session.userId;
};
}
+9 -3
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { hashPassword, newId, newToken } from '../lib/crypto.ts';
import { hashPassword, newId } from '../lib/crypto.ts';
import type { SessionStore } from '../services/session-store.ts';
import type { UserStore } from '../services/user-store.ts';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -9,7 +10,11 @@ type SignupBody = {
password?: string;
};
export async function authRoutes(app: FastifyInstance, store: UserStore): Promise<void> {
export async function authRoutes(
app: FastifyInstance,
store: UserStore,
sessionStore: SessionStore,
): Promise<void> {
app.post('/auth/signup', async (request, reply) => {
const { email: rawEmail, password } = request.body as SignupBody;
const email = rawEmail?.trim().toLowerCase();
@@ -25,6 +30,7 @@ export async function authRoutes(app: FastifyInstance, store: UserStore): Promis
}
const user = await store.create({ id: newId(), email, passwordHash: hashPassword(password) });
return reply.status(201).send({ user: { id: user.id, email: user.email }, sessionToken: newToken() });
const session = await sessionStore.create(user.id);
return reply.status(201).send({ user: { id: user.id, email: user.email }, sessionToken: session.token });
});
}
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from 'fastify';
import { requireSession } from '../middleware/auth.ts';
import { newId } from '../lib/crypto.ts';
import type { PairingService } from '../services/pairing-service.ts';
import type { SessionStore } from '../services/session-store.ts';
import type { SiteStore } from '../services/site-store.ts';
export type SiteRoutesDeps = {
sessionStore: SessionStore;
pairingService: PairingService;
siteStore: SiteStore;
};
type RedeemBody = {
code?: string;
siteUrl?: string;
};
export async function siteRoutes(app: FastifyInstance, deps: SiteRoutesDeps): Promise<void> {
app.post('/sites/pair', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
const { code, expiresAt } = deps.pairingService.issue(request.userId as string);
return reply.status(201).send({ code, expiresAt });
});
app.post('/sites/redeem', async (request, reply) => {
const { code, siteUrl } = request.body as RedeemBody;
if (code === undefined || siteUrl === undefined) {
return reply.status(400).send({ error: 'invalid' });
}
const result = deps.pairingService.redeem(code, siteUrl);
if (!result.ok) {
return reply.status(400).send({ error: result.error });
}
const site = await deps.siteStore.create({
id: newId(),
accountId: result.accountId,
siteUrl: result.siteUrl,
readToken: result.readToken,
deployToken: result.deployToken,
hmacSecret: result.hmacSecret,
connected: false,
});
return reply.status(201).send({
siteId: site.id,
readToken: site.readToken,
deployToken: site.deployToken,
hmacSecret: site.hmacSecret,
});
});
app.get('/sites/:siteId', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
const site = await deps.siteStore.findById((request.params as { siteId: string }).siteId);
if (site === undefined || site.accountId !== request.userId) {
return reply.status(404).send({ error: 'not_found' });
}
return { id: site.id, siteUrl: site.siteUrl, connected: site.connected };
});
app.post('/sites/:siteId/confirm', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
const site = await deps.siteStore.findById((request.params as { siteId: string }).siteId);
if (site === undefined || site.accountId !== request.userId) {
return reply.status(404).send({ error: 'not_found' });
}
await deps.siteStore.setConnected(site.id, true);
return { connected: true };
});
}
+78
View File
@@ -0,0 +1,78 @@
import { generateHmacSecret, generatePairingCode, generateToken, isHttpsUrl } from '../lib/codes.ts';
export type PendingPairing = {
code: string;
accountId: string;
expiresAt: number;
attempts: number;
locked: boolean;
consumed: boolean;
};
export type RedeemError = 'invalid' | 'expired' | 'locked' | 'consumed';
export type RedeemResult =
| { ok: true; accountId: string; siteUrl: string; readToken: string; deployToken: string; hmacSecret: string }
| { ok: false; error: RedeemError };
export type PairingServiceOptions = {
ttlMs?: number;
maxAttempts?: number;
now?: () => number;
};
const DEFAULT_TTL_MS = 5 * 60 * 1000;
const DEFAULT_MAX_ATTEMPTS = 5;
export class PairingService {
private readonly pending = new Map<string, PendingPairing>();
private readonly now: () => number;
private readonly ttlMs: number;
private readonly maxAttempts: number;
constructor(opts: PairingServiceOptions = {}) {
this.now = opts.now ?? Date.now;
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
}
issue(accountId: string): { code: string; expiresAt: number } {
const code = generatePairingCode();
const expiresAt = this.now() + this.ttlMs;
this.pending.set(code, { code, accountId, expiresAt, attempts: 0, locked: false, consumed: false });
return { code, expiresAt };
}
redeem(code: string, siteUrl: string): RedeemResult {
const pairing = this.pending.get(code);
if (pairing === undefined) {
return { ok: false, error: 'invalid' };
}
if (pairing.consumed) {
return { ok: false, error: 'consumed' };
}
if (pairing.locked) {
return { ok: false, error: 'locked' };
}
if (this.now() > pairing.expiresAt) {
return { ok: false, error: 'expired' };
}
if (!isHttpsUrl(siteUrl)) {
pairing.attempts += 1;
if (pairing.attempts >= this.maxAttempts) {
pairing.locked = true;
}
return { ok: false, error: 'invalid' };
}
pairing.consumed = true;
return {
ok: true,
accountId: pairing.accountId,
siteUrl,
readToken: generateToken(),
deployToken: generateToken(),
hmacSecret: generateHmacSecret(),
};
}
}
+64
View File
@@ -0,0 +1,64 @@
import { createHash, createHmac } from 'node:crypto';
import { isHttpsUrl } from '../lib/codes.ts';
export type PluginCredentials = {
siteUrl: string;
readToken: string;
deployToken?: string;
hmacSecret: string;
};
function sha256hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export class PluginClient {
private readonly baseUrl: string;
constructor(private readonly creds: PluginCredentials) {
if (!isHttpsUrl(creds.siteUrl)) {
throw new Error('Site URL must be https');
}
this.baseUrl = `${creds.siteUrl.replace(/\/$/, '')}/wp-json/wursor/v1`;
}
async get(path: string): Promise<unknown> {
return this.request('GET', path);
}
async post(path: string, body?: unknown): Promise<unknown> {
return this.request('POST', path, body);
}
private async request(method: string, path: string, body?: unknown): Promise<unknown> {
const bodyText = body === undefined ? '' : JSON.stringify(body);
const timestamp = String(Math.floor(Date.now() / 1000));
const canonical = `${timestamp}\n${method}\n${path}\n${sha256hex(bodyText)}`;
const signature = createHmac('sha256', this.creds.hmacSecret).update(canonical).digest('hex');
const token = method === 'GET' ? this.creds.readToken : (this.creds.deployToken ?? this.creds.readToken);
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
'X-Wursor-Timestamp': timestamp,
'X-Wursor-Signature': signature,
};
if (bodyText !== '') {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: bodyText === '' ? undefined : bodyText,
});
if (!res.ok) {
if (res.status === 401) {
throw new Error('Authentication failed');
}
throw new Error(`Plugin HTTP ${res.status}`);
}
return res.json();
}
}
+26
View File
@@ -0,0 +1,26 @@
import { generateToken } from '../lib/codes.ts';
export type Session = {
token: string;
userId: string;
createdAt: number;
};
export interface SessionStore {
create(userId: string): Promise<Session>;
findByToken(token: string): Promise<Session | undefined>;
}
export class InMemorySessionStore implements SessionStore {
private byToken = new Map<string, Session>();
async create(userId: string): Promise<Session> {
const session = { token: generateToken(), userId, createdAt: Date.now() };
this.byToken.set(session.token, session);
return session;
}
async findByToken(token: string): Promise<Session | undefined> {
return this.byToken.get(token);
}
}
+35
View File
@@ -0,0 +1,35 @@
export type Site = {
id: string;
accountId: string;
siteUrl: string;
readToken: string;
deployToken: string;
hmacSecret: string;
connected: boolean;
};
export interface SiteStore {
create(site: Site): Promise<Site>;
findById(id: string): Promise<Site | undefined>;
setConnected(id: string, connected: boolean): Promise<void>;
}
export class InMemorySiteStore implements SiteStore {
private byId = new Map<string, Site>();
async create(site: Site): Promise<Site> {
this.byId.set(site.id, site);
return site;
}
async findById(id: string): Promise<Site | undefined> {
return this.byId.get(id);
}
async setConnected(id: string, connected: boolean): Promise<void> {
const site = this.byId.get(id);
if (site !== undefined) {
this.byId.set(id, { ...site, connected });
}
}
}
@@ -0,0 +1,26 @@
# 16. Pairing-code TTL/lockout lives on the Wursor API; the plugin enforces token/HMAC/scope
- **Status:** Accepted
- **Date:** 2026-08-15
## Context
The pairing threat model (`spikes/pairing-threat-model.md`) mandates that **Wursor generates** the pairing code and **the plugin redeems** it — explicitly rejecting the plugin-local generate/redeem sketch in IMPLEMENTATION. Its "Sprint 2 tests" section, however, still labels the pairing-code TTL/lockout/single-use tests under `plugin/__tests__/test-auth.php`, a leftover from that rejected sketch.
## Decision
The pairing code lifecycle (issue, 5-minute TTL, 5-attempt lockout, single-use, `site_url` https check) is enforced in the API's `PairingService`. The plugin's `class-auth.php` enforces token hashing (SHA-256 + `hash_equals`), HMAC verification, `read` vs `deploy` scoping, and rotation.
### Options considered
- Follow the test-file labels literally (plugin enforces the pairing code).
- Follow the locked flow (chosen).
### Rejected
- Literal labels — they contradict the "Wursor generates, plugin redeems" flow the same note mandates; pairing state can only live where the code is issued.
## Consequences
- `api/__tests__/routes/sites-pair.test.ts` + `pairing-service.test.ts` cover TTL/lockout/single-use.
- `plugin/__tests__/test-auth.php` covers hashing, HMAC, scope, and rotation only.
+1
View File
@@ -29,6 +29,7 @@ Each ADR is a single file following the [Nygard format](https://cognitect.com/bl
| [0013](0013-docker-boundary-mock.md) | Sandbox orchestration mocks the Docker boundary; real daemon client deferred | Accepted |
| [0014](0014-postgres-queryable.md) | Postgres user store via a Queryable boundary; schema in SQL migrations | Accepted |
| [0015](0015-dockerode-engine-gating.md) | Docker daemon client via dockerode behind an injected engine; sandbox gated by env | Accepted |
| [0016](0016-pairing-code-ownership.md) | Pairing-code TTL/lockout lives on the Wursor API; the plugin enforces token/HMAC/scope | Accepted |
## How to add one