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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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