feat: Sprint 2 — Wursor-side pairing flow + signed plugin client
Mirror to GitHub / mirror (push) Canceled after 0s
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:
+14
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user