Initial commit: AS4/411 directory and discovery service for Sankofa Marketplace
Some checks failed
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-02-08 08:44:20 -08:00
commit c24ae925cf
109 changed files with 7222 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
{
"name": "@as4-411/api-rest",
"type": "module",
"version": "0.1.0",
"description": "REST API for as4-411 resolver and admin",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"test": "node --test dist/**/*.test.js 2>/dev/null || true"
},
"dependencies": {
"@as4-411/core": "workspace:*",
"@as4-411/resolver": "workspace:*",
"@as4-411/storage": "workspace:*",
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18"
}
}

View File

@@ -0,0 +1,17 @@
import express from "express";
import { createResolverRouter } from "./routes/resolver.js";
import { createSystemRouter } from "./routes/system.js";
import { createAdminRouter } from "./routes/admin.js";
import type { Resolver } from "@as4-411/resolver";
import type { AdminStore } from "@as4-411/storage";
export function createApp(resolver: Resolver, adminStore?: AdminStore | null): express.Application {
const app = express();
app.use(express.json());
app.use(createResolverRouter(resolver));
app.use(createSystemRouter());
app.use(createAdminRouter(adminStore ?? null));
return app;
}

View File

@@ -0,0 +1,4 @@
export { createApp } from "./app.js";
export { createResolverRouter } from "./routes/resolver.js";
export { createSystemRouter } from "./routes/system.js";
export { createAdminRouter } from "./routes/admin.js";

View File

@@ -0,0 +1,276 @@
import { Router, type Request, type Response } from "express";
import type { AdminStore } from "@as4-411/storage";
import type {
Tenant,
Participant,
Identifier,
Endpoint,
CredentialRef,
Policy,
} from "@as4-411/core";
function randomId(): string {
return crypto.randomUUID();
}
/**
* Admin CRUD routes. If adminStore is not provided, returns 501.
* Paths match OpenAPI: /v1/admin/tenants, participants, identifiers, endpoints, credentials, policies.
*/
export function createAdminRouter(adminStore: AdminStore | null): Router {
const router = Router();
if (!adminStore) {
const notImplemented = (_req: Request, res: Response) => {
res.status(501).json({ error: "Admin API not implemented yet" });
};
router.get("/v1/admin/tenants", notImplemented);
router.post("/v1/admin/tenants", notImplemented);
router.get("/v1/admin/tenants/:tenantId", notImplemented);
router.put("/v1/admin/tenants/:tenantId", notImplemented);
router.delete("/v1/admin/tenants/:tenantId", notImplemented);
router.get("/v1/admin/participants", notImplemented);
router.post("/v1/admin/participants", notImplemented);
router.get("/v1/admin/participants/:participantId", notImplemented);
router.put("/v1/admin/participants/:participantId", notImplemented);
router.delete("/v1/admin/participants/:participantId", notImplemented);
router.get("/v1/admin/participants/:participantId/identifiers", notImplemented);
router.post("/v1/admin/participants/:participantId/identifiers", notImplemented);
router.get("/v1/admin/participants/:participantId/endpoints", notImplemented);
router.post("/v1/admin/participants/:participantId/endpoints", notImplemented);
router.get("/v1/admin/participants/:participantId/credentials", notImplemented);
router.post("/v1/admin/participants/:participantId/credentials", notImplemented);
router.get("/v1/admin/policies", notImplemented);
router.post("/v1/admin/policies", notImplemented);
router.get("/v1/admin/policies/:policyId", notImplemented);
router.put("/v1/admin/policies/:policyId", notImplemented);
router.delete("/v1/admin/policies/:policyId", notImplemented);
return router;
}
// Tenants
router.get("/v1/admin/tenants", async (_req: Request, res: Response) => {
try {
const list = await adminStore.listTenants();
res.json(list);
} catch (e) {
res.status(500).json({ error: String(e) });
}
});
router.post("/v1/admin/tenants", async (req: Request, res: Response) => {
try {
const body = req.body as Partial<Tenant>;
const id = body.id ?? randomId();
const name = body.name ?? "";
await adminStore.createTenant({ id, name });
res.status(201).json({ id, name });
} catch (e) {
res.status(400).json({ error: String(e) });
}
});
router.get("/v1/admin/tenants/:tenantId", async (req: Request, res: Response) => {
const t = await adminStore.getTenant(req.params.tenantId);
if (!t) return res.status(404).json({ error: "Not found" });
res.json(t);
});
router.put("/v1/admin/tenants/:tenantId", async (req: Request, res: Response) => {
const ok = await adminStore.updateTenant(req.params.tenantId, {
name: (req.body as { name?: string }).name ?? "",
});
if (!ok) return res.status(404).json({ error: "Not found" });
const t = await adminStore.getTenant(req.params.tenantId);
res.json(t);
});
router.delete("/v1/admin/tenants/:tenantId", async (req: Request, res: Response) => {
const ok = await adminStore.deleteTenant(req.params.tenantId);
if (!ok) return res.status(404).json({ error: "Not found" });
res.status(204).send();
});
// Participants
router.get("/v1/admin/participants", async (req: Request, res: Response) => {
try {
const tenantId = req.query.tenantId as string | undefined;
const list = await adminStore.listParticipants({ tenantId });
res.json(list);
} catch (e) {
res.status(500).json({ error: String(e) });
}
});
router.post("/v1/admin/participants", async (req: Request, res: Response) => {
try {
const body = req.body as Partial<Participant>;
const id = body.id ?? randomId();
const tenantId = body.tenantId ?? "";
const name = body.name ?? "";
await adminStore.createParticipant({ id, tenantId, name });
res.status(201).json({ id, tenantId, name });
} catch (e) {
res.status(400).json({ error: String(e) });
}
});
router.get("/v1/admin/participants/:participantId", async (req: Request, res: Response) => {
const p = await adminStore.getParticipant(req.params.participantId);
if (!p) return res.status(404).json({ error: "Not found" });
res.json(p);
});
router.put("/v1/admin/participants/:participantId", async (req: Request, res: Response) => {
const body = req.body as { name?: string };
const ok = await adminStore.updateParticipant(req.params.participantId, {
name: body.name ?? "",
});
if (!ok) return res.status(404).json({ error: "Not found" });
const p = await adminStore.getParticipant(req.params.participantId);
res.json(p);
});
router.delete("/v1/admin/participants/:participantId", async (req: Request, res: Response) => {
const ok = await adminStore.deleteParticipant(req.params.participantId);
if (!ok) return res.status(404).json({ error: "Not found" });
res.status(204).send();
});
// Identifiers (nested under participant)
router.get("/v1/admin/participants/:participantId/identifiers", async (req: Request, res: Response) => {
const list = await adminStore.getIdentifiersByParticipantId(req.params.participantId);
res.json(list);
});
router.post("/v1/admin/participants/:participantId/identifiers", async (req: Request, res: Response) => {
try {
const body = req.body as Partial<Identifier>;
const id = body.id ?? randomId();
const participantId = req.params.participantId;
const identifier: Identifier = {
id,
participantId,
identifier_type: body.identifier_type ?? "",
value: body.value ?? "",
scope: body.scope,
priority: body.priority ?? 0,
verified_at: body.verified_at,
};
await adminStore.createIdentifier(identifier);
res.status(201).json(identifier);
} catch (e) {
res.status(400).json({ error: String(e) });
}
});
// Endpoints
router.get("/v1/admin/participants/:participantId/endpoints", async (req: Request, res: Response) => {
const list = await adminStore.getEndpointsByParticipantId(req.params.participantId);
res.json(list);
});
router.post("/v1/admin/participants/:participantId/endpoints", async (req: Request, res: Response) => {
try {
const body = req.body as Partial<Endpoint>;
const id = body.id ?? randomId();
const participantId = req.params.participantId;
const endpoint: Endpoint = {
id,
participantId,
protocol: body.protocol ?? "",
address: body.address ?? "",
profile: body.profile,
priority: body.priority ?? 0,
status: body.status ?? "active",
};
await adminStore.createEndpoint(endpoint);
res.status(201).json(endpoint);
} catch (e) {
res.status(400).json({ error: String(e) });
}
});
// Credentials
router.get("/v1/admin/participants/:participantId/credentials", async (req: Request, res: Response) => {
const list = await adminStore.getCredentialsByParticipantId(req.params.participantId);
res.json(list);
});
router.post("/v1/admin/participants/:participantId/credentials", async (req: Request, res: Response) => {
try {
const body = req.body as Partial<CredentialRef>;
const id = body.id ?? randomId();
const participantId = req.params.participantId;
const credential: CredentialRef = {
id,
participantId,
credential_type: body.credential_type ?? "tls",
vault_ref: body.vault_ref ?? "",
fingerprint: body.fingerprint,
valid_from: body.valid_from,
valid_to: body.valid_to,
};
await adminStore.createCredential(credential);
res.status(201).json(credential);
} catch (e) {
res.status(400).json({ error: String(e) });
}
});
// Policies
router.get("/v1/admin/policies", async (req: Request, res: Response) => {
try {
const tenantId = req.query.tenantId as string | undefined;
const list = await adminStore.listPolicies({ tenantId });
res.json(list);
} catch (e) {
res.status(500).json({ error: String(e) });
}
});
router.post("/v1/admin/policies", async (req: Request, res: Response) => {
try {
const body = req.body as Partial<Policy>;
const id = body.id ?? randomId();
const tenantId = body.tenantId ?? "";
const policy: Policy = {
id,
tenantId,
rule_json: body.rule_json ?? {},
effect: body.effect ?? "allow",
priority: body.priority ?? 0,
};
await adminStore.createPolicy(policy);
res.status(201).json(policy);
} catch (e) {
res.status(400).json({ error: String(e) });
}
});
router.get("/v1/admin/policies/:policyId", async (req: Request, res: Response) => {
const p = await adminStore.getPolicy(req.params.policyId);
if (!p) return res.status(404).json({ error: "Not found" });
res.json(p);
});
router.put("/v1/admin/policies/:policyId", async (req: Request, res: Response) => {
const body = req.body as Partial<Policy>;
const ok = await adminStore.updatePolicy(req.params.policyId, {
rule_json: body.rule_json ?? {},
effect: body.effect ?? "allow",
priority: body.priority ?? 0,
});
if (!ok) return res.status(404).json({ error: "Not found" });
const p = await adminStore.getPolicy(req.params.policyId);
res.json(p);
});
router.delete("/v1/admin/policies/:policyId", async (req: Request, res: Response) => {
const ok = await adminStore.deletePolicy(req.params.policyId);
if (!ok) return res.status(404).json({ error: "Not found" });
res.status(204).send();
});
return router;
}

View File

@@ -0,0 +1,46 @@
import { Router, type Request, type Response } from "express";
import type { Resolver } from "@as4-411/resolver";
import type { ResolveRequest } from "@as4-411/core";
export function createResolverRouter(resolver: Resolver): Router {
const router = Router();
router.post("/v1/resolve", async (req: Request, res: Response) => {
try {
const body = req.body as ResolveRequest;
if (!body?.identifiers?.length) {
res.status(400).json({ error: "identifiers required and must be non-empty" });
return;
}
const result = await resolver.resolve(body);
res.json(result);
} catch (err) {
res.status(503).json({
error: "Resolution failed",
message: err instanceof Error ? err.message : String(err),
});
}
});
router.post("/v1/bulk-resolve", async (req: Request, res: Response) => {
try {
const { requests } = req.body as { requests?: ResolveRequest[] };
if (!Array.isArray(requests)) {
res.status(400).json({ error: "requests array required" });
return;
}
const results = await Promise.all(requests.map((r) => resolver.resolve(r)));
res.json({
results,
traceId: crypto.randomUUID(),
});
} catch (err) {
res.status(503).json({
error: "Bulk resolution failed",
message: err instanceof Error ? err.message : String(err),
});
}
});
return router;
}

View File

@@ -0,0 +1,20 @@
import { Router, type Request, type Response } from "express";
export function createSystemRouter(): Router {
const router = Router();
router.get("/v1/health", (_req: Request, res: Response) => {
res.json({
status: "ok",
version: "0.1.0",
checks: {},
});
});
router.get("/v1/metrics", (_req: Request, res: Response) => {
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.send("# as4-411 metrics (placeholder)\n");
});
return router;
}

View File

@@ -0,0 +1,15 @@
import { createApp } from "./app.js";
import { Resolver } from "@as4-411/resolver";
import { InMemoryDirectoryStore } from "@as4-411/storage";
import { InMemoryResolveCache } from "@as4-411/resolver";
const store = new InMemoryDirectoryStore();
const cache = new InMemoryResolveCache();
const resolver = new Resolver({ store, cache });
const app = createApp(resolver, store);
const port = Number(process.env.PORT) || 4110;
app.listen(port, () => {
console.log(`as4-411 API listening on http://localhost:${port}`);
});

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1 @@
{"name":"@as4-411/connector-file","type":"module","version":"0.1.0","main":"dist/index.js","types":"dist/index.d.ts","scripts":{"build":"tsc"},"dependencies":{"@as4-411/core":"workspace:*","@as4-411/storage":"workspace:*"},"devDependencies":{"typescript":"^5.3.0"}}

View File

@@ -0,0 +1,72 @@
import type { RoutingArtifactStore } from "@as4-411/storage";
import type { RoutingArtifact } from "@as4-411/core";
import type { BinTableEntry } from "@as4-411/core";
export interface BinTableIngestOptions {
tenantId?: string;
artifactId?: string;
effectiveFrom?: string;
effectiveTo?: string;
signature?: string;
fingerprint?: string;
}
export async function ingestBinTableFromJson(
artifactStore: RoutingArtifactStore,
data: { entries: BinTableEntry[]; version?: string },
options: BinTableIngestOptions = {}
): Promise<void> {
const id = options.artifactId ?? `bin_table_${Date.now()}`;
const payload = {
version: data.version ?? "1.0",
data: { entries: data.entries },
signature: options.signature,
fingerprint: options.fingerprint,
};
const artifact: RoutingArtifact = {
id,
tenantId: options.tenantId,
artifactType: "bin_table",
payload,
effectiveFrom: options.effectiveFrom ?? new Date().toISOString(),
effectiveTo: options.effectiveTo,
};
await artifactStore.put(artifact);
}
function parseCsvLine(line: string): string[] {
return line.split(",").map((c) => c.trim());
}
export async function ingestBinTableFromCsv(
artifactStore: RoutingArtifactStore,
csvText: string,
options: BinTableIngestOptions = {}
): Promise<void> {
const lines = csvText.split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) {
await ingestBinTableFromJson(artifactStore, { entries: [] }, options);
return;
}
const header = parseCsvLine(lines[0]).map((h) => h.toLowerCase().replace(/\s/g, ""));
const entries: BinTableEntry[] = [];
for (let i = 1; i < lines.length; i++) {
const values = parseCsvLine(lines[i]);
const row: Record<string, string> = {};
header.forEach((h, j) => {
row[h] = values[j] ?? "";
});
const binPrefix = row["binprefix"] ?? row["bin_prefix"] ?? "";
const routingTarget = row["routingtarget"] ?? row["routing_target"] ?? "";
if (!binPrefix || !routingTarget) continue;
entries.push({
binPrefix,
binLength: parseInt(row["binlength"] ?? row["bin_length"] ?? "6", 10) || 6,
brand: row["brand"] || undefined,
region: row["region"] || undefined,
routingTarget,
tenantId: (row["tenantid"] ?? row["tenant_id"]) || undefined,
});
}
await ingestBinTableFromJson(artifactStore, { entries }, options);
}

View File

@@ -0,0 +1,4 @@
export { ingestBinTableFromJson, ingestBinTableFromCsv } from "./bin-table.js";
export type { BinTableIngestOptions } from "./bin-table.js";
export { ingestSignedArtifact } from "./signed-artifact.js";
export type { SignedArtifactBundle } from "./signed-artifact.js";

View File

@@ -0,0 +1,41 @@
import type { RoutingArtifactStore } from "@as4-411/storage";
import type { RoutingArtifact, RoutingArtifactType } from "@as4-411/core";
import { isKnownArtifactType, validateArtifactPayload } from "@as4-411/core";
export interface SignedArtifactBundle {
id: string;
tenantId?: string;
artifactType: string;
payload: {
version: string;
data: unknown;
signature?: string;
fingerprint?: string;
};
effectiveFrom: string;
effectiveTo?: string;
}
/**
* Ingest a signed artifact bundle (e.g. from file or API). Validates type and payload shape, then persists.
*/
export async function ingestSignedArtifact(
artifactStore: RoutingArtifactStore,
bundle: SignedArtifactBundle
): Promise<void> {
if (!isKnownArtifactType(bundle.artifactType)) {
throw new Error(`Unknown artifact type: ${bundle.artifactType}`);
}
if (!validateArtifactPayload(bundle.artifactType as RoutingArtifactType, bundle.payload)) {
throw new Error(`Invalid payload for artifact type ${bundle.artifactType}`);
}
const artifact: RoutingArtifact = {
id: bundle.id,
tenantId: bundle.tenantId,
artifactType: bundle.artifactType as RoutingArtifact["artifactType"],
payload: bundle.payload,
effectiveFrom: bundle.effectiveFrom,
effectiveTo: bundle.effectiveTo,
};
await artifactStore.put(artifact);
}

View File

@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","rootDir":"src","strict":true,"declaration":true,"skipLibCheck":true},"include":["src/**/*"],"exclude":["node_modules","dist"]}

View File

@@ -0,0 +1 @@
{"name":"@as4-411/connector-ktt","type":"module","version":"0.1.0","main":"dist/index.js","types":"dist/index.d.ts","scripts":{"build":"tsc"},"dependencies":{"@as4-411/core":"workspace:*","@as4-411/storage":"workspace:*"},"devDependencies":{"typescript":"^5.3.0"}}

View File

@@ -0,0 +1,2 @@
export { ingestFromFile, ingestFromApi } from "./ingest.js";
export type { KttIngestFromFileOptions, KttIngestFromApiOptions } from "./ingest.js";

View File

@@ -0,0 +1,32 @@
import type { AdminStore } from "@as4-411/storage";
/**
* KTT connector placeholder. Ingest from file or API (stubs).
* When KTT is defined per sector, implement authoritative directory source and identifier formats.
*/
export interface KttIngestFromFileOptions {
tenantId?: string;
}
/** Placeholder: ingest from file (e.g. YAML/JSON). Stub implementation. */
export async function ingestFromFile(
_store: AdminStore,
_filePath: string,
_options?: KttIngestFromFileOptions
): Promise<{ participants: number; identifiers: number }> {
return { participants: 0, identifiers: 0 };
}
export interface KttIngestFromApiOptions {
tenantId?: string;
endpoint?: string;
}
/** Placeholder: ingest from API. Stub implementation. */
export async function ingestFromApi(
_store: AdminStore,
_options?: KttIngestFromApiOptions
): Promise<{ participants: number; identifiers: number }> {
return { participants: 0, identifiers: 0 };
}

View File

@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","rootDir":"src","strict":true,"declaration":true,"skipLibCheck":true},"include":["src/**/*"],"exclude":["node_modules","dist"]}

View File

@@ -0,0 +1,18 @@
{
"name": "@as4-411/core",
"type": "module",
"version": "0.1.0",
"description": "Domain model, validation, and policy types for as4-411",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js 2>/dev/null || true"
},
"devDependencies": {
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18"
}
}

View File

@@ -0,0 +1,78 @@
/**
* Protocol adapter plugin interface. Each rail implements this contract.
* See ADR-001 (adapter-interface-and-versioning).
*/
import type {
Participant,
Endpoint,
Capability,
Identifier,
RouteDirective,
ResolveRequest,
ServiceContext,
} from "./types.js";
/** Minimal read-only view supplied by the resolver (e.g. DirectoryStore). */
export interface AdapterContext {
findParticipantsByIdentifiers(
identifiers: Array<{ type: string; value: string }>,
options?: { tenantId?: string }
): Promise<Participant[]>;
getEndpointsByParticipantId(
participantId: string,
options?: { protocol?: string; status?: string }
): Promise<Endpoint[]>;
getCapabilitiesByParticipantId(participantId: string): Promise<Capability[]>;
}
/** A single candidate (participant + endpoint) produced by an adapter. */
export interface AdapterCandidate {
participant: Participant;
endpoint: Endpoint;
capability?: Capability;
identifier?: Identifier;
}
/** Result of optional ingest. */
export interface IngestResult {
participants: number;
identifiers: number;
endpoints?: number;
}
/** Options when rendering a directive (e.g. default TTL). */
export interface RenderDirectiveOptions {
defaultTtlSeconds?: number;
}
/**
* Protocol adapter: one per rail. Registry discovers/loads by protocol or identifier type.
* Semantic versioning of this interface: backward-compatible additions only (new optional methods or optional fields).
*/
export interface ProtocolAdapter {
/** Adapter semantic version (e.g. "1.0.0"). */
readonly version: string;
/** Protocol or rail name (e.g. "as4", "ss7", "iso8583"). */
readonly protocol: string;
validateIdentifier(type: string, value: string): boolean;
/** Return normalized value for storage/lookup, or null if invalid. */
normalizeIdentifier(type: string, value: string): string | null;
/** Return candidates for the request using the provided context. */
resolveCandidates(
ctx: AdapterContext,
request: ResolveRequest,
options?: { tenantId?: string }
): Promise<AdapterCandidate[]>;
/** Whether the candidate matches the service context (e.g. capability). */
evaluateCapabilities(candidate: AdapterCandidate, serviceContext?: ServiceContext): boolean;
/** Build a RouteDirective from a candidate. */
renderRouteDirective(candidate: AdapterCandidate, options?: RenderDirectiveOptions): RouteDirective;
/** Optional: ingest from external source (SMP, file, etc.). */
ingestSource?(config: unknown): Promise<IngestResult>;
}

View File

@@ -0,0 +1,4 @@
export * from "./types.js";
export * from "./validation.js";
export * from "./protocol_registry/index.js";
export * from "./adapter-interface.js";

View File

@@ -0,0 +1,97 @@
/**
* Routing artifact type definitions and payload shapes.
* Versioned, optionally signed.
*/
import type { RoutingArtifactType, RoutingArtifactPayload } from "./types.js";
export interface BinTableEntry {
binPrefix: string;
binLength: number;
brand?: string;
region?: string;
routingTarget: string;
tenantId?: string;
}
export interface BinTablePayload {
version: string;
data: { entries: BinTableEntry[] };
signature?: string;
fingerprint?: string;
}
export interface GttTableEntry {
globalTitle: string;
pointCode?: string;
ssn?: string;
translationType?: string;
}
export interface GttTablePayload {
version: string;
data: { entries: GttTableEntry[] };
signature?: string;
fingerprint?: string;
}
export interface ParticipantMapEntry {
identifierType: string;
identifierValue: string;
participantId: string;
endpointId?: string;
}
export interface ParticipantMapPayload {
version: string;
data: { entries: ParticipantMapEntry[] };
signature?: string;
fingerprint?: string;
}
export interface FallbackRule {
match: Record<string, unknown>;
targetParticipantId?: string;
targetEndpointId?: string;
priority: number;
}
export interface FallbackRulesPayload {
version: string;
data: { rules: FallbackRule[] };
signature?: string;
fingerprint?: string;
}
export const ARTIFACT_TYPES: RoutingArtifactType[] = [
"bin_table",
"gtt_table",
"participant_map",
"fallback_rules",
];
export function isKnownArtifactType(t: string): t is RoutingArtifactType {
return ARTIFACT_TYPES.includes(t as RoutingArtifactType);
}
/**
* Validate payload shape for artifact type. Returns true if valid or type unknown.
*/
export function validateArtifactPayload(
artifactType: RoutingArtifactType,
payload: RoutingArtifactPayload
): boolean {
if (!payload?.version || typeof payload.data !== "object") return false;
switch (artifactType) {
case "bin_table":
return Array.isArray((payload.data as { entries?: unknown }).entries);
case "gtt_table":
return Array.isArray((payload.data as { entries?: unknown }).entries);
case "participant_map":
return Array.isArray((payload.data as { entries?: unknown }).entries);
case "fallback_rules":
return Array.isArray((payload.data as { rules?: unknown }).rules);
default:
return true;
}
}

View File

@@ -0,0 +1,3 @@
export * from "./types.js";
export * from "./validators.js";
export * from "./artifacts.js";

View File

@@ -0,0 +1,39 @@
/** Rail profile and routing artifact types. */
export type ProtocolFamily =
| "as4"
| "ss7"
| "iso8583"
| "mq"
| "sftp"
| "api"
| "https";
export interface RailProfile {
name: string;
protocolFamily: ProtocolFamily;
addressPattern?: string | RegExp;
allowedIdentifierTypes?: string[];
}
export type RoutingArtifactType =
| "bin_table"
| "gtt_table"
| "participant_map"
| "fallback_rules";
export interface RoutingArtifactPayload {
version: string;
data: unknown;
signature?: string;
fingerprint?: string;
}
export interface RoutingArtifact {
id: string;
tenantId?: string;
artifactType: RoutingArtifactType;
payload: RoutingArtifactPayload;
effectiveFrom: string;
effectiveTo?: string;
}

View File

@@ -0,0 +1,36 @@
import type { RailProfile } from "./types.js";
const BUILTIN: RailProfile[] = [
{ name: "peppol-as4", protocolFamily: "as4", allowedIdentifierTypes: ["as4.partyId", "peppol.participantId"] },
{ name: "visa-base1", protocolFamily: "iso8583", allowedIdentifierTypes: ["pan.bin", "mid", "tid"] },
{ name: "dtcc-mq", protocolFamily: "mq", allowedIdentifierTypes: ["lei", "bic", "dtc.participantId"] },
{ name: "as4", protocolFamily: "as4", allowedIdentifierTypes: ["as4.partyId"] },
{ name: "ss7", protocolFamily: "ss7", allowedIdentifierTypes: ["e164", "gt", "pc", "ssn"] },
];
const registry = new Map<string, RailProfile>(BUILTIN.map((p) => [p.name, p]));
export function registerProfile(profile: RailProfile): void {
registry.set(profile.name, profile);
}
export function getProfile(name: string): RailProfile | undefined {
return registry.get(name);
}
export function listProfiles(): RailProfile[] {
return Array.from(registry.values());
}
export function validateAddressForProfile(profileName: string, address: string): boolean {
const profile = registry.get(profileName);
if (!profile?.addressPattern) return true;
const re = typeof profile.addressPattern === "string" ? new RegExp(profile.addressPattern) : profile.addressPattern;
return re.test(address);
}
export function isIdentifierTypeAllowed(profileName: string, identifierType: string): boolean {
const profile = registry.get(profileName);
if (!profile?.allowedIdentifierTypes) return true;
return profile.allowedIdentifierTypes.includes(identifierType);
}

181
packages/core/src/types.ts Normal file
View File

@@ -0,0 +1,181 @@
/**
* Domain types aligned with OpenAPI and data-model.
* No I/O; pure domain and validation.
*/
export interface Tenant {
id: string;
name: string;
createdAt?: string;
updatedAt?: string;
}
export interface Participant {
id: string;
tenantId: string;
name: string;
createdAt?: string;
updatedAt?: string;
}
export interface Identifier {
id: string;
participantId: string;
identifier_type: string;
value: string;
scope?: string;
priority: number;
verified_at?: string;
}
export interface Endpoint {
id: string;
participantId: string;
protocol: string;
address: string;
profile?: string;
priority: number;
status: "active" | "inactive" | "draining";
}
export interface Capability {
id: string;
participantId: string;
service?: string;
action?: string;
process?: string;
document_type?: string;
constraints_json?: Record<string, unknown>;
}
export interface CredentialRef {
id: string;
participantId: string;
credential_type: "tls" | "sign" | "encrypt";
vault_ref: string;
fingerprint?: string;
valid_from?: string;
valid_to?: string;
}
export interface Policy {
id: string;
tenantId: string;
rule_json: Record<string, unknown>;
effect: "allow" | "deny";
priority: number;
}
/** Input identifier for resolve request */
export interface IdentifierInput {
type: string;
value: string;
scope?: string;
}
export interface ServiceContext {
service?: string;
action?: string;
process?: string;
documentType?: string;
}
export interface ResolveConstraints {
trustDomain?: string;
region?: string;
jurisdiction?: string;
maxResults?: number;
/** Card network brand: visa, mastercard, amex, discover, diners */
networkBrand?: string;
/** Tenant contract or connectivity group for per-tenant/contract routing */
tenantContract?: string;
connectivityGroup?: string;
/** Explicit required capability filter */
requiredCapability?: string;
/** Message type (e.g. ISO8583 MTI or AS4 service/action) */
messageType?: string;
}
export interface ResolveRequest {
identifiers: IdentifierInput[];
serviceContext?: ServiceContext;
constraints?: ResolveConstraints;
tenant?: string;
desiredProtocols?: string[];
}
/** Security block in RouteDirective */
export interface RouteDirectiveSecurity {
signRequired?: boolean;
encryptRequired?: boolean;
keyRefs?: string[];
algorithms?: Record<string, string>;
}
/** Service context in RouteDirective */
export interface RouteDirectiveServiceContext {
service?: string;
action?: string;
serviceIndicator?: string;
}
/** QoS in RouteDirective */
export interface RouteDirectiveQos {
retries?: number;
receiptsRequired?: boolean;
ordering?: string;
}
/** Evidence in RouteDirective (single or array for multiple sources) */
export interface RouteDirectiveEvidence {
source?: string;
lastVerified?: string;
confidenceScore?: number;
signature?: string;
}
export interface RouteDirective {
target_protocol: string;
target_address: string;
transport_profile?: string;
security?: RouteDirectiveSecurity;
service_context?: RouteDirectiveServiceContext;
qos?: RouteDirectiveQos;
ttl_seconds?: number;
evidence?: RouteDirectiveEvidence | RouteDirectiveEvidence[];
}
/** Directive with optional reason (for alternates) */
export interface DirectiveWithReason {
directive: RouteDirective;
reason?: string;
}
/** Advisory failure policy for gateway */
export interface FailurePolicy {
retry?: boolean;
backoff?: string;
circuitBreak?: boolean;
}
/** Entry in resolution trace (which source contributed) */
export interface ResolutionTraceEntry {
source: string;
directiveIndex?: number;
message?: string;
}
export interface ResolveResponse {
/** Best match; when set, directives[0] should equal primary for backward compat */
primary?: RouteDirective;
/** Ordered fallback directives with reason */
alternates?: DirectiveWithReason[];
directives: RouteDirective[];
ttl?: number;
traceId?: string;
correlationId?: string;
failure_policy?: FailurePolicy;
/** TTL for negative (no-match) cache in seconds */
negative_cache_ttl?: number;
resolution_trace?: ResolutionTraceEntry[];
}

View File

@@ -0,0 +1,107 @@
/**
* Identifier validation helpers (stubs / minimal regex-based).
* Aligned with identifier types in data-model.md.
*/
/** E.164: optional +, digits only, typically up to 15 digits */
const E164_REGEX = /^\+?[1-9]\d{1,14}$/;
/** AS4 PartyId: common pattern scheme:value (e.g. 0088:123456789) */
const PARTY_ID_REGEX = /^[^:]+:[^:]+$/;
/** PEPPOL participant ID: same format as PartyId (ISO 6523) */
const PEPPOL_PARTICIPANT_REGEX = /^[0-9]{4}:[a-zA-Z0-9]+$/;
/** Point code: numeric, format depends on variant (ITU 14-bit, ANSI 24-bit); accept digits and dots */
const PC_REGEX = /^[\d.]+$/;
/** SSN: 1-255 */
const SSN_REGEX = /^(?:25[0-5]|2[0-4]\d|1?\d{1,2})$/;
/** KTT (placeholder rail): alphanumeric, optional separators */
const KTT_ID_REGEX = /^[a-zA-Z0-9._-]+$/;
/** BIN/IIN: 6-8 digits only (never full PAN) */
const PAN_BIN_REGEX = /^\d{6,8}$/;
/** Merchant ID / Terminal ID: alphanumeric, tenant-scoped format */
const MID_TID_REGEX = /^[a-zA-Z0-9]+$/;
/** LEI: 20 alphanumeric */
const LEI_REGEX = /^[A-Z0-9]{20}$/;
/** BIC: 8 or 11 alphanumeric */
const BIC_REGEX = /^[A-Za-z0-9]{8}([A-Za-z0-9]{3})?$/;
/** DTC participant/account ID: alphanumeric, tenant-scoped */
const DTC_ID_REGEX = /^[a-zA-Z0-9._-]+$/;
export function validateE164(value: string): boolean {
return typeof value === "string" && E164_REGEX.test(value.trim());
}
export function validateAs4PartyId(value: string): boolean {
return typeof value === "string" && value.length > 0 && PARTY_ID_REGEX.test(value.trim());
}
export function validatePeppolParticipantId(value: string): boolean {
return typeof value === "string" && PEPPOL_PARTICIPANT_REGEX.test(value.trim());
}
export function validatePointCode(value: string): boolean {
return typeof value === "string" && PC_REGEX.test(value.trim());
}
export function validateSsn(value: string): boolean {
return typeof value === "string" && SSN_REGEX.test(value.trim());
}
export function validateKttId(value: string): boolean {
return typeof value === "string" && value.length > 0 && KTT_ID_REGEX.test(value.trim());
}
export function validatePanBin(value: string): boolean {
return typeof value === "string" && PAN_BIN_REGEX.test(value.replace(/\D/g, ""));
}
export function validateMidOrTid(value: string): boolean {
return typeof value === "string" && value.length > 0 && MID_TID_REGEX.test(value.trim());
}
export function validateLei(value: string): boolean {
return typeof value === "string" && LEI_REGEX.test(value.trim());
}
export function validateBic(value: string): boolean {
return typeof value === "string" && BIC_REGEX.test(value.trim());
}
export function validateDtcId(value: string): boolean {
return typeof value === "string" && value.length > 0 && DTC_ID_REGEX.test(value.trim());
}
const VALIDATORS: Record<string, (v: string) => boolean> = {
e164: validateE164,
"as4.partyId": validateAs4PartyId,
"peppol.participantId": validatePeppolParticipantId,
pc: validatePointCode,
ssn: validateSsn,
"ktt.id": validateKttId,
"ktt.participantId": validateKttId,
"pan.bin": validatePanBin,
mid: validateMidOrTid,
tid: validateMidOrTid,
lei: validateLei,
bic: validateBic,
"dtc.participantId": validateDtcId,
"dtc.accountId": validateDtcId,
};
/**
* Validate an identifier by type. Returns true if valid or type has no validator (permissive).
*/
export function validateIdentifier(type: string, value: string): boolean {
const fn = VALIDATORS[type];
if (!fn) return typeof value === "string" && value.length > 0;
return fn(value);
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,22 @@
{
"name": "@as4-411/resolver",
"type": "module",
"version": "0.1.0",
"description": "Resolution pipeline and caching for as4-411",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js 2>/dev/null || true"
},
"dependencies": {
"@as4-411/core": "workspace:*",
"@as4-411/storage": "workspace:*"
},
"devDependencies": {
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18"
}
}

View File

@@ -0,0 +1,49 @@
import type { ResolveRequest, ResolveResponse, RouteDirective } from "@as4-411/core";
import type { RoutingArtifactStore } from "@as4-411/storage";
import type { BinTableEntry } from "@as4-411/core";
/**
* Try to resolve using a routing artifact (e.g. BIN table). Returns directives if found, else null.
*/
export async function tryArtifactResolution(
request: ResolveRequest,
artifactStore: RoutingArtifactStore,
defaultTtlSeconds: number
): Promise<ResolveResponse | null> {
const binId = request.identifiers.find((i) => i.type === "pan.bin");
if (!binId?.value) return null;
const artifact = await artifactStore.get("bin_table", {
tenantId: request.tenant ?? undefined,
});
if (!artifact?.payload?.data) return null;
const data = artifact.payload.data as { entries?: BinTableEntry[] };
const entries = data.entries;
if (!Array.isArray(entries) || entries.length === 0) return null;
const binValue = String(binId.value).replace(/\D/g, "").slice(0, 12);
const entry = entries.find((e) => {
const prefix = String(e.binPrefix).replace(/\D/g, "");
const len = e.binLength ?? prefix.length;
return binValue.startsWith(prefix) && binValue.length >= len;
});
if (!entry) return null;
const directive: RouteDirective = {
target_protocol: "iso8583",
target_address: entry.routingTarget,
transport_profile: "bin_table",
ttl_seconds: defaultTtlSeconds,
evidence: {
source: "routing_artifact",
confidenceScore: 0.9,
},
};
return {
directives: [directive],
ttl: defaultTtlSeconds,
traceId: crypto.randomUUID(),
};
}

View File

@@ -0,0 +1,43 @@
import type { ResolveRequest, ResolveResponse } from "@as4-411/core";
export interface ResolveCache {
get(key: string): Promise<ResolveResponse | null>;
set(key: string, value: ResolveResponse, ttlSeconds: number): Promise<void>;
delete(key: string): Promise<void>;
}
export function cacheKey(request: ResolveRequest): string {
const ids = request.identifiers
.map((i) => `${i.type}:${i.value}:${i.scope ?? ""}`)
.sort()
.join("|");
const ctx = request.serviceContext ? JSON.stringify(request.serviceContext) : "";
const constraints = request.constraints ? JSON.stringify(request.constraints) : "";
const tenant = request.tenant ?? "";
return `resolve:${tenant}:${ids}:${ctx}:${constraints}`;
}
export class InMemoryResolveCache implements ResolveCache {
private store = new Map<string, { value: ResolveResponse; expiresAt: number }>();
async get(key: string): Promise<ResolveResponse | null> {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
async set(key: string, value: ResolveResponse, ttlSeconds: number): Promise<void> {
this.store.set(key, {
value,
expiresAt: Date.now() + ttlSeconds * 1000,
});
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
}

View File

@@ -0,0 +1,5 @@
export { Resolver } from "./resolver.js";
export type { ResolverOptions } from "./resolver.js";
export { cacheKey, InMemoryResolveCache } from "./cache.js";
export type { ResolveCache } from "./cache.js";
export * from "./pipeline.js";

View File

@@ -0,0 +1,146 @@
import type {
ResolveRequest,
RouteDirective,
Participant,
Endpoint,
Identifier,
Capability,
Policy,
} from "@as4-411/core";
import type { DirectoryStore } from "@as4-411/storage";
import { validateIdentifier } from "@as4-411/core";
export interface PipelineContext {
request: ResolveRequest;
normalizedIdentifiers: Array<{ type: string; value: string; scope?: string }>;
candidates: Array<{
participant: Participant;
endpoint: Endpoint;
identifier?: Identifier;
capability?: Capability;
}>;
policies: Policy[];
directives: RouteDirective[];
}
/** Step 1: Normalize and validate identifiers */
export function normalizeInput(request: ResolveRequest): PipelineContext["normalizedIdentifiers"] {
const out: Array<{ type: string; value: string; scope?: string }> = [];
for (const id of request.identifiers) {
const value = String(id.value).trim();
if (!value) continue;
if (!validateIdentifier(id.type, value)) continue;
out.push({ type: id.type, value, scope: id.scope });
}
return out;
}
/** Step 2: Expand context — for MVP we use the same set; equivalence graph can be added later */
export function expandContext(
normalized: PipelineContext["normalizedIdentifiers"]
): Array<{ type: string; value: string }> {
return normalized.map((n) => ({ type: n.type, value: n.value }));
}
/** Step 3: Candidate retrieval */
export async function retrieveCandidates(
store: DirectoryStore,
identifierPairs: Array<{ type: string; value: string }>,
tenantId?: string
): Promise<Participant[]> {
return store.findParticipantsByIdentifiers(identifierPairs, { tenantId });
}
/** Step 4: Capability filter — keep participants that match service context */
export async function filterByCapability(
store: DirectoryStore,
participantIds: string[],
service?: string,
action?: string
): Promise<Set<string>> {
const allowed = new Set<string>();
for (const pid of participantIds) {
const caps = await store.getCapabilitiesByParticipantId(pid);
if (caps.length === 0) {
allowed.add(pid);
continue;
}
const match = caps.some((c) => {
if (service != null && c.service !== service) return false;
if (action != null && c.action !== action) return false;
return true;
});
if (match) allowed.add(pid);
}
return allowed;
}
/** Step 5: Policy filter — tenant scoping and allow/deny by participant or identifier type */
export function filterByPolicy(participants: Participant[], policies: Policy[]): Participant[] {
const denyRules = policies.filter((p) => p.effect === "deny");
const allowRules = policies.filter((p) => p.effect === "allow");
let out = participants;
// Deny: exclude participants listed in deny rule_json.participantId or rule_json.participantIds
if (denyRules.length > 0) {
const deniedIds = new Set<string>();
for (const r of denyRules) {
const j = r.rule_json ?? {};
if (typeof j.participantId === "string") deniedIds.add(j.participantId as string);
if (Array.isArray(j.participantIds))
(j.participantIds as string[]).forEach((id) => deniedIds.add(id));
}
out = out.filter((p) => !deniedIds.has(p.id));
}
// Allow (restrictive): if any allow rules exist, only include participants matching at least one
if (allowRules.length > 0) {
const allowedIds = new Set<string>();
for (const r of allowRules) {
const j = r.rule_json ?? {};
if (typeof j.participantId === "string") allowedIds.add(j.participantId as string);
if (Array.isArray(j.participantIds))
(j.participantIds as string[]).forEach((id) => allowedIds.add(id));
}
if (allowedIds.size > 0) out = out.filter((p) => allowedIds.has(p.id));
}
return out;
}
/** Step 6: Score and rank (deterministic). Higher score first; tie-break: priority DESC, id ASC */
export function scoreAndRank(
candidates: PipelineContext["candidates"]
): PipelineContext["candidates"] {
return [...candidates].sort((a, b) => {
let scoreA = a.endpoint.priority ?? 0;
let scoreB = b.endpoint.priority ?? 0;
if (a.endpoint.status === "active") scoreA += 100;
if (b.endpoint.status === "active") scoreB += 100;
if (a.endpoint.status === "draining") scoreA += 50;
if (b.endpoint.status === "draining") scoreB += 50;
if (scoreA !== scoreB) return scoreB - scoreA;
const idCmp = (a.endpoint.id ?? "").localeCompare(b.endpoint.id ?? "");
if (idCmp !== 0) return idCmp;
return (a.participant.id ?? "").localeCompare(b.participant.id ?? "");
});
}
/** Step 7: Assemble directives from ranked candidates */
export function assembleDirectives(
candidates: PipelineContext["candidates"],
defaultTtlSeconds: number
): RouteDirective[] {
const maxResults = 10;
return candidates.slice(0, maxResults).map((c) => ({
target_protocol: c.endpoint.protocol,
target_address: c.endpoint.address,
transport_profile: c.endpoint.profile,
ttl_seconds: defaultTtlSeconds,
evidence: {
source: "directory",
confidenceScore: 1,
},
}));
}

View File

@@ -0,0 +1,166 @@
import type { ResolveRequest, ResolveResponse } from "@as4-411/core";
import type { DirectoryStore, RoutingArtifactStore } from "@as4-411/storage";
import { cacheKey } from "./cache.js";
import type { ResolveCache } from "./cache.js";
import { tryArtifactResolution } from "./artifact-resolve.js";
import {
normalizeInput,
expandContext,
retrieveCandidates,
filterByCapability,
filterByPolicy,
scoreAndRank,
assembleDirectives,
} from "./pipeline.js";
const DEFAULT_TTL_SECONDS = 300;
export interface ResolverOptions {
store: DirectoryStore;
cache?: ResolveCache;
artifactStore?: RoutingArtifactStore;
defaultTtlSeconds?: number;
}
/**
* Resolver: runs the resolution pipeline and optionally caches results.
* Same inputs + same store state => stable ordering (see resolution-algorithm.md).
*/
export class Resolver {
constructor(private readonly options: ResolverOptions) {}
async resolve(request: ResolveRequest): Promise<ResolveResponse> {
const traceId = crypto.randomUUID();
const cache = this.options.cache;
const key = cacheKey(request);
if (cache) {
const cached = await cache.get(key);
if (cached) {
return { ...cached, traceId };
}
}
// 1. Normalize input
const normalized = normalizeInput(request);
if (normalized.length === 0) {
const empty: ResolveResponse = {
directives: [],
ttl: 0,
traceId,
negative_cache_ttl: 60,
};
if (cache) await cache.set(key, empty, 60);
return empty;
}
// 1b. Artifact-based resolution (e.g. BIN table)
const ttl = this.options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS;
if (this.options.artifactStore) {
const artifactResponse = await tryArtifactResolution(
request,
this.options.artifactStore,
ttl
);
if (artifactResponse && artifactResponse.directives.length > 0) {
const dirs = artifactResponse.directives;
const out: ResolveResponse = {
...artifactResponse,
traceId,
primary: dirs[0],
alternates: dirs.slice(1).map((d) => ({ directive: d, reason: "fallback" })),
resolution_trace: [{ source: "routing_artifact" }],
};
if (cache) await cache.set(key, out, ttl);
return out;
}
}
// 2. Expand context
const identifierPairs = expandContext(normalized);
// 3. Candidate retrieval
const participants = await retrieveCandidates(
this.options.store,
identifierPairs,
request.tenant
);
if (participants.length === 0) {
const empty: ResolveResponse = {
directives: [],
ttl: 60,
traceId,
negative_cache_ttl: 60,
};
if (cache) await cache.set(key, empty, 60);
return empty;
}
// 4. Capability filter
const service = request.serviceContext?.service;
const action = request.serviceContext?.action;
const allowedParticipantIds = await filterByCapability(
this.options.store,
participants.map((p) => p.id),
service,
action
);
const allowedParticipants = participants.filter((p) => allowedParticipantIds.has(p.id));
if (allowedParticipants.length === 0) {
const empty: ResolveResponse = {
directives: [],
ttl: 60,
traceId,
negative_cache_ttl: 60,
};
if (cache) await cache.set(key, empty, 60);
return empty;
}
// 5. Policy filter
const tenantId = request.tenant ?? allowedParticipants[0]?.tenantId;
const policies = tenantId ? await this.options.store.getPoliciesByTenantId(tenantId) : [];
const policyFiltered = filterByPolicy(allowedParticipants, policies);
// Build candidate list: participant + endpoint
const candidates: Array<{
participant: (typeof policyFiltered)[0];
endpoint: import("@as4-411/core").Endpoint;
identifier?: import("@as4-411/core").Identifier;
capability?: import("@as4-411/core").Capability;
}> = [];
for (const participant of policyFiltered) {
const endpoints = await this.options.store.getEndpointsByParticipantId(participant.id, {
status: "active",
});
if (endpoints.length === 0) {
const anyEndpoints = await this.options.store.getEndpointsByParticipantId(participant.id);
for (const ep of anyEndpoints) {
candidates.push({ participant, endpoint: ep });
}
} else {
for (const ep of endpoints) {
candidates.push({ participant, endpoint: ep });
}
}
}
// 6. Score and rank
const ranked = scoreAndRank(candidates);
// 7. Assemble directives
const directives = assembleDirectives(ranked, ttl);
const response: ResolveResponse = {
directives,
ttl,
traceId,
primary: directives[0],
alternates: directives.slice(1).map((d) => ({ directive: d, reason: "priority" })),
resolution_trace: [{ source: "internal directory" }],
};
if (cache) await cache.set(key, response, ttl);
return response;
}
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

View File

@@ -0,0 +1,91 @@
-- Initial schema for as4-411 directory (data-model.md)
-- Run with psql or migration runner; uses snake_case for columns.
CREATE TABLE IF NOT EXISTS tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS participants (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_participants_tenant_id ON participants(tenant_id);
CREATE TABLE IF NOT EXISTS identifiers (
id TEXT PRIMARY KEY,
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
identifier_type TEXT NOT NULL,
value TEXT NOT NULL,
scope TEXT,
priority INTEGER NOT NULL DEFAULT 0,
verified_at TIMESTAMPTZ
);
CREATE INDEX idx_identifiers_lookup ON identifiers(identifier_type, value);
CREATE INDEX idx_identifiers_participant_id ON identifiers(participant_id);
CREATE TABLE IF NOT EXISTS endpoints (
id TEXT PRIMARY KEY,
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
protocol TEXT NOT NULL,
address TEXT NOT NULL,
profile TEXT,
priority INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'draining'))
);
CREATE INDEX idx_endpoints_participant_id ON endpoints(participant_id);
CREATE TABLE IF NOT EXISTS capabilities (
id TEXT PRIMARY KEY,
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
service TEXT,
action TEXT,
process TEXT,
document_type TEXT,
constraints_json JSONB
);
CREATE INDEX idx_capabilities_participant_id ON capabilities(participant_id);
CREATE TABLE IF NOT EXISTS credentials (
id TEXT PRIMARY KEY,
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
credential_type TEXT NOT NULL CHECK (credential_type IN ('tls', 'sign', 'encrypt')),
vault_ref TEXT NOT NULL,
fingerprint TEXT,
valid_from TIMESTAMPTZ,
valid_to TIMESTAMPTZ
);
CREATE INDEX idx_credentials_participant_id ON credentials(participant_id);
CREATE TABLE IF NOT EXISTS policies (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
rule_json JSONB NOT NULL DEFAULT '{}',
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
priority INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_policies_tenant_id ON policies(tenant_id);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
actor TEXT,
action TEXT NOT NULL,
resource TEXT NOT NULL,
resource_id TEXT NOT NULL,
payload JSONB,
hash_prev TEXT
);
CREATE INDEX idx_audit_log_resource ON audit_log(resource, resource_id);

View File

@@ -0,0 +1,16 @@
-- Routing artifacts: BIN tables, GTT tables, participant maps, fallback rules.
-- See docs/architecture/data-model and protocol_registry.
CREATE TABLE IF NOT EXISTS routing_artifacts (
id TEXT PRIMARY KEY,
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
artifact_type TEXT NOT NULL CHECK (artifact_type IN ('bin_table', 'gtt_table', 'participant_map', 'fallback_rules')),
artifact_payload JSONB NOT NULL,
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ,
signature TEXT,
fingerprint TEXT
);
CREATE INDEX idx_routing_artifacts_tenant_type ON routing_artifacts(tenant_id, artifact_type);
CREATE INDEX idx_routing_artifacts_effective ON routing_artifacts(effective_from, effective_to);

View File

@@ -0,0 +1,20 @@
-- Graph layer: edges with provenance and validity (see data-model.md).
-- Optional: used when explicit graph and conflict resolution are needed.
CREATE TABLE IF NOT EXISTS edges (
id TEXT PRIMARY KEY,
from_type TEXT NOT NULL,
from_id TEXT NOT NULL,
to_type TEXT NOT NULL,
to_id TEXT NOT NULL,
relation TEXT NOT NULL,
confidence REAL,
source TEXT,
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_to TIMESTAMPTZ
);
CREATE INDEX idx_edges_from ON edges(from_type, from_id);
CREATE INDEX idx_edges_to ON edges(to_type, to_id);
CREATE INDEX idx_edges_relation ON edges(relation);
CREATE INDEX idx_edges_valid ON edges(valid_from, valid_to);

View File

@@ -0,0 +1,23 @@
{
"name": "@as4-411/storage",
"type": "module",
"version": "0.1.0",
"description": "Directory store port and implementations for as4-411",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js 2>/dev/null || true"
},
"dependencies": {
"@as4-411/core": "workspace:*",
"pg": "^8.11.3"
},
"devDependencies": {
"@types/pg": "^8.10.9",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18"
}
}

View File

View File

View File

@@ -0,0 +1,54 @@
import type {
Tenant,
Participant,
Identifier,
Endpoint,
Capability,
CredentialRef,
Policy,
} from "@as4-411/core";
import type { DirectoryStore } from "./port.js";
/**
* Admin store: DirectoryStore read methods plus full CRUD for directory entities.
* Used by Admin API; Postgres and InMemory can implement.
*/
export interface AdminStore extends DirectoryStore {
// Tenants
listTenants(): Promise<Tenant[]>;
getTenant(id: string): Promise<Tenant | null>;
createTenant(tenant: Tenant): Promise<void>;
updateTenant(id: string, tenant: Omit<Tenant, "id">): Promise<boolean>;
deleteTenant(id: string): Promise<boolean>;
// Participants
listParticipants(options?: { tenantId?: string }): Promise<Participant[]>;
getParticipant(id: string): Promise<Participant | null>;
createParticipant(participant: Participant): Promise<void>;
updateParticipant(id: string, participant: Omit<Participant, "id" | "tenantId">): Promise<boolean>;
deleteParticipant(id: string): Promise<boolean>;
// Identifiers (create/list; list is getIdentifiersByParticipantId)
createIdentifier(identifier: Identifier): Promise<void>;
deleteIdentifier(id: string): Promise<boolean>;
// Endpoints
createEndpoint(endpoint: Endpoint): Promise<void>;
updateEndpoint(id: string, endpoint: Omit<Endpoint, "id" | "participantId">): Promise<boolean>;
deleteEndpoint(id: string): Promise<boolean>;
// Capabilities
createCapability(capability: Capability): Promise<void>;
deleteCapability(id: string): Promise<boolean>;
// Credentials
createCredential(credential: CredentialRef): Promise<void>;
deleteCredential(id: string): Promise<boolean>;
// Policies
listPolicies(options?: { tenantId?: string }): Promise<Policy[]>;
getPolicy(id: string): Promise<Policy | null>;
createPolicy(policy: Policy): Promise<void>;
updatePolicy(id: string, policy: Omit<Policy, "id" | "tenantId">): Promise<boolean>;
deletePolicy(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,8 @@
export type { DirectoryStore } from "./port.js";
export type { AdminStore } from "./admin-port.js";
export type { RoutingArtifactStore } from "./routing-artifact-port.js";
export { InMemoryDirectoryStore } from "./memory-store.js";
export { InMemoryRoutingArtifactStore } from "./routing-artifact-memory.js";
export { PostgresDirectoryStore } from "./postgres/postgres-store.js";
export { PostgresRoutingArtifactStore } from "./postgres/routing-artifact-store.js";
export type { PostgresStoreConfig } from "./postgres/postgres-store.js";

View File

@@ -0,0 +1,242 @@
import type {
Tenant,
Participant,
Identifier,
Endpoint,
Capability,
CredentialRef,
Policy,
} from "@as4-411/core";
import type { AdminStore } from "./admin-port.js";
/**
* In-memory directory store for development and tests.
* Implements AdminStore (read + write). Not persistent; no migrations.
*/
export class InMemoryDirectoryStore implements AdminStore {
private tenants: Map<string, Tenant> = new Map();
private participants: Map<string, Participant> = new Map();
private identifiers: Identifier[] = [];
private endpoints: Endpoint[] = [];
private capabilities: Capability[] = [];
private credentials: CredentialRef[] = [];
private policies: Policy[] = [];
addParticipant(p: Participant): void {
this.participants.set(p.id, p);
}
addIdentifier(i: Identifier): void {
this.identifiers.push(i);
}
addEndpoint(e: Endpoint): void {
this.endpoints.push(e);
}
addCapability(c: Capability): void {
this.capabilities.push(c);
}
addCredential(c: CredentialRef): void {
this.credentials.push(c);
}
addPolicy(p: Policy): void {
this.policies.push(p);
}
addTenant(t: Tenant): void {
this.tenants.set(t.id, t);
}
async listTenants(): Promise<Tenant[]> {
return Array.from(this.tenants.values());
}
async getTenant(id: string): Promise<Tenant | null> {
return this.tenants.get(id) ?? null;
}
async createTenant(tenant: Tenant): Promise<void> {
this.tenants.set(tenant.id, tenant);
}
async updateTenant(id: string, tenant: Omit<Tenant, "id">): Promise<boolean> {
const existing = this.tenants.get(id);
if (!existing) return false;
this.tenants.set(id, { ...existing, ...tenant });
return true;
}
async deleteTenant(id: string): Promise<boolean> {
return this.tenants.delete(id);
}
async listParticipants(options?: { tenantId?: string }): Promise<Participant[]> {
let list = Array.from(this.participants.values());
if (options?.tenantId)
list = list.filter((p) => p.tenantId === options.tenantId);
return list;
}
async getParticipant(id: string): Promise<Participant | null> {
return this.participants.get(id) ?? null;
}
async createParticipant(participant: Participant): Promise<void> {
this.participants.set(participant.id, participant);
}
async updateParticipant(
id: string,
participant: Omit<Participant, "id" | "tenantId">
): Promise<boolean> {
const existing = this.participants.get(id);
if (!existing) return false;
this.participants.set(id, { ...existing, ...participant });
return true;
}
async deleteParticipant(id: string): Promise<boolean> {
if (!this.participants.has(id)) return false;
this.participants.delete(id);
this.identifiers = this.identifiers.filter((i) => i.participantId !== id);
this.endpoints = this.endpoints.filter((e) => e.participantId !== id);
this.capabilities = this.capabilities.filter((c) => c.participantId !== id);
this.credentials = this.credentials.filter((c) => c.participantId !== id);
return true;
}
async createIdentifier(identifier: Identifier): Promise<void> {
this.identifiers.push(identifier);
}
async deleteIdentifier(id: string): Promise<boolean> {
const idx = this.identifiers.findIndex((i) => i.id === id);
if (idx === -1) return false;
this.identifiers.splice(idx, 1);
return true;
}
async createEndpoint(endpoint: Endpoint): Promise<void> {
this.endpoints.push(endpoint);
}
async updateEndpoint(
id: string,
endpoint: Omit<Endpoint, "id" | "participantId">
): Promise<boolean> {
const idx = this.endpoints.findIndex((e) => e.id === id);
if (idx === -1) return false;
this.endpoints[idx] = { ...this.endpoints[idx], ...endpoint };
return true;
}
async deleteEndpoint(id: string): Promise<boolean> {
const idx = this.endpoints.findIndex((e) => e.id === id);
if (idx === -1) return false;
this.endpoints.splice(idx, 1);
return true;
}
async createCapability(capability: Capability): Promise<void> {
this.capabilities.push(capability);
}
async deleteCapability(id: string): Promise<boolean> {
const idx = this.capabilities.findIndex((c) => c.id === id);
if (idx === -1) return false;
this.capabilities.splice(idx, 1);
return true;
}
async createCredential(credential: CredentialRef): Promise<void> {
this.credentials.push(credential);
}
async deleteCredential(id: string): Promise<boolean> {
const idx = this.credentials.findIndex((c) => c.id === id);
if (idx === -1) return false;
this.credentials.splice(idx, 1);
return true;
}
async listPolicies(options?: { tenantId?: string }): Promise<Policy[]> {
let list = Array.from(this.policies);
if (options?.tenantId)
list = list.filter((p) => p.tenantId === options.tenantId);
return list.sort((a, b) => b.priority - a.priority);
}
async getPolicy(id: string): Promise<Policy | null> {
return this.policies.find((p) => p.id === id) ?? null;
}
async createPolicy(policy: Policy): Promise<void> {
this.policies.push(policy);
}
async updatePolicy(id: string, policy: Omit<Policy, "id" | "tenantId">): Promise<boolean> {
const idx = this.policies.findIndex((p) => p.id === id);
if (idx === -1) return false;
this.policies[idx] = { ...this.policies[idx], ...policy };
return true;
}
async deletePolicy(id: string): Promise<boolean> {
const idx = this.policies.findIndex((p) => p.id === id);
if (idx === -1) return false;
this.policies.splice(idx, 1);
return true;
}
async findParticipantsByIdentifiers(
identifiers: Array<{ type: string; value: string }>,
options?: { tenantId?: string }
): Promise<Participant[]> {
const byKey = new Set<string>();
for (const id of identifiers) {
const matching = this.identifiers.filter(
(i) =>
i.identifier_type === id.type &&
i.value === id.value &&
(options?.tenantId == null ||
this.participants.get(i.participantId)?.tenantId === options.tenantId)
);
for (const m of matching) byKey.add(m.participantId);
}
const out: Participant[] = [];
for (const pid of byKey) {
const p = this.participants.get(pid);
if (p) out.push(p);
}
return out;
}
async getIdentifiersByParticipantId(participantId: string): Promise<Identifier[]> {
return this.identifiers.filter((i) => i.participantId === participantId);
}
async getEndpointsByParticipantId(
participantId: string,
options?: { protocol?: string; status?: string }
): Promise<Endpoint[]> {
let list = this.endpoints.filter((e) => e.participantId === participantId);
if (options?.protocol) list = list.filter((e) => e.protocol === options.protocol);
if (options?.status) list = list.filter((e) => e.status === options.status);
return list;
}
async getCapabilitiesByParticipantId(participantId: string): Promise<Capability[]> {
return this.capabilities.filter((c) => c.participantId === participantId);
}
async getCredentialsByParticipantId(participantId: string): Promise<CredentialRef[]> {
return this.credentials.filter((c) => c.participantId === participantId);
}
async getPoliciesByTenantId(tenantId: string): Promise<Policy[]> {
return this.policies.filter((p) => p.tenantId === tenantId);
}
}

View File

@@ -0,0 +1,38 @@
import type {
Participant,
Identifier,
Endpoint,
Capability,
CredentialRef,
Policy,
} from "@as4-411/core";
/**
* Directory store port: enough for the resolver to find participants and endpoints.
* Implementations: in-memory, Postgres, SQLite.
*/
export interface DirectoryStore {
/** Find participants that have any of the given identifier (type, value) pairs, optionally scoped by tenant */
findParticipantsByIdentifiers(
identifiers: Array<{ type: string; value: string }>,
options?: { tenantId?: string }
): Promise<Participant[]>;
/** Get all identifiers for a participant */
getIdentifiersByParticipantId(participantId: string): Promise<Identifier[]>;
/** Get all endpoints for a participant, optionally filter by protocol */
getEndpointsByParticipantId(
participantId: string,
options?: { protocol?: string; status?: string }
): Promise<Endpoint[]>;
/** Get capabilities for a participant */
getCapabilitiesByParticipantId(participantId: string): Promise<Capability[]>;
/** Get credential refs for a participant */
getCredentialsByParticipantId(participantId: string): Promise<CredentialRef[]>;
/** Get policies for a tenant (for policy filter step) */
getPoliciesByTenantId(tenantId: string): Promise<Policy[]>;
}

View File

@@ -0,0 +1,388 @@
import type { Pool } from "pg";
import type {
Tenant,
Participant,
Identifier,
Endpoint,
Capability,
CredentialRef,
Policy,
} from "@as4-411/core";
import type { AdminStore } from "../admin-port.js";
function rowToTenant(r: Record<string, unknown>): Tenant {
return {
id: r.id as string,
name: r.name as string,
createdAt: (r.created_at as Date)?.toISOString?.(),
updatedAt: (r.updated_at as Date)?.toISOString?.(),
};
}
function rowToParticipant(r: Record<string, unknown>): Participant {
return {
id: r.id as string,
tenantId: r.tenant_id as string,
name: r.name as string,
createdAt: (r.created_at as Date)?.toISOString?.(),
updatedAt: (r.updated_at as Date)?.toISOString?.(),
};
}
function rowToIdentifier(r: Record<string, unknown>): Identifier {
return {
id: r.id as string,
participantId: r.participant_id as string,
identifier_type: r.identifier_type as string,
value: r.value as string,
scope: r.scope as string | undefined,
priority: Number(r.priority) ?? 0,
verified_at: (r.verified_at as Date)?.toISOString?.(),
};
}
function rowToEndpoint(r: Record<string, unknown>): Endpoint {
return {
id: r.id as string,
participantId: r.participant_id as string,
protocol: r.protocol as string,
address: r.address as string,
profile: r.profile as string | undefined,
priority: Number(r.priority) ?? 0,
status: r.status as "active" | "inactive" | "draining",
};
}
function rowToCapability(r: Record<string, unknown>): Capability {
return {
id: r.id as string,
participantId: r.participant_id as string,
service: r.service as string | undefined,
action: r.action as string | undefined,
process: r.process as string | undefined,
document_type: r.document_type as string | undefined,
constraints_json: r.constraints_json as Record<string, unknown> | undefined,
};
}
function rowToCredential(r: Record<string, unknown>): CredentialRef {
return {
id: r.id as string,
participantId: r.participant_id as string,
credential_type: r.credential_type as "tls" | "sign" | "encrypt",
vault_ref: r.vault_ref as string,
fingerprint: r.fingerprint as string | undefined,
valid_from: (r.valid_from as Date)?.toISOString?.(),
valid_to: (r.valid_to as Date)?.toISOString?.(),
};
}
function rowToPolicy(r: Record<string, unknown>): Policy {
return {
id: r.id as string,
tenantId: r.tenant_id as string,
rule_json: (r.rule_json as Record<string, unknown>) ?? {},
effect: r.effect as "allow" | "deny",
priority: Number(r.priority) ?? 0,
};
}
export interface PostgresStoreConfig {
pool: Pool;
}
/**
* Postgres implementation of AdminStore (read + write).
* Run migrations (001_initial.sql) before use.
*/
export class PostgresDirectoryStore implements AdminStore {
constructor(private readonly config: PostgresStoreConfig) {}
private get pool(): Pool {
return this.config.pool;
}
async findParticipantsByIdentifiers(
identifiers: Array<{ type: string; value: string }>,
options?: { tenantId?: string }
): Promise<Participant[]> {
if (identifiers.length === 0) return [];
const values: unknown[] = identifiers.flatMap((id) => [id.type, id.value]);
const conditions = identifiers
.map((_, i) => `(i.identifier_type = $${2 * i + 1} AND i.value = $${2 * i + 2})`)
.join(" OR ");
let sql = `
SELECT DISTINCT p.id, p.tenant_id, p.name, p.created_at, p.updated_at
FROM participants p
JOIN identifiers i ON i.participant_id = p.id
WHERE ${conditions}
`;
if (options?.tenantId) {
values.push(options.tenantId);
sql += ` AND p.tenant_id = $${values.length}`;
}
const result = await this.pool.query(sql, values);
return result.rows.map(rowToParticipant);
}
async getIdentifiersByParticipantId(participantId: string): Promise<Identifier[]> {
const result = await this.pool.query(
"SELECT * FROM identifiers WHERE participant_id = $1",
[participantId]
);
return result.rows.map(rowToIdentifier);
}
async getEndpointsByParticipantId(
participantId: string,
options?: { protocol?: string; status?: string }
): Promise<Endpoint[]> {
let sql = "SELECT * FROM endpoints WHERE participant_id = $1";
const values: unknown[] = [participantId];
if (options?.protocol) {
values.push(options.protocol);
sql += ` AND protocol = $${values.length}`;
}
if (options?.status) {
values.push(options.status);
sql += ` AND status = $${values.length}`;
}
const result = await this.pool.query(sql, values);
return result.rows.map(rowToEndpoint);
}
async getCapabilitiesByParticipantId(participantId: string): Promise<Capability[]> {
const result = await this.pool.query(
"SELECT * FROM capabilities WHERE participant_id = $1",
[participantId]
);
return result.rows.map(rowToCapability);
}
async getCredentialsByParticipantId(participantId: string): Promise<CredentialRef[]> {
const result = await this.pool.query(
"SELECT * FROM credentials WHERE participant_id = $1",
[participantId]
);
return result.rows.map(rowToCredential);
}
async getPoliciesByTenantId(tenantId: string): Promise<Policy[]> {
const result = await this.pool.query(
"SELECT * FROM policies WHERE tenant_id = $1 ORDER BY priority DESC",
[tenantId]
);
return result.rows.map(rowToPolicy);
}
async listTenants(): Promise<Tenant[]> {
const result = await this.pool.query("SELECT * FROM tenants ORDER BY id");
return result.rows.map(rowToTenant);
}
async getTenant(id: string): Promise<Tenant | null> {
const result = await this.pool.query("SELECT * FROM tenants WHERE id = $1", [id]);
return result.rows[0] ? rowToTenant(result.rows[0]) : null;
}
async createTenant(tenant: Tenant): Promise<void> {
await this.pool.query(
"INSERT INTO tenants (id, name) VALUES ($1, $2)",
[tenant.id, tenant.name]
);
}
async updateTenant(id: string, tenant: Omit<Tenant, "id">): Promise<boolean> {
const result = await this.pool.query(
"UPDATE tenants SET name = $1, updated_at = NOW() WHERE id = $2",
[tenant.name, id]
);
return (result.rowCount ?? 0) > 0;
}
async deleteTenant(id: string): Promise<boolean> {
const result = await this.pool.query("DELETE FROM tenants WHERE id = $1", [id]);
return (result.rowCount ?? 0) > 0;
}
async listParticipants(options?: { tenantId?: string }): Promise<Participant[]> {
let sql = "SELECT * FROM participants";
const values: unknown[] = [];
if (options?.tenantId) {
values.push(options.tenantId);
sql += " WHERE tenant_id = $1";
}
sql += " ORDER BY id";
const result = await this.pool.query(sql, values);
return result.rows.map(rowToParticipant);
}
async getParticipant(id: string): Promise<Participant | null> {
const result = await this.pool.query("SELECT * FROM participants WHERE id = $1", [id]);
return result.rows[0] ? rowToParticipant(result.rows[0]) : null;
}
async createParticipant(participant: Participant): Promise<void> {
await this.pool.query(
"INSERT INTO participants (id, tenant_id, name) VALUES ($1, $2, $3)",
[participant.id, participant.tenantId, participant.name]
);
}
async updateParticipant(
id: string,
participant: Omit<Participant, "id" | "tenantId">
): Promise<boolean> {
const result = await this.pool.query(
"UPDATE participants SET name = $1, updated_at = NOW() WHERE id = $2",
[participant.name, id]
);
return (result.rowCount ?? 0) > 0;
}
async deleteParticipant(id: string): Promise<boolean> {
const result = await this.pool.query("DELETE FROM participants WHERE id = $1", [id]);
return (result.rowCount ?? 0) > 0;
}
async createIdentifier(identifier: Identifier): Promise<void> {
await this.pool.query(
"INSERT INTO identifiers (id, participant_id, identifier_type, value, scope, priority, verified_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
[
identifier.id,
identifier.participantId,
identifier.identifier_type,
identifier.value,
identifier.scope ?? null,
identifier.priority ?? 0,
identifier.verified_at ? new Date(identifier.verified_at) : null,
]
);
}
async deleteIdentifier(id: string): Promise<boolean> {
const result = await this.pool.query("DELETE FROM identifiers WHERE id = $1", [id]);
return (result.rowCount ?? 0) > 0;
}
async createEndpoint(endpoint: Endpoint): Promise<void> {
await this.pool.query(
"INSERT INTO endpoints (id, participant_id, protocol, address, profile, priority, status) VALUES ($1, $2, $3, $4, $5, $6, $7)",
[
endpoint.id,
endpoint.participantId,
endpoint.protocol,
endpoint.address,
endpoint.profile ?? null,
endpoint.priority ?? 0,
endpoint.status,
]
);
}
async updateEndpoint(
id: string,
endpoint: Omit<Endpoint, "id" | "participantId">
): Promise<boolean> {
const result = await this.pool.query(
"UPDATE endpoints SET protocol = $1, address = $2, profile = $3, priority = $4, status = $5 WHERE id = $6",
[
endpoint.protocol,
endpoint.address,
endpoint.profile ?? null,
endpoint.priority ?? 0,
endpoint.status,
id,
]
);
return (result.rowCount ?? 0) > 0;
}
async deleteEndpoint(id: string): Promise<boolean> {
const result = await this.pool.query("DELETE FROM endpoints WHERE id = $1", [id]);
return (result.rowCount ?? 0) > 0;
}
async createCapability(capability: Capability): Promise<void> {
await this.pool.query(
"INSERT INTO capabilities (id, participant_id, service, action, process, document_type, constraints_json) VALUES ($1, $2, $3, $4, $5, $6, $7)",
[
capability.id,
capability.participantId,
capability.service ?? null,
capability.action ?? null,
capability.process ?? null,
capability.document_type ?? null,
capability.constraints_json ? JSON.stringify(capability.constraints_json) : null,
]
);
}
async deleteCapability(id: string): Promise<boolean> {
const result = await this.pool.query("DELETE FROM capabilities WHERE id = $1", [id]);
return (result.rowCount ?? 0) > 0;
}
async createCredential(credential: CredentialRef): Promise<void> {
await this.pool.query(
"INSERT INTO credentials (id, participant_id, credential_type, vault_ref, fingerprint, valid_from, valid_to) VALUES ($1, $2, $3, $4, $5, $6, $7)",
[
credential.id,
credential.participantId,
credential.credential_type,
credential.vault_ref,
credential.fingerprint ?? null,
credential.valid_from ? new Date(credential.valid_from) : null,
credential.valid_to ? new Date(credential.valid_to) : null,
]
);
}
async deleteCredential(id: string): Promise<boolean> {
const result = await this.pool.query("DELETE FROM credentials WHERE id = $1", [id]);
return (result.rowCount ?? 0) > 0;
}
async listPolicies(options?: { tenantId?: string }): Promise<Policy[]> {
let sql = "SELECT * FROM policies";
const values: unknown[] = [];
if (options?.tenantId) {
values.push(options.tenantId);
sql += " WHERE tenant_id = $1";
}
sql += " ORDER BY priority DESC, id";
const result = await this.pool.query(sql, values);
return result.rows.map(rowToPolicy);
}
async getPolicy(id: string): Promise<Policy | null> {
const result = await this.pool.query("SELECT * FROM policies WHERE id = $1", [id]);
return result.rows[0] ? rowToPolicy(result.rows[0]) : null;
}
async createPolicy(policy: Policy): Promise<void> {
await this.pool.query(
"INSERT INTO policies (id, tenant_id, rule_json, effect, priority) VALUES ($1, $2, $3, $4, $5)",
[
policy.id,
policy.tenantId,
JSON.stringify(policy.rule_json ?? {}),
policy.effect,
policy.priority ?? 0,
]
);
}
async updatePolicy(id: string, policy: Omit<Policy, "id" | "tenantId">): Promise<boolean> {
const result = await this.pool.query(
"UPDATE policies SET rule_json = $1, effect = $2, priority = $3 WHERE id = $4",
[JSON.stringify(policy.rule_json ?? {}), policy.effect, policy.priority ?? 0, id]
);
return (result.rowCount ?? 0) > 0;
}
async deletePolicy(id: string): Promise<boolean> {
const result = await this.pool.query("DELETE FROM policies WHERE id = $1", [id]);
return (result.rowCount ?? 0) > 0;
}
}

View File

@@ -0,0 +1,79 @@
import type { Pool } from "pg";
import type { RoutingArtifact } from "@as4-411/core";
import type { RoutingArtifactStore } from "../routing-artifact-port.js";
function rowToArtifact(r: Record<string, unknown>): RoutingArtifact {
return {
id: r.id as string,
tenantId: r.tenant_id as string | undefined,
artifactType: r.artifact_type as RoutingArtifact["artifactType"],
payload: r.artifact_payload as RoutingArtifact["payload"],
effectiveFrom: (r.effective_from as Date)?.toISOString?.() ?? "",
effectiveTo: (r.effective_to as Date)?.toISOString?.(),
};
}
export class PostgresRoutingArtifactStore implements RoutingArtifactStore {
constructor(private readonly pool: Pool) {}
async get(
artifactType: RoutingArtifact["artifactType"],
options?: { tenantId?: string; atTime?: Date }
): Promise<RoutingArtifact | null> {
const at = options?.atTime ?? new Date();
let sql =
"SELECT * FROM routing_artifacts WHERE artifact_type = $1 AND effective_from <= $2 AND (effective_to IS NULL OR effective_to >= $2)";
const values: unknown[] = [artifactType, at];
if (options?.tenantId) {
values.push(options.tenantId);
sql += " AND tenant_id = $3";
}
sql += " ORDER BY effective_from DESC LIMIT 1";
const result = await this.pool.query(sql, values);
return result.rows[0] ? rowToArtifact(result.rows[0]) : null;
}
async put(artifact: RoutingArtifact): Promise<void> {
await this.pool.query(
`INSERT INTO routing_artifacts (id, tenant_id, artifact_type, artifact_payload, effective_from, effective_to, signature, fingerprint)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
artifact_type = EXCLUDED.artifact_type,
artifact_payload = EXCLUDED.artifact_payload,
effective_from = EXCLUDED.effective_from,
effective_to = EXCLUDED.effective_to,
signature = EXCLUDED.signature,
fingerprint = EXCLUDED.fingerprint`,
[
artifact.id,
artifact.tenantId ?? null,
artifact.artifactType,
JSON.stringify(artifact.payload),
new Date(artifact.effectiveFrom),
artifact.effectiveTo ? new Date(artifact.effectiveTo) : null,
artifact.payload.signature ?? null,
artifact.payload.fingerprint ?? null,
]
);
}
async list(
options?: { tenantId?: string; artifactType?: RoutingArtifact["artifactType"] }
): Promise<RoutingArtifact[]> {
let sql = "SELECT * FROM routing_artifacts WHERE 1=1";
const values: unknown[] = [];
let n = 1;
if (options?.tenantId) {
values.push(options.tenantId);
sql += ` AND tenant_id = $${n++}`;
}
if (options?.artifactType) {
values.push(options.artifactType);
sql += ` AND artifact_type = $${n++}`;
}
sql += " ORDER BY effective_from DESC";
const result = await this.pool.query(sql, values);
return result.rows.map(rowToArtifact);
}
}

View File

@@ -0,0 +1,37 @@
import type { RoutingArtifact } from "@as4-411/core";
import type { RoutingArtifactStore } from "./routing-artifact-port.js";
export class InMemoryRoutingArtifactStore implements RoutingArtifactStore {
private artifacts: RoutingArtifact[] = [];
async get(
artifactType: RoutingArtifact["artifactType"],
options?: { tenantId?: string; atTime?: Date }
): Promise<RoutingArtifact | null> {
const at = options?.atTime ?? new Date();
const list = this.artifacts.filter(
(a) =>
a.artifactType === artifactType &&
(options?.tenantId == null || a.tenantId === options.tenantId) &&
new Date(a.effectiveFrom) <= at &&
(a.effectiveTo == null || new Date(a.effectiveTo) >= at)
);
list.sort((a, b) => new Date(b.effectiveFrom).getTime() - new Date(a.effectiveFrom).getTime());
return list[0] ?? null;
}
async put(artifact: RoutingArtifact): Promise<void> {
const idx = this.artifacts.findIndex((a) => a.id === artifact.id);
if (idx >= 0) this.artifacts[idx] = artifact;
else this.artifacts.push(artifact);
}
async list(
options?: { tenantId?: string; artifactType?: RoutingArtifact["artifactType"] }
): Promise<RoutingArtifact[]> {
let list = [...this.artifacts];
if (options?.tenantId) list = list.filter((a) => a.tenantId === options.tenantId);
if (options?.artifactType) list = list.filter((a) => a.artifactType === options.artifactType);
return list;
}
}

View File

@@ -0,0 +1,18 @@
import type { RoutingArtifact } from "@as4-411/core";
/**
* Store for routing artifacts (BIN table, GTT table, participant_map, fallback_rules).
* Optional: resolver can use this when resolving by BIN or GTT.
*/
export interface RoutingArtifactStore {
get(
artifactType: RoutingArtifact["artifactType"],
options?: { tenantId?: string; atTime?: Date }
): Promise<RoutingArtifact | null>;
put(artifact: RoutingArtifact): Promise<void>;
list(
options?: { tenantId?: string; artifactType?: RoutingArtifact["artifactType"] }
): Promise<RoutingArtifact[]>;
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}