Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 134 additions & 2 deletions packages/connect-core/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}

describe('@pacto-connect/core', () => {
it('exposes a version', () => {
Expand All @@ -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');
});
});
173 changes: 164 additions & 9 deletions packages/connect-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>; 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<PactoSession>;
}

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<PactoSession> {
const data = await this.client.refreshSession(this.clientSecret);
return new PactoSession(this.client, data);
}
}

interface InternalPactoClient extends PactoClient {
refreshSession(clientSecret: string): Promise<PactoSessionData>;
}

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<string, unknown>,
): Promise<PactoSessionData> {
const headers: Record<string, string> = {
'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<PactoSession> {
const data = await requestSession('/v1/session', params);
return new PactoSession(this, data);
},
async refreshSession(clientSecret: string): Promise<PactoSessionData> {
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 };
9 changes: 8 additions & 1 deletion packages/connect-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,11 @@
* React bindings. Scaffolding only — the <PactoCheckout/> 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';
3 changes: 3 additions & 0 deletions services/connect-gateway/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading