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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ on:
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: anchor
POSTGRES_PASSWORD: anchor
POSTGRES_DB: anchor_kit_smoke
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U anchor -d anchor_kit_smoke"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -43,3 +57,8 @@ jobs:

- name: Build
run: bun run build

- name: Node PostgreSQL package smoke test
run: bun run smoke:node-postgres
env:
DATABASE_URL: postgres://anchor:anchor@127.0.0.1:5432/anchor_kit_smoke
2 changes: 1 addition & 1 deletion ci/fallow-health-baseline.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"healthScore": 60,
"hotspotCount": 40
"hotspotCount": 41
}
16 changes: 12 additions & 4 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,15 +308,19 @@ framework: {
queue?: {
backend: 'memory', // currently only 'memory' supported
concurrency?: number, // optional, defaults to 1
maxPendingJobs?: number, // optional, defaults to unlimited
onError?: (job, error) => void | Promise<void>,
},
// ...
}
```

| Field | Type | Default | Status | Notes |
| ------------------- | ---------- | ---------- | -------------- | -------------------------------------------------------------------------------------- |
| `queue.backend` | `'memory'` | `'memory'` | ✅ Implemented | In-process task queue; retains at most 10,000 active task buckets |
| `queue.concurrency` | `number` | `1` | ✅ Implemented | Number of concurrent background workers; scales up to handle webhook and watcher loads |
| Field | Type | Default | Status | Notes |
| ---------------------- | ---------- | ---------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `queue.backend` | `'memory'` | `'memory'` | ✅ Implemented | In-process task queue; retains at most 10,000 active task buckets |
| `queue.concurrency` | `number` | `1` | ✅ Implemented | Number of concurrent background workers; scales up to handle webhook and watcher loads |
| `queue.maxPendingJobs` | `number` | unlimited | ✅ Implemented | Maximum waiting jobs; running jobs do not count toward the limit. Must be a positive safe integer. |
| `queue.onError` | function | — | ✅ Implemented | Called with the failed job and error when a worker throws. Without a handler, or if the handler throws, the queue logs the error and continues processing. |

**Example:**

Expand All @@ -325,11 +329,15 @@ framework: {
queue: {
backend: 'memory',
concurrency: 5,
maxPendingJobs: 100,
onError: (job, error) => logger.error({ job, error }, 'Background job failed'),
},
// ...
}
```

Focused check: `bun test tests/core/factory-background-startup.test.ts`. It must pass the public-factory assertions that enforce the configured pending limit and invoke `onError` after a worker failure.

---

### Watchers
Expand Down
31 changes: 31 additions & 0 deletions docs/database-migrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Database migrations

`SqlDatabaseAdapter` records applied migrations in `anchor_kit_migrations`. The core schema upgrades through ordered, stable migration names. Every migration runs in its own database transaction, and its record is written in that transaction only after `up` succeeds. A failed migration is rolled back and may be retried on the next `migrate()` call.

The built-in `core:001-initial-schema` migration creates the current tables and indexes without dropping or replacing existing data. `core:002-idempotency-status` adds the `status` column only when an older `idempotency_keys` table does not already contain it. This allows databases created before migration tracking to be adopted in place.

Plugins register append-only named migrations through `schema.migrations`:

```typescript
const plugin = {
id: 'ledger',
schema: {
migrations: [
{
name: '001-ledger-entries',
up: async ({ execute }) => {
await execute(
'CREATE TABLE IF NOT EXISTS ledger_entries (id TEXT PRIMARY KEY, reference TEXT NOT NULL)',
);
},
},
],
},
};

anchor.use(plugin);
```

The runtime stores each plugin migration as `plugin:<plugin-id>:<migration-name>`, with URI-encoded plugin IDs and migration names, after the core migrations and in plugin registration order. Migration names must be unique and must not be renamed or edited after release. `execute` runs SQL on the selected provider; `hasColumn` supports safe upgrades of existing tables. A plugin migration should use provider-specific SQL when the dialects differ.

Focused check: `bun test tests/runtime/database/versioned-migrations.test.ts`. It must pass the current-schema adoption and legacy upgrade data-preservation assertions, then prove a failed plugin migration leaves no partial schema or applied record and can be retried successfully.
26 changes: 14 additions & 12 deletions docs/implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,33 @@ This file tracks where the codebase currently aligns or diverges from the canoni
## Implemented Now (Runtime)

- SQL persistence through the `DatabaseAdapter` interface, with SQLite support for local development and tests and PostgreSQL support through the configured SQL client.
- Express HTTP adapter for health, info, authentication, transaction, and webhook routes, including request limits and rate limiting.
- Request deadline abort signals for route extensions and deposit hooks, with a single timeout response and pre-write deadline checks.
- Webhook processing with optional signature verification, event deduplication, persistence, callback dispatch, failure status updates, and explicit unresolved status for timed-out callbacks.
- Configured SEP-10 client-domain signatures are verified before token issuance and included in the token claim.
- Express HTTP routes cover health/info, standard and legacy SEP-10 authentication, interactive-deposit requests, transaction lookup, and webhook intake. Request limits, rate limiting, and request-deadline abort signals apply to runtime routes and deposit hooks. These handlers do not establish complete SEP wire-protocol conformance.
- Webhook processing supports optional signature verification, provider-scoped event deduplication, persistence, callback dispatch, failure status updates, and an unresolved status for timed-out callbacks.
- Configured SEP-10 client-domain signatures are verified before token issuance, and the client domain is included in the token claim.
- In-memory queue adapter for bounded background work and graceful shutdown.
- SQL-backed settlement job records with stable operation identity and reconnect-safe pending state.
- Transaction watcher lifecycle for polling pending transactions, persisting watcher tasks, and dispatching work through the queue.
- Typed KYC verification contract and deterministic in-memory mock provider for local development and tests; customer persistence and asset-policy enforcement are not yet connected.
- Plugin route registration and active deposit-request, SEP-10 challenge, and transaction-status hooks. The withdrawal-request hook is typed but is not invoked.
- Typed KYC verification contract and deterministic in-memory mock provider for local development and tests. Customer persistence is available; asset-policy enforcement is not connected.
- SEP-10 signer-weight verification primitive; account signer lookup and endpoint integration remain separate work.
- Provider-neutral payment rail adapter contract for initiation, event verification, status lookup, and recovery; no provider implementation is bundled.
- Provider-neutral payment rail adapter contract for initiation, event verification, status lookup, and recovery. No provider implementation is bundled.

## Planned Per Unified Spec (Not Yet Implemented)
## Protocol Conformance (Not Yet Implemented)

- Protocol modules: SEP-10, SEP-12, SEP-6, SEP-24 runtime flows, SEP-31, SEP-38.
- Adapter implementations: rail adapters, remote signer adapters, KYC provider adapters, and rate adapters. A local Stellar Keypair signer is implemented.
- Protocol transaction state-machine runtime and provider integrations.
- Complete protocol engines and conformance for SEP-6, SEP-12, SEP-24 deposit/withdrawal flows, SEP-31, and SEP-38. Existing HTTP routes, SEP-10 primitives, and protocol-related types provide partial foundation support, not complete protocol implementations.
- Provider adapters for rails, remote signing, KYC, and rates. A local Stellar Keypair signer is implemented.
- Protocol transaction state machines and provider integrations.

## Intentionally Deferred

- Runtime protocol engines and provider integrations listed above remain deferred while foundation APIs and typing guarantees stabilize.
- Empty module directories under `src/core/sep6`, `src/core/sep24`, and `src/core/sep31` are placeholders for the deferred protocol flows.
- `src/services` contains the implemented queue and watcher orchestration; future provider-specific services remain deferred.
- Directories under `src/core/sep6`, `src/core/sep24`, and `src/core/sep31` are reserved for future protocol engines; their presence does not indicate runtime conformance.
- Queue, watcher, and webhook orchestration already runs under `src/runtime`; `src/services` is reserved for future higher-level and provider-specific services.

## Drift Matrix

Focused documentation check: `bun test tests/readme-protocol-status.test.ts`. It must pass the assertions distinguishing the shared HTTP/plugin foundation from complete SEP protocol engines.

| Area | Status | Classification | Source Expectation | Current Code State | Action Taken |
| ---------------------------- | --------------- | -------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Root SDK API | Partial | Digression | Unified docs center API on `createAnchor` factory | Root package entrypoint exported only types before cleanup | Exported `createAnchor` and `AnchorInstance` from root entrypoints |
Expand Down
15 changes: 15 additions & 0 deletions docs/node-postgres-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Node PostgreSQL package smoke test

This focused consumer check runs on Node 22. It builds the package, packs and installs the tarball into a clean temporary Node project, imports `createAnchor` from the package's public entry point, initializes it against PostgreSQL, serves one `/health` request, and shuts down the server and anchor.

The database must be disposable because the test creates the SDK's migration metadata and core tables.

PowerShell:

```powershell
$env:DATABASE_URL = 'postgres://anchor:anchor@127.0.0.1:5432/anchor_kit_smoke'
bun run build
bun run smoke:node-postgres
```

Expected result: the script exits with code 0 and prints `Node PostgreSQL package smoke passed: public import, migration, HTTP /health, and shutdown.` The repository CI runs this check against a disposable PostgreSQL 16 service.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "rm -rf dist && bun build src/index.ts --outdir dist --target node --format esm --packages external && bunx tsc -p tsconfig.types.json && node scripts/fix-dts-imports.mjs",
"build": "rm -rf dist && bun x rolldown --config rolldown.config.mjs && bunx tsc -p tsconfig.types.json && node scripts/fix-dts-imports.mjs && node scripts/check-alias-imports.mjs",
"check:aliases": "node scripts/check-alias-imports.mjs",
"check:consumer": "node fixtures/consumer/verify.mjs && bun run check:package-consumer",
"check:package-consumer": "bun run build && node scripts/check-package-consumer.mjs",
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"check:pack": "node scripts/check-pack-contents.mjs",
"smoke:node-postgres": "node scripts/check-node-postgres-package.mjs",
"check:package-smoke": "node scripts/check-package-smoke.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
Expand Down
98 changes: 98 additions & 0 deletions scripts/check-node-postgres-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { execFileSync } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL must point to a disposable PostgreSQL database');
}

const tempDirectory = await mkdtemp(join(tmpdir(), 'anchor-kit-node-smoke-'));

try {
const packOutput = execFileSync(
'npm',
['pack', '--ignore-scripts', '--json', '--pack-destination', tempDirectory],
{ encoding: 'utf8' },
);
const packJsonStart = packOutput.indexOf('[');
if (packJsonStart < 0) {
throw new Error('npm pack did not return JSON metadata');
}
const [packedPackage] = JSON.parse(packOutput.slice(packJsonStart));
if (!packedPackage?.filename) {
throw new Error('npm pack did not return a tarball filename');
}

const tarballPath = join(tempDirectory, packedPackage.filename);
const consumerPath = join(tempDirectory, 'consumer.mjs');
await writeFile(
consumerPath,
`
import assert from 'node:assert/strict';
import express from 'express';
import { createAnchor } from 'anchor-kit';

const signingKey = 'SAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC5MY';
const anchor = createAnchor({
network: { network: 'testnet' },
server: { interactiveDomain: 'https://anchor.example.com' },
security: {
sep10SigningKey: signingKey,
interactiveJwtSecret: 'node-consumer-smoke-jwt-secret',
distributionAccountSecret: signingKey,
},
assets: {
assets: [{
code: 'USDC',
issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5',
}],
},
framework: {
database: { provider: 'postgres', url: process.env.DATABASE_URL },
watchers: { enabled: false },
},
});

await anchor.init();
const app = express();
app.use(anchor.getExpressRouter());
const server = app.listen(0, '127.0.0.1');
try {
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
});
const address = server.address();
assert.ok(address && typeof address === 'object');
const response = await fetch(\`http://127.0.0.1:\${address.port}/health\`, {
signal: AbortSignal.timeout(5000),
});
assert.equal(response.status, 200);
const health = await response.json();
assert.equal(health.status, 'ok');
assert.ok(typeof health.version === 'string' && health.version.length > 0);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
});
await anchor.shutdown();
}
`,
);

execFileSync('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarballPath], {
cwd: tempDirectory,
stdio: 'inherit',
});
execFileSync(process.execPath, [consumerPath], {
cwd: tempDirectory,
env: process.env,
stdio: 'inherit',
});
console.log(
'Node PostgreSQL package smoke passed: public import, migration, HTTP /health, and shutdown.',
);
} finally {
await rm(tempDirectory, { recursive: true, force: true });
}
2 changes: 2 additions & 0 deletions src/core/config-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ function buildFrameworkConfig(
queue: {
backend: input.framework.queue?.backend ?? 'memory',
concurrency: input.framework.queue?.concurrency ?? 1,
maxPendingJobs: input.framework.queue?.maxPendingJobs,
onError: input.framework.queue?.onError,
},
watchers: {
enabled: input.framework.watchers?.enabled ?? true,
Expand Down
34 changes: 25 additions & 9 deletions src/core/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import type {
Watcher,
WebhookProcessor,
} from '@/runtime/interfaces.ts';
import { InMemoryQueueAdapter } from '../runtime/queue/in-memory-queue.ts';
import { TransactionWatcher } from '../runtime/watchers/transaction-watcher.ts';
import { DefaultWebhookProcessor } from '../runtime/webhooks/default-webhook-processor.ts';
import type { AnchorKitConfig } from '../types/config.ts';
import { InMemoryQueueAdapter } from '@/runtime/queue/in-memory-queue.ts';
import { TransactionWatcher } from '@/runtime/watchers/transaction-watcher.ts';
import { DefaultWebhookProcessor } from '@/runtime/webhooks/default-webhook-processor.ts';
import type { SchemaMigration } from '@/types/foundation.ts';
import type { AnchorKitConfig } from '@/types/config.ts';
import type { AnchorPlugin, AnchorPluginHooks } from '@/types/plugin.ts';

export interface CreateAnchorOptions {
Expand Down Expand Up @@ -104,7 +105,11 @@ export class AnchorInstance {
}

const queueConcurrency = frameworkConfig.queue?.concurrency ?? 1;
this.queue = new InMemoryQueueAdapter({ concurrency: queueConcurrency });
this.queue = new InMemoryQueueAdapter({
concurrency: queueConcurrency,
maxPendingJobs: frameworkConfig.queue?.maxPendingJobs,
onError: frameworkConfig.queue?.onError,
});

this.webhookProcessor = new DefaultWebhookProcessor({
config: this.config.getConfig(),
Expand Down Expand Up @@ -280,10 +285,21 @@ export class AnchorInstance {
}

protected createDatabaseAdapter(): DatabaseAdapter {
return (
this.injectedDatabaseAdapter ??
createSqlDatabaseAdapter(this.config.get('framework').database)
);
if (this.injectedDatabaseAdapter) {
return this.injectedDatabaseAdapter;
}

const migrations: SchemaMigration[] = [];
for (const plugin of this.plugins.values()) {
for (const migration of plugin.schema?.migrations ?? []) {
migrations.push({
...migration,
name: `plugin:${encodeURIComponent(plugin.id)}:${encodeURIComponent(migration.name)}`,
});
}
}

return createSqlDatabaseAdapter(this.config.get('framework').database, migrations);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/core/sep24/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Planned Module Stub
# SEP-24 Module (Reserved)

This directory is a placeholder for SEP-24 runtime module implementation.
This directory is reserved for a future SEP-24 protocol engine. A complete SEP-24-compliant interactive deposit and withdrawal flow is not implemented here.

Current SEP-24 support in this repository is type-level (`src/types/sep24/**`) only.
The shared HTTP runtime currently provides an SDK-specific `POST /transactions/deposit/interactive` request path and `GET /transactions/{id}` lookup, along with persistence and plugin hooks. These endpoints are foundation infrastructure, not evidence of complete SEP-24 wire-protocol conformance. Check the current official SEP-24 specification before relying on wire behavior.
Loading
Loading