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
+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 });
}
}
}