diff --git a/api/__tests__/lib/codes.test.ts b/api/__tests__/lib/codes.test.ts new file mode 100644 index 0000000..5d96569 --- /dev/null +++ b/api/__tests__/lib/codes.test.ts @@ -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); + }); +}); diff --git a/api/__tests__/routes/sites-pair.test.ts b/api/__tests__/routes/sites-pair.test.ts new file mode 100644 index 0000000..fd5376f --- /dev/null +++ b/api/__tests__/routes/sites-pair.test.ts @@ -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 { + 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); + }); +}); diff --git a/api/__tests__/services/pairing-service.test.ts b/api/__tests__/services/pairing-service.test.ts new file mode 100644 index 0000000..ae5d517 --- /dev/null +++ b/api/__tests__/services/pairing-service.test.ts @@ -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' }); + }); +}); diff --git a/api/__tests__/services/plugin-client.test.ts b/api/__tests__/services/plugin-client.test.ts new file mode 100644 index 0000000..ae261d4 --- /dev/null +++ b/api/__tests__/services/plugin-client.test.ts @@ -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; + +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 }]; + 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 }]; + 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(); + }); +}); diff --git a/api/src/app.ts b/api/src/app.ts index cfa9860..7b9427f 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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; } diff --git a/api/src/lib/codes.ts b/api/src/lib/codes.ts new file mode 100644 index 0000000..9ddcfde --- /dev/null +++ b/api/src/lib/codes.ts @@ -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; + } +} diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts new file mode 100644 index 0000000..3487f04 --- /dev/null +++ b/api/src/middleware/auth.ts @@ -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 => { + 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; + }; +} diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 9a868c3..283c578 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -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 { +export async function authRoutes( + app: FastifyInstance, + store: UserStore, + sessionStore: SessionStore, +): Promise { 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 }); }); } diff --git a/api/src/routes/sites.ts b/api/src/routes/sites.ts new file mode 100644 index 0000000..b6341b1 --- /dev/null +++ b/api/src/routes/sites.ts @@ -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 { + 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 }; + }); +} diff --git a/api/src/services/pairing-service.ts b/api/src/services/pairing-service.ts new file mode 100644 index 0000000..d79efef --- /dev/null +++ b/api/src/services/pairing-service.ts @@ -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(); + 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(), + }; + } +} diff --git a/api/src/services/plugin-client.ts b/api/src/services/plugin-client.ts new file mode 100644 index 0000000..1ef8a79 --- /dev/null +++ b/api/src/services/plugin-client.ts @@ -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 { + return this.request('GET', path); + } + + async post(path: string, body?: unknown): Promise { + return this.request('POST', path, body); + } + + private async request(method: string, path: string, body?: unknown): Promise { + 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 = { + 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(); + } +} diff --git a/api/src/services/session-store.ts b/api/src/services/session-store.ts new file mode 100644 index 0000000..baed11d --- /dev/null +++ b/api/src/services/session-store.ts @@ -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; + findByToken(token: string): Promise; +} + +export class InMemorySessionStore implements SessionStore { + private byToken = new Map(); + + async create(userId: string): Promise { + const session = { token: generateToken(), userId, createdAt: Date.now() }; + this.byToken.set(session.token, session); + return session; + } + + async findByToken(token: string): Promise { + return this.byToken.get(token); + } +} diff --git a/api/src/services/site-store.ts b/api/src/services/site-store.ts new file mode 100644 index 0000000..b8d01b1 --- /dev/null +++ b/api/src/services/site-store.ts @@ -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; + findById(id: string): Promise; + setConnected(id: string, connected: boolean): Promise; +} + +export class InMemorySiteStore implements SiteStore { + private byId = new Map(); + + async create(site: Site): Promise { + this.byId.set(site.id, site); + return site; + } + + async findById(id: string): Promise { + return this.byId.get(id); + } + + async setConnected(id: string, connected: boolean): Promise { + const site = this.byId.get(id); + if (site !== undefined) { + this.byId.set(id, { ...site, connected }); + } + } +} diff --git a/docs/decisions/0016-pairing-code-ownership.md b/docs/decisions/0016-pairing-code-ownership.md new file mode 100644 index 0000000..52644c0 --- /dev/null +++ b/docs/decisions/0016-pairing-code-ownership.md @@ -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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c7571fb..fd53daf 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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