From ece6acf56861c317af2eef0b053f0f6995ae1dc2 Mon Sep 17 00:00:00 2001 From: jean1222312432 Date: Wed, 17 Jun 2026 15:14:53 -0600 Subject: [PATCH 1/2] feat(gateway): checkout session handshake model + endpoint --- services/connect-gateway/.env.example | 3 + .../migration.sql | 31 +++ services/connect-gateway/prisma/schema.prisma | 43 +++- services/connect-gateway/src/app.ts | 3 + services/connect-gateway/src/errors.ts | 35 ++++ .../connect-gateway/src/routes/session.ts | 110 +++++++++++ services/connect-gateway/src/session.test.ts | 168 ++++++++++++++++ services/connect-gateway/src/sessions.test.ts | 141 +++++++++++++ services/connect-gateway/src/sessions.ts | 187 ++++++++++++++++++ 9 files changed, 715 insertions(+), 6 deletions(-) create mode 100644 services/connect-gateway/prisma/migrations/20250617150000_add_checkout_sessions/migration.sql create mode 100644 services/connect-gateway/src/errors.ts create mode 100644 services/connect-gateway/src/routes/session.ts create mode 100644 services/connect-gateway/src/session.test.ts create mode 100644 services/connect-gateway/src/sessions.test.ts create mode 100644 services/connect-gateway/src/sessions.ts diff --git a/services/connect-gateway/.env.example b/services/connect-gateway/.env.example index 5a21e93..c27f29b 100644 --- a/services/connect-gateway/.env.example +++ b/services/connect-gateway/.env.example @@ -15,5 +15,8 @@ PACTO_API_KEY= # Secret used to sign session handshakes and webhooks GATEWAY_SIGNING_SECRET= +# Checkout session TTL in milliseconds (default: 900000 / 15 minutes) +SESSION_TTL_MS= + # Bearer token for /admin/* key management endpoints GATEWAY_ADMIN_TOKEN= diff --git a/services/connect-gateway/prisma/migrations/20250617150000_add_checkout_sessions/migration.sql b/services/connect-gateway/prisma/migrations/20250617150000_add_checkout_sessions/migration.sql new file mode 100644 index 0000000..ca923c9 --- /dev/null +++ b/services/connect-gateway/prisma/migrations/20250617150000_add_checkout_sessions/migration.sql @@ -0,0 +1,31 @@ +-- CreateEnum +CREATE TYPE "CheckoutMode" AS ENUM ('buy', 'sell'); + +-- CreateEnum +CREATE TYPE "SessionStatus" AS ENUM ('active', 'expired', 'consumed', 'revoked'); + +-- CreateTable +CREATE TABLE "CheckoutSession" ( + "id" TEXT NOT NULL, + "apiKeyId" TEXT NOT NULL, + "mode" "CheckoutMode" NOT NULL, + "listingId" TEXT, + "quote" JSONB, + "clientSecretHash" TEXT NOT NULL, + "status" "SessionStatus" NOT NULL DEFAULT 'active', + "expiresAt" TIMESTAMP(3) NOT NULL, + "refreshCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CheckoutSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "CheckoutSession_apiKeyId_idx" ON "CheckoutSession"("apiKeyId"); + +-- CreateIndex +CREATE INDEX "CheckoutSession_status_idx" ON "CheckoutSession"("status"); + +-- AddForeignKey +ALTER TABLE "CheckoutSession" ADD CONSTRAINT "CheckoutSession_apiKeyId_fkey" FOREIGN KEY ("apiKeyId") REFERENCES "ApiKey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/services/connect-gateway/prisma/schema.prisma b/services/connect-gateway/prisma/schema.prisma index 5b964fc..56a11ae 100644 --- a/services/connect-gateway/prisma/schema.prisma +++ b/services/connect-gateway/prisma/schema.prisma @@ -18,18 +18,49 @@ enum KeyStatus { revoked } +enum CheckoutMode { + buy + sell +} + +enum SessionStatus { + active + expired + consumed + revoked +} + model ApiKey { - id String @id @default(cuid()) - publishableKey String @unique + id String @id @default(cuid()) + publishableKey String @unique secretKeyHash String secretLast4 String - mode KeyMode @default(test) + mode KeyMode @default(test) allowedOrigins String[] - status KeyStatus @default(active) + status KeyStatus @default(active) label String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + checkoutSessions CheckoutSession[] @@index([publishableKey]) @@index([status]) } + +model CheckoutSession { + id String @id @default(cuid()) + apiKeyId String + apiKey ApiKey @relation(fields: [apiKeyId], references: [id]) + mode CheckoutMode + listingId String? + quote Json? + clientSecretHash String + status SessionStatus @default(active) + expiresAt DateTime + refreshCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([apiKeyId]) + @@index([status]) +} diff --git a/services/connect-gateway/src/app.ts b/services/connect-gateway/src/app.ts index 6961802..4cd4560 100644 --- a/services/connect-gateway/src/app.ts +++ b/services/connect-gateway/src/app.ts @@ -2,6 +2,7 @@ import type { ApiKey } from '@prisma/client'; import { Hono } from 'hono'; import { originValidation } from './middleware/origin.js'; import { adminRoutes } from './routes/admin.js'; +import { sessionRoutes } from './routes/session.js'; type GatewayVariables = { apiKey: ApiKey; @@ -22,6 +23,8 @@ export function createApp(): Hono<{ Variables: GatewayVariables }> { return originValidation(c, next); }); + app.route('/v1/session', sessionRoutes); + app.all('*', (c) => c.json({ error: 'not found' }, 404)); return app; diff --git a/services/connect-gateway/src/errors.ts b/services/connect-gateway/src/errors.ts new file mode 100644 index 0000000..2f8d614 --- /dev/null +++ b/services/connect-gateway/src/errors.ts @@ -0,0 +1,35 @@ +import type { ContentfulStatusCode } from 'hono/utils/http-status'; + +export type SessionErrorCode = 'session_invalid' | 'session_expired'; + +export interface GatewayErrorBody { + error: { + type: string; + code: string; + message: string; + }; +} + +export class SessionError extends Error { + constructor( + public readonly code: SessionErrorCode, + message: string, + ) { + super(message); + this.name = 'SessionError'; + } +} + +export function sessionErrorStatus(code: SessionErrorCode): ContentfulStatusCode { + return code === 'session_expired' ? 410 : 401; +} + +export function toGatewayErrorBody(type: string, code: string, message: string): GatewayErrorBody { + return { + error: { + type, + code, + message, + }, + }; +} diff --git a/services/connect-gateway/src/routes/session.ts b/services/connect-gateway/src/routes/session.ts new file mode 100644 index 0000000..66ce0b5 --- /dev/null +++ b/services/connect-gateway/src/routes/session.ts @@ -0,0 +1,110 @@ +import type { ApiKey, CheckoutMode, Prisma } from '@prisma/client'; +import { Hono } from 'hono'; +import { SessionError, sessionErrorStatus, toGatewayErrorBody } from '../errors.js'; +import { createCheckoutSession, refreshCheckoutSession } from '../sessions.js'; + +type SessionRouteVariables = { + apiKey: ApiKey; +}; + +const session = new Hono<{ Variables: SessionRouteVariables }>(); + +function isCheckoutMode(value: string): value is CheckoutMode { + return value === 'buy' || value === 'sell'; +} + +session.post('/', async (c) => { + const apiKey = c.get('apiKey'); + const body = await c.req.json<{ + listingId?: string; + quote?: Record; + mode?: string; + }>(); + + const hasListingId = typeof body.listingId === 'string' && body.listingId.length > 0; + const hasQuote = + body.quote !== undefined && body.quote !== null && typeof body.quote === 'object'; + + if (!hasListingId && !hasQuote) { + return c.json( + toGatewayErrorBody('validation_error', 'invalid_request', 'listingId or quote is required'), + 400, + ); + } + + if (hasListingId && hasQuote) { + return c.json( + toGatewayErrorBody( + 'validation_error', + 'invalid_request', + 'provide listingId or quote, not both', + ), + 400, + ); + } + + if (!body.mode || !isCheckoutMode(body.mode)) { + return c.json( + toGatewayErrorBody('validation_error', 'invalid_request', 'mode must be "buy" or "sell"'), + 400, + ); + } + + try { + const result = await createCheckoutSession({ + apiKeyId: apiKey.id, + mode: body.mode, + listingId: hasListingId ? body.listingId : undefined, + quote: hasQuote ? (body.quote as Prisma.InputJsonValue) : undefined, + }); + + return c.json({ + sessionId: result.sessionId, + clientSecret: result.clientSecret, + expiresAt: result.expiresAt.toISOString(), + mode: result.mode, + }); + } catch (error) { + if (error instanceof SessionError) { + return c.json( + toGatewayErrorBody('session_error', error.code, error.message), + sessionErrorStatus(error.code), + ); + } + + throw error; + } +}); + +session.post('/refresh', async (c) => { + const body = await c.req.json<{ clientSecret?: string }>(); + + if (!body.clientSecret || typeof body.clientSecret !== 'string') { + return c.json( + toGatewayErrorBody('validation_error', 'invalid_request', 'clientSecret is required'), + 400, + ); + } + + try { + const result = await refreshCheckoutSession(body.clientSecret); + + return c.json({ + sessionId: result.sessionId, + clientSecret: result.clientSecret, + expiresAt: result.expiresAt.toISOString(), + mode: result.mode, + }); + } catch (error) { + if (error instanceof SessionError) { + return c.json( + toGatewayErrorBody('session_error', error.code, error.message), + sessionErrorStatus(error.code), + ); + } + + throw error; + } +}); + +export { session as sessionRoutes }; diff --git a/services/connect-gateway/src/session.test.ts b/services/connect-gateway/src/session.test.ts new file mode 100644 index 0000000..8d3489b --- /dev/null +++ b/services/connect-gateway/src/session.test.ts @@ -0,0 +1,168 @@ +import type { ApiKey } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createApp } from './app.js'; +import { PUBLISHABLE_KEY_HEADER } from './middleware/origin.js'; + +const mockApiKey: ApiKey = { + id: 'key_1', + publishableKey: 'pk_test_mockkey', + secretKeyHash: 'hash', + secretLast4: 'abcd', + mode: 'test', + allowedOrigins: ['https://allowed.example'], + status: 'active', + label: null, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), +}; + +vi.mock('./keys.js', () => ({ + findActiveApiKeyByPublishableKey: vi.fn(), + isOriginAllowed: (origin: string, allowed: string[]) => allowed.includes(origin), + createApiKey: vi.fn(), + listApiKeys: vi.fn(), + rotateApiKey: vi.fn(), + revokeApiKey: vi.fn(), + hashSecretKey: vi.fn(), + generateKeyPair: vi.fn(), +})); + +vi.mock('./sessions.js', () => ({ + createCheckoutSession: vi.fn(), + refreshCheckoutSession: vi.fn(), +})); + +vi.mock('./db.js', () => ({ + prisma: {}, +})); + +import { SessionError } from './errors.js'; +import * as keys from './keys.js'; +import * as sessions from './sessions.js'; + +const sessionHeaders = { + Origin: 'https://allowed.example', + [PUBLISHABLE_KEY_HEADER]: mockApiKey.publishableKey, + 'Content-Type': 'application/json', +}; + +describe('session routes', () => { + beforeEach(() => { + vi.mocked(keys.findActiveApiKeyByPublishableKey).mockReset(); + vi.mocked(sessions.createCheckoutSession).mockReset(); + vi.mocked(sessions.refreshCheckoutSession).mockReset(); + vi.mocked(keys.findActiveApiKeyByPublishableKey).mockResolvedValue(mockApiKey); + process.env.GATEWAY_ADMIN_TOKEN = 'test-admin-token'; + }); + + it('creates a checkout session with listingId', async () => { + vi.mocked(sessions.createCheckoutSession).mockResolvedValue({ + sessionId: 'session_1', + clientSecret: 'cs_session_1_signature', + expiresAt: new Date('2024-01-01T00:15:00.000Z'), + mode: 'buy', + }); + + const app = createApp(); + const res = await app.request('/v1/session', { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ listingId: 'listing_1', mode: 'buy' }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.sessionId).toBe('session_1'); + expect(body.clientSecret).toBe('cs_session_1_signature'); + expect(body.mode).toBe('buy'); + expect(sessions.createCheckoutSession).toHaveBeenCalledWith({ + apiKeyId: mockApiKey.id, + mode: 'buy', + listingId: 'listing_1', + quote: undefined, + }); + }); + + it('rejects session creation without listingId or quote', async () => { + const app = createApp(); + const res = await app.request('/v1/session', { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ mode: 'buy' }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: { + type: 'validation_error', + code: 'invalid_request', + message: 'listingId or quote is required', + }, + }); + }); + + it('returns typed error for invalid session on refresh', async () => { + vi.mocked(sessions.refreshCheckoutSession).mockRejectedValue( + new SessionError('session_invalid', 'Client secret signature mismatch'), + ); + + const app = createApp(); + const res = await app.request('/v1/session/refresh', { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ clientSecret: 'cs_other_session_signature' }), + }); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + error: { + type: 'session_error', + code: 'session_invalid', + message: 'Client secret signature mismatch', + }, + }); + }); + + it('returns typed error for expired session on refresh', async () => { + vi.mocked(sessions.refreshCheckoutSession).mockRejectedValue( + new SessionError('session_expired', 'Session has expired'), + ); + + const app = createApp(); + const res = await app.request('/v1/session/refresh', { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ clientSecret: 'cs_session_1_signature' }), + }); + + expect(res.status).toBe(410); + expect(await res.json()).toEqual({ + error: { + type: 'session_error', + code: 'session_expired', + message: 'Session has expired', + }, + }); + }); + + it('refreshes a checkout session', async () => { + vi.mocked(sessions.refreshCheckoutSession).mockResolvedValue({ + sessionId: 'session_1', + clientSecret: 'cs_session_1_new_signature', + expiresAt: new Date('2024-01-01T00:30:00.000Z'), + mode: 'sell', + }); + + const app = createApp(); + const res = await app.request('/v1/session/refresh', { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ clientSecret: 'cs_session_1_signature' }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clientSecret).toBe('cs_session_1_new_signature'); + expect(body.mode).toBe('sell'); + }); +}); diff --git a/services/connect-gateway/src/sessions.test.ts b/services/connect-gateway/src/sessions.test.ts new file mode 100644 index 0000000..615f177 --- /dev/null +++ b/services/connect-gateway/src/sessions.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockCheckoutSession = { + id: 'session_1', + apiKeyId: 'key_1', + mode: 'buy' as const, + listingId: 'listing_1', + quote: null, + clientSecretHash: '', + status: 'active' as const, + expiresAt: new Date('2024-06-01T12:15:00.000Z'), + refreshCount: 0, + createdAt: new Date('2024-06-01T12:00:00.000Z'), + updatedAt: new Date('2024-06-01T12:00:00.000Z'), +}; + +vi.mock('./db.js', () => ({ + prisma: { + checkoutSession: { + create: vi.fn(), + update: vi.fn(), + findUnique: vi.fn(), + }, + }, +})); + +import type { CheckoutSession } from '@prisma/client'; +import { prisma } from './db.js'; +import { + buildClientSecret, + hashClientSecret, + refreshCheckoutSession, + validateClientSecret, +} from './sessions.js'; + +describe('sessions service', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T12:00:00.000Z')); + process.env.GATEWAY_SIGNING_SECRET = 'test-signing-secret'; + vi.mocked(prisma.checkoutSession.create).mockReset(); + vi.mocked(prisma.checkoutSession.update).mockReset(); + vi.mocked(prisma.checkoutSession.findUnique).mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('builds client secrets that cannot be used across sessions', async () => { + const expiresAt = new Date('2024-06-01T12:15:00.000Z'); + const secretOne = buildClientSecret('session_1', 'key_1', expiresAt); + const secretTwo = buildClientSecret('session_2', 'key_1', expiresAt); + + vi.mocked(prisma.checkoutSession.findUnique) + .mockResolvedValueOnce({ + ...mockCheckoutSession, + expiresAt, + clientSecretHash: hashClientSecret(secretOne), + } as CheckoutSession) + .mockResolvedValueOnce(null); + + await expect(validateClientSecret(secretOne)).resolves.toMatchObject({ id: 'session_1' }); + await expect(validateClientSecret(secretTwo)).rejects.toEqual( + expect.objectContaining({ code: 'session_invalid' }), + ); + }); + + it('rejects expired sessions with a typed error', async () => { + const expiredAt = new Date('2024-06-01T11:00:00.000Z'); + const clientSecret = buildClientSecret('session_1', 'key_1', expiredAt); + const expiredSession = { + id: 'session_1', + apiKeyId: 'key_1', + mode: 'buy' as const, + listingId: 'listing_1', + quote: null, + clientSecretHash: hashClientSecret(clientSecret), + status: 'active' as const, + expiresAt: expiredAt, + refreshCount: 0, + createdAt: new Date('2024-06-01T12:00:00.000Z'), + updatedAt: new Date('2024-06-01T12:00:00.000Z'), + }; + + vi.mocked(prisma.checkoutSession.findUnique).mockResolvedValue( + expiredSession as CheckoutSession, + ); + vi.mocked(prisma.checkoutSession.update).mockResolvedValue({ + ...expiredSession, + status: 'expired', + } as CheckoutSession); + + await expect(validateClientSecret(clientSecret)).rejects.toEqual( + expect.objectContaining({ code: 'session_expired' }), + ); + }); + + it('refreshes a session and rotates the client secret', async () => { + const expiresAt = new Date('2024-06-01T13:00:00.000Z'); + const clientSecret = buildClientSecret('session_1', 'key_1', expiresAt); + const refreshedExpiresAt = new Date('2024-06-01T12:35:00.000Z'); + + vi.mocked(prisma.checkoutSession.findUnique).mockResolvedValue({ + ...mockCheckoutSession, + expiresAt, + clientSecretHash: hashClientSecret(clientSecret), + } as CheckoutSession); + vi.mocked(prisma.checkoutSession.update).mockResolvedValue({ + ...mockCheckoutSession, + expiresAt: refreshedExpiresAt, + refreshCount: 1, + clientSecretHash: hashClientSecret( + buildClientSecret('session_1', 'key_1', refreshedExpiresAt), + ), + } as CheckoutSession); + + vi.setSystemTime(new Date('2024-06-01T12:20:00.000Z')); + + const result = await refreshCheckoutSession(clientSecret); + + expect(result.sessionId).toBe('session_1'); + expect(result.clientSecret).toBe(buildClientSecret('session_1', 'key_1', refreshedExpiresAt)); + expect(result.expiresAt.toISOString()).toBe(refreshedExpiresAt.toISOString()); + }); + + it('rejects secrets from another api key even with the same session id', async () => { + const expiresAt = new Date('2024-06-01T12:15:00.000Z'); + const foreignSecret = buildClientSecret('session_1', 'key_2', expiresAt); + + vi.mocked(prisma.checkoutSession.findUnique).mockResolvedValue({ + ...mockCheckoutSession, + expiresAt, + clientSecretHash: hashClientSecret(buildClientSecret('session_1', 'key_1', expiresAt)), + } as CheckoutSession); + + await expect(validateClientSecret(foreignSecret)).rejects.toEqual( + expect.objectContaining({ code: 'session_invalid' }), + ); + }); +}); diff --git a/services/connect-gateway/src/sessions.ts b/services/connect-gateway/src/sessions.ts new file mode 100644 index 0000000..ba4a3d4 --- /dev/null +++ b/services/connect-gateway/src/sessions.ts @@ -0,0 +1,187 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; +import type { CheckoutMode, CheckoutSession, Prisma } from '@prisma/client'; +import { prisma } from './db.js'; +import { SessionError } from './errors.js'; + +const CLIENT_SECRET_PREFIX = 'cs_'; +const DEFAULT_SESSION_TTL_MS = 15 * 60 * 1000; + +export interface CreateSessionInput { + apiKeyId: string; + mode: CheckoutMode; + listingId?: string; + quote?: Prisma.InputJsonValue; +} + +export interface SessionResult { + sessionId: string; + clientSecret: string; + expiresAt: Date; + mode: CheckoutMode; +} + +function getSessionTtlMs(): number { + const configured = process.env.SESSION_TTL_MS; + if (!configured) { + return DEFAULT_SESSION_TTL_MS; + } + + const parsed = Number(configured); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_SESSION_TTL_MS; + } + + return parsed; +} + +function getSigningSecret(): string { + const secret = process.env.GATEWAY_SIGNING_SECRET; + if (!secret) { + throw new Error('GATEWAY_SIGNING_SECRET is not configured'); + } + + return secret; +} + +function computeSignature(sessionId: string, apiKeyId: string, expiresAt: Date): string { + const payload = `${sessionId}|${apiKeyId}|${expiresAt.toISOString()}`; + return createHmac('sha256', getSigningSecret()).update(payload).digest('base64url'); +} + +export function buildClientSecret(sessionId: string, apiKeyId: string, expiresAt: Date): string { + const signature = computeSignature(sessionId, apiKeyId, expiresAt); + return `${CLIENT_SECRET_PREFIX}${sessionId}.${signature}`; +} + +export function hashClientSecret(clientSecret: string): string { + return createHash('sha256').update(clientSecret).digest('hex'); +} + +export function parseClientSecret( + clientSecret: string, +): { sessionId: string; signature: string } | null { + if (!clientSecret.startsWith(CLIENT_SECRET_PREFIX)) { + return null; + } + + const rest = clientSecret.slice(CLIENT_SECRET_PREFIX.length); + const separatorIndex = rest.indexOf('.'); + if (separatorIndex === -1) { + return null; + } + + const sessionId = rest.slice(0, separatorIndex); + const signature = rest.slice(separatorIndex + 1); + if (!sessionId || !signature) { + return null; + } + + return { sessionId, signature }; +} + +function signaturesMatch(provided: string, expected: string): boolean { + const providedBuffer = Buffer.from(provided); + const expectedBuffer = Buffer.from(expected); + + if (providedBuffer.length !== expectedBuffer.length) { + return false; + } + + return timingSafeEqual(providedBuffer, expectedBuffer); +} + +export async function createCheckoutSession(input: CreateSessionInput): Promise { + const expiresAt = new Date(Date.now() + getSessionTtlMs()); + + const session = await prisma.checkoutSession.create({ + data: { + apiKeyId: input.apiKeyId, + mode: input.mode, + listingId: input.listingId, + quote: input.quote ?? undefined, + expiresAt, + clientSecretHash: '', + }, + }); + + const clientSecret = buildClientSecret(session.id, input.apiKeyId, expiresAt); + const clientSecretHash = hashClientSecret(clientSecret); + + const updated = await prisma.checkoutSession.update({ + where: { id: session.id }, + data: { clientSecretHash }, + }); + + return { + sessionId: updated.id, + clientSecret, + expiresAt: updated.expiresAt, + mode: updated.mode, + }; +} + +export async function refreshCheckoutSession(clientSecret: string): Promise { + const session = await validateClientSecret(clientSecret); + const newExpiresAt = new Date(Date.now() + getSessionTtlMs()); + const newClientSecret = buildClientSecret(session.id, session.apiKeyId, newExpiresAt); + const newHash = hashClientSecret(newClientSecret); + + const updated = await prisma.checkoutSession.update({ + where: { id: session.id }, + data: { + expiresAt: newExpiresAt, + clientSecretHash: newHash, + refreshCount: { increment: 1 }, + status: 'active', + }, + }); + + return { + sessionId: updated.id, + clientSecret: newClientSecret, + expiresAt: updated.expiresAt, + mode: updated.mode, + }; +} + +export async function validateClientSecret(clientSecret: string): Promise { + const parsed = parseClientSecret(clientSecret); + if (!parsed) { + throw new SessionError('session_invalid', 'Invalid client secret format'); + } + + const session = await prisma.checkoutSession.findUnique({ + where: { id: parsed.sessionId }, + }); + + if (!session) { + throw new SessionError('session_invalid', 'Session not found'); + } + + if (session.status === 'revoked' || session.status === 'consumed') { + throw new SessionError('session_invalid', 'Session is no longer valid'); + } + + const expectedSignature = computeSignature(session.id, session.apiKeyId, session.expiresAt); + if (!signaturesMatch(parsed.signature, expectedSignature)) { + throw new SessionError('session_invalid', 'Client secret signature mismatch'); + } + + const hash = hashClientSecret(clientSecret); + if (hash !== session.clientSecretHash) { + throw new SessionError('session_invalid', 'Client secret hash mismatch'); + } + + if (session.expiresAt < new Date() || session.status === 'expired') { + if (session.status !== 'expired') { + await prisma.checkoutSession.update({ + where: { id: session.id }, + data: { status: 'expired' }, + }); + } + + throw new SessionError('session_expired', 'Session has expired'); + } + + return session; +} From 754497c8786eb95feab2423436f3c3be8607d006 Mon Sep 17 00:00:00 2001 From: jean1222312432 Date: Wed, 17 Jun 2026 15:14:53 -0600 Subject: [PATCH 2/2] feat(core): session handshake + PactoSession --- packages/connect-core/src/index.test.ts | 136 ++++++++++++++++++- packages/connect-core/src/index.ts | 173 ++++++++++++++++++++++-- packages/connect-react/src/index.ts | 9 +- 3 files changed, 306 insertions(+), 12 deletions(-) diff --git a/packages/connect-core/src/index.test.ts b/packages/connect-core/src/index.test.ts index b226e86..893fd1c 100644 --- a/packages/connect-core/src/index.test.ts +++ b/packages/connect-core/src/index.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, it } from 'vitest'; -import { init, VERSION } from './index'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { init, PactoSession, VERSION } from './index'; + +const gatewayUrl = 'https://gateway.example'; +const publishableKey = 'pk_test_123'; +const origin = 'https://allowed.example'; + +function mockFetchResponse(status: number, body: Record) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} describe('@pacto-connect/core', () => { it('exposes a version', () => { @@ -17,3 +29,123 @@ describe('@pacto-connect/core', () => { expect(client.gatewayUrl).toContain('http'); }); }); + +describe('@pacto-connect/core sessions', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('creates a checkout session', async () => { + vi.mocked(fetch).mockResolvedValue( + mockFetchResponse(200, { + sessionId: 'session_1', + clientSecret: 'cs_session_1_signature', + expiresAt: '2024-01-01T00:15:00.000Z', + mode: 'buy', + }) as Response, + ); + + const client = init({ publishableKey, gatewayUrl, origin }); + const session = await client.createCheckoutSession({ + listingId: 'listing_1', + mode: 'buy', + }); + + expect(session).toBeInstanceOf(PactoSession); + expect(session.sessionId).toBe('session_1'); + expect(session.clientSecret).toBe('cs_session_1_signature'); + expect(session.mode).toBe('buy'); + expect(session.isExpired()).toBe(true); + }); + + it('maps invalid session errors from the gateway', async () => { + vi.mocked(fetch).mockResolvedValue( + mockFetchResponse(401, { + error: { + type: 'session_error', + code: 'session_invalid', + message: 'Client secret signature mismatch', + }, + }) as Response, + ); + + const client = init({ publishableKey, gatewayUrl, origin }); + + await expect( + client.createCheckoutSession({ listingId: 'listing_1', mode: 'buy' }), + ).rejects.toEqual( + expect.objectContaining({ + name: 'PactoSessionError', + code: 'session_invalid', + }), + ); + }); + + it('maps expired session errors from the gateway', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + mockFetchResponse(200, { + sessionId: 'session_1', + clientSecret: 'cs_session_1_signature', + expiresAt: '2024-01-01T00:15:00.000Z', + mode: 'buy', + }) as Response, + ) + .mockResolvedValueOnce( + mockFetchResponse(410, { + error: { + type: 'session_error', + code: 'session_expired', + message: 'Session has expired', + }, + }) as Response, + ); + + const client = init({ publishableKey, gatewayUrl, origin }); + const session = await client.createCheckoutSession({ + listingId: 'listing_1', + mode: 'buy', + }); + + await expect(session.refresh()).rejects.toEqual( + expect.objectContaining({ + name: 'PactoSessionError', + code: 'session_expired', + }), + ); + }); + + it('refreshes a checkout session', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + mockFetchResponse(200, { + sessionId: 'session_1', + clientSecret: 'cs_session_1_signature', + expiresAt: '2024-01-01T00:15:00.000Z', + mode: 'buy', + }) as Response, + ) + .mockResolvedValueOnce( + mockFetchResponse(200, { + sessionId: 'session_1', + clientSecret: 'cs_session_1_new_signature', + expiresAt: '2024-01-01T00:30:00.000Z', + mode: 'sell', + }) as Response, + ); + + const client = init({ publishableKey, gatewayUrl, origin }); + const session = await client.createCheckoutSession({ + listingId: 'listing_1', + mode: 'buy', + }); + const refreshed = await session.refresh(); + + expect(refreshed.clientSecret).toBe('cs_session_1_new_signature'); + expect(refreshed.mode).toBe('sell'); + }); +}); diff --git a/packages/connect-core/src/index.ts b/packages/connect-core/src/index.ts index 1b322c9..7d27b09 100644 --- a/packages/connect-core/src/index.ts +++ b/packages/connect-core/src/index.ts @@ -1,37 +1,192 @@ /** * @pacto-connect/core * - * Framework-agnostic SDK core. Scaffolding only — feature work lives in the issues: - * - #2 handshake + PactoSession - * - #3 typed REST client + idempotency - * - #4 realtime escrow events (SSE) + * Framework-agnostic SDK core for Pacto Connect. */ export const VERSION = '0.0.0'; +export type CheckoutMode = 'buy' | 'sell'; + export interface PactoInitOptions { /** Publishable key issued by the Connect Gateway (pk_live_* / pk_test_*). */ publishableKey: string; /** Gateway base URL. Defaults to the hosted Pacto Connect gateway. */ gatewayUrl?: string; + /** Origin header for non-browser environments. */ + origin?: string; +} + +export type CreateCheckoutSessionParams = + | { listingId: string; mode: CheckoutMode } + | { quote: Record; mode: CheckoutMode }; + +export interface PactoSessionData { + sessionId: string; + clientSecret: string; + expiresAt: Date; + mode: CheckoutMode; } export interface PactoClient { readonly publishableKey: string; readonly gatewayUrl: string; + createCheckoutSession(params: CreateCheckoutSessionParams): Promise; +} + +export class PactoError extends Error { + constructor( + public readonly type: string, + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'PactoError'; + } +} + +export class PactoSessionError extends PactoError { + constructor(code: 'session_invalid' | 'session_expired', message: string) { + super('session_error', code, message); + this.name = 'PactoSessionError'; + } +} + +interface GatewayErrorResponse { + error?: { + type?: string; + code?: string; + message?: string; + }; +} + +interface GatewaySessionResponse { + sessionId: string; + clientSecret: string; + expiresAt: string; + mode: CheckoutMode; } const DEFAULT_GATEWAY_URL = 'https://connect.pacto.example'; +const PUBLISHABLE_KEY_HEADER = 'x-pacto-publishable-key'; + +function isCheckoutMode(value: string): value is CheckoutMode { + return value === 'buy' || value === 'sell'; +} + +function parseGatewayError(body: GatewayErrorResponse, status: number): PactoError { + const code = body.error?.code ?? 'unknown_error'; + const type = body.error?.type ?? 'gateway_error'; + const message = body.error?.message ?? `Gateway request failed with status ${status}`; + + if (type === 'session_error' && (code === 'session_invalid' || code === 'session_expired')) { + return new PactoSessionError(code, message); + } + + return new PactoError(type, code, message); +} + +export class PactoSession { + readonly sessionId: string; + readonly clientSecret: string; + readonly expiresAt: Date; + readonly mode: CheckoutMode; + + constructor( + private readonly client: InternalPactoClient, + data: PactoSessionData, + ) { + this.sessionId = data.sessionId; + this.clientSecret = data.clientSecret; + this.expiresAt = data.expiresAt; + this.mode = data.mode; + } + + isExpired(): boolean { + return this.expiresAt.getTime() <= Date.now(); + } + + async refresh(): Promise { + const data = await this.client.refreshSession(this.clientSecret); + return new PactoSession(this.client, data); + } +} + +interface InternalPactoClient extends PactoClient { + refreshSession(clientSecret: string): Promise; +} + +function createGatewayClient(options: PactoInitOptions): InternalPactoClient { + const publishableKey = options.publishableKey; + const gatewayUrl = options.gatewayUrl ?? DEFAULT_GATEWAY_URL; + const origin = options.origin; + + async function requestSession( + path: string, + body: Record, + ): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + [PUBLISHABLE_KEY_HEADER]: publishableKey, + }; + + if (origin) { + headers.Origin = origin; + } + + const response = await fetch(`${gatewayUrl}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); -/** Entry point. Real session/handshake logic is implemented in issue #2. */ + const responseBody = (await response.json()) as GatewaySessionResponse & GatewayErrorResponse; + + if (!response.ok) { + throw parseGatewayError(responseBody, response.status); + } + + if ( + !responseBody.sessionId || + !responseBody.clientSecret || + !responseBody.expiresAt || + !isCheckoutMode(responseBody.mode) + ) { + throw new PactoError( + 'gateway_error', + 'invalid_response', + 'Gateway returned an invalid session payload', + ); + } + + return { + sessionId: responseBody.sessionId, + clientSecret: responseBody.clientSecret, + expiresAt: new Date(responseBody.expiresAt), + mode: responseBody.mode, + }; + } + + return { + publishableKey, + gatewayUrl, + async createCheckoutSession(params: CreateCheckoutSessionParams): Promise { + const data = await requestSession('/v1/session', params); + return new PactoSession(this, data); + }, + async refreshSession(clientSecret: string): Promise { + return requestSession('/v1/session/refresh', { clientSecret }); + }, + }; +} + +/** Entry point for the Pacto Connect SDK. */ export function init(options: PactoInitOptions): PactoClient { if (!options.publishableKey) { throw new Error('[pacto-connect] publishableKey is required'); } - return { - publishableKey: options.publishableKey, - gatewayUrl: options.gatewayUrl ?? DEFAULT_GATEWAY_URL, - }; + + return createGatewayClient(options); } export const Pacto = { init, VERSION }; diff --git a/packages/connect-react/src/index.ts b/packages/connect-react/src/index.ts index ae82513..eb677ef 100644 --- a/packages/connect-react/src/index.ts +++ b/packages/connect-react/src/index.ts @@ -4,4 +4,11 @@ * React bindings. Scaffolding only — the widget is issue #5. */ export const VERSION = '0.0.0'; -export type { PactoClient, PactoInitOptions } from '@pacto-connect/core'; +export type { + CheckoutMode, + CreateCheckoutSessionParams, + PactoClient, + PactoInitOptions, + PactoSessionData, +} from '@pacto-connect/core'; +export { PactoError, PactoSession, PactoSessionError } from '@pacto-connect/core';