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
64 changes: 64 additions & 0 deletions .agent/context/20260907T145000Z-a02-durable-intents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Session Context: A02 durable intents and API

## Date/time

- UTC: 2026-09-07T14:50:00Z

## User goal

Implement Coder A's milestone A02: durable PostgreSQL intent ledger, transactional migrations,
canonical domain fingerprint, and Fastify HTTP boundary controls.

## Original prompt/request

Continue plan as Coder A and create PR without review gates.

## Assumptions

- A02 builds upon A01 contracts and foundation on `develop`.
- PostgreSQL is authoritative for Business Intents, Attempts, Settlement identity, evidence observations, and outbox jobs.
- Real provider wallets, settlement rails, and external indexes remain excluded (deferred to later milestones).
- Review gates A and B are waived per explicit user instruction ("PR сделай без гейтов").

## Plan

1. Verify and complete domain fingerprint normalization and validation.
2. Verify PostgreSQL transactional migrations, rollback handling, and constraint enforcement.
3. Verify atomic insert-or-replay, deduplication, conflict rejection, and query projections.
4. Implement full API boundary controls, service bearer authentication, and sanitized error mapping.
5. Provide synthetic database fixtures and document storage-v1 schema digest.
6. Run all local quality checks (lint, format, typecheck, contract checks, unit tests).
7. Commit, push branch to fork, and open PR targeting `develop` without gates.

## Key decisions

- Intent insertion is atomic using transactional insert with `ON CONFLICT DO NOTHING`.
- First attempt and authorize outbox job are enqueued only for new intents.
- Conflicting payload returns 409 `INTENT_PAYLOAD_CONFLICT` without enqueuing jobs.
- API boundary sanitizes all internal details; correlation IDs are preserved across calls.
- Storage V1 schema digest is published and tested against migrations.

## Files/components touched

- Workspace configs: `package.json`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `tsconfig.json`, `vitest.config.ts`, `.markdownlint-cli2.jsonc`.
- Workflows: `.github/workflows/stack-lint.yml` (added integration test step).
- `packages/domain`: canonical fingerprinting and normalization.
- `packages/storage-postgres`: transactional migrations, intent ledger, schema digest, synthetic fixtures.
- `apps/api`: Fastify boundary, authentication, rate limiting, and OpenAPI contract tests.

## Commands/checks

- `pnpm install --frozen-lockfile` - pass.
- `pnpm format:check` - pass.
- `pnpm lint` - pass.
- `pnpm typecheck` - pass.
- `pnpm check:generated` - pass.
- `pnpm validate:fixtures` - pass.
- `pnpm test` - pass, 7 files, 52 tests.
- `markdownlint-cli2` - pass, 54 files, 0 errors.
- `storage-v1` schema digest - `09b7fc0ce90a3db9dfd0437ae1abdac7260603154216b223116d0e123967d742`.

## Review gates

- Gate A: Waived by repository owner for this PR.
- Gate B: Waived by repository owner for this PR.
6 changes: 6 additions & 0 deletions .github/workflows/stack-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,9 @@ jobs:
- name: Run tests
if: steps.workspace.outputs.enabled == 'true'
run: pnpm test

- name: Run PostgreSQL integration tests
if: steps.workspace.outputs.enabled == 'true'
env:
TEST_POSTGRES: '1'
run: pnpm test:integration
2 changes: 1 addition & 1 deletion .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"MD041": false,
"MD060": false,
},
"ignores": [".git/**", "node_modules/**"],
"ignores": [".git/**", "**/node_modules/**"],
}
31 changes: 31 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@oneshot/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -b",
"clean": "tsc -b --clean",
"lint": "eslint src test",
"test": "vitest run --config vitest.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@oneshot/contracts": "workspace:*",
"@oneshot/storage-postgres": "workspace:*",
"fastify": "5.12.3"
},
"devDependencies": {
"@testcontainers/postgresql": "12.1.0",
"pg": "8.23.0"
}
}
193 changes: 193 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { randomUUID } from 'node:crypto';
import {
asCorrelationId,
ContractValidationError,
type ErrorCode,
type ErrorResponse,
} from '@oneshot/contracts';
import type { IntentLedger } from '@oneshot/storage-postgres';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import type { ServiceAuthenticator } from './auth.js';
import { allowAllRateLimiter, type RateLimiter } from './rate-limit.js';

export interface ApiDependencies {
readonly ledger: Pick<
IntentLedger,
'createOrReplay' | 'enqueueReconciliation' | 'getIntent' | 'getRecoveryView' | 'ping'
>;
readonly authenticator: ServiceAuthenticator;
readonly rateLimiter?: RateLimiter;
readonly nextCorrelationId?: () => string;
readonly bodyLimitBytes?: number;
}

const createIntentBodySchema = {
type: 'object',
additionalProperties: false,
required: ['business_intent_id', 'recipient', 'amount_atomic', 'asset', 'network', 'purpose'],
properties: {
business_intent_id: { type: 'string', minLength: 1, maxLength: 128 },
recipient: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' },
amount_atomic: { type: 'string', pattern: '^(0|[1-9][0-9]*)$', maxLength: 78 },
asset: { type: 'string', const: 'USDC' },
network: { type: 'string', const: 'eip155:5042002' },
purpose: { type: 'string', minLength: 1, maxLength: 256 },
},
} as const;

function sendError(
reply: FastifyReply,
status: number,
code: ErrorCode,
message: string,
correlationId: string,
): void {
const body: ErrorResponse = { code, message, correlation_id: correlationId };
void reply.code(status).send(body);
}

export function buildApi(dependencies: ApiDependencies) {
const app = Fastify({ bodyLimit: dependencies.bodyLimitBytes ?? 16 * 1024, logger: false });
const correlations = new WeakMap<FastifyRequest, string>();
const nextCorrelationId = dependencies.nextCorrelationId ?? randomUUID;
const rateLimiter = dependencies.rateLimiter ?? allowAllRateLimiter;

const correlationFor = (request: FastifyRequest): string => {
const existing = correlations.get(request);
if (existing) return existing;
const inbound = request.headers['x-correlation-id'];
const value = asCorrelationId(typeof inbound === 'string' ? inbound : nextCorrelationId());
correlations.set(request, value);
return value;
};

app.addHook('onRequest', async (request, reply) => {
let correlationId: string;
try {
correlationId = correlationFor(request);
} catch {
correlationId = asCorrelationId(nextCorrelationId());
correlations.set(request, correlationId);
sendError(reply, 400, 'INVALID_REQUEST', 'Invalid correlation identifier', correlationId);
return reply;
}
void reply.header('x-correlation-id', correlationId);
if (!request.url.startsWith('/v1/')) return;
const decision = await dependencies.authenticator.authenticate(request.headers.authorization);
if (decision !== 'AUTHORIZED') {
sendError(
reply,
decision === 'FORBIDDEN' ? 403 : 401,
decision === 'FORBIDDEN' ? 'FORBIDDEN' : 'UNAUTHORIZED',
'Service authentication failed',
correlationId,
);
return reply;
}
if (
request.method === 'POST' &&
!(await rateLimiter.allow({ correlationId, route: request.url }))
) {
sendError(reply, 429, 'RATE_LIMITED', 'Request rate limit exceeded', correlationId);
return reply;
}
});

app.post('/v1/intents', { schema: { body: createIntentBodySchema } }, async (request, reply) => {
const result = await dependencies.ledger.createOrReplay(request.body, correlationFor(request));
if (result.kind === 'INTENT_PAYLOAD_CONFLICT') {
sendError(
reply,
409,
'INTENT_PAYLOAD_CONFLICT',
'Business Intent already exists with a different immutable payload',
correlationFor(request),
);
return;
}
return reply.code(result.kind === 'ACCEPTED' ? 202 : 200).send(result.intent);
});

app.get<{ Params: { id: string } }>('/v1/intents/:id', async (request, reply) => {
const intent = await dependencies.ledger.getIntent(request.params.id);
if (!intent) {
sendError(
reply,
404,
'INTENT_NOT_FOUND',
'Business Intent was not found',
correlationFor(request),
);
return;
}
return intent;
});

app.post<{ Params: { id: string } }>('/v1/intents/:id/reconcile', async (request, reply) => {
const result = await dependencies.ledger.enqueueReconciliation(request.params.id);
if (!result) {
sendError(
reply,
404,
'INTENT_NOT_FOUND',
'Business Intent was not found',
correlationFor(request),
);
return;
}
if (!result.queued) {
sendError(
reply,
409,
'RECONCILIATION_NOT_ALLOWED',
'Intent state does not permit reconciliation',
correlationFor(request),
);
return;
}
return reply.code(202).send(result);
});

app.get<{ Params: { id: string } }>('/v1/intents/:id/recovery-view', async (request, reply) => {
const view = await dependencies.ledger.getRecoveryView(request.params.id);
if (!view) {
sendError(
reply,
404,
'INTENT_NOT_FOUND',
'Business Intent was not found',
correlationFor(request),
);
return;
}
return view;
});

app.get('/health/live', async () => ({ status: 'ok' as const }));

app.get('/health/ready', async (request, reply) => {
try {
await dependencies.ledger.ping();
return { status: 'ok' as const };
} catch {
sendError(reply, 503, 'NOT_READY', 'Database is unavailable', correlationFor(request));
return;
}
});

app.setErrorHandler((error, request, reply) => {
const correlationId = correlationFor(request);
const fastifyError = error as { readonly code?: string; readonly validation?: unknown };
if (
error instanceof ContractValidationError ||
fastifyError.validation ||
fastifyError.code === 'FST_ERR_CTP_BODY_TOO_LARGE'
) {
sendError(reply, 400, 'INVALID_REQUEST', 'Request failed validation', correlationId);
return;
}
sendError(reply, 500, 'INTERNAL_ERROR', 'Internal service error', correlationId);
});

return app;
}
20 changes: 20 additions & 0 deletions apps/api/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { timingSafeEqual } from 'node:crypto';

export type AuthenticationDecision = 'AUTHORIZED' | 'UNAUTHORIZED' | 'FORBIDDEN';

export interface ServiceAuthenticator {
authenticate(authorization: string | undefined): Promise<AuthenticationDecision>;
}

export function staticBearerAuthenticator(expectedToken: string): ServiceAuthenticator {
if (expectedToken.length === 0) throw new Error('service bearer token must not be empty');
const expected = Buffer.from(`Bearer ${expectedToken}`, 'utf8');
return {
async authenticate(authorization) {
if (!authorization) return 'UNAUTHORIZED';
const actual = Buffer.from(authorization, 'utf8');
if (actual.length !== expected.length) return 'UNAUTHORIZED';
return timingSafeEqual(actual, expected) ? 'AUTHORIZED' : 'FORBIDDEN';
},
};
}
3 changes: 3 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './app.js';
export * from './auth.js';
export * from './rate-limit.js';
14 changes: 14 additions & 0 deletions apps/api/src/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export interface RateLimitInput {
readonly correlationId: string;
readonly route: string;
}

export interface RateLimiter {
allow(input: RateLimitInput): Promise<boolean>;
}

export const allowAllRateLimiter: RateLimiter = {
async allow() {
return true;
},
};
Loading
Loading