Initial commit: AS4/411 directory and discovery service for Sankofa Marketplace
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
27
packages/api/rest/package.json
Normal file
27
packages/api/rest/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
packages/api/rest/src/app.ts
Normal file
17
packages/api/rest/src/app.ts
Normal 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;
|
||||
}
|
||||
4
packages/api/rest/src/index.ts
Normal file
4
packages/api/rest/src/index.ts
Normal 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";
|
||||
276
packages/api/rest/src/routes/admin.ts
Normal file
276
packages/api/rest/src/routes/admin.ts
Normal 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;
|
||||
}
|
||||
46
packages/api/rest/src/routes/resolver.ts
Normal file
46
packages/api/rest/src/routes/resolver.ts
Normal 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;
|
||||
}
|
||||
20
packages/api/rest/src/routes/system.ts
Normal file
20
packages/api/rest/src/routes/system.ts
Normal 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;
|
||||
}
|
||||
15
packages/api/rest/src/server.ts
Normal file
15
packages/api/rest/src/server.ts
Normal 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}`);
|
||||
});
|
||||
16
packages/api/rest/tsconfig.json
Normal file
16
packages/api/rest/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
packages/connectors/file/package.json
Normal file
1
packages/connectors/file/package.json
Normal 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"}}
|
||||
72
packages/connectors/file/src/bin-table.ts
Normal file
72
packages/connectors/file/src/bin-table.ts
Normal 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);
|
||||
}
|
||||
4
packages/connectors/file/src/index.ts
Normal file
4
packages/connectors/file/src/index.ts
Normal 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";
|
||||
41
packages/connectors/file/src/signed-artifact.ts
Normal file
41
packages/connectors/file/src/signed-artifact.ts
Normal 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);
|
||||
}
|
||||
1
packages/connectors/file/tsconfig.json
Normal file
1
packages/connectors/file/tsconfig.json
Normal 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"]}
|
||||
1
packages/connectors/ktt/package.json
Normal file
1
packages/connectors/ktt/package.json
Normal 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"}}
|
||||
2
packages/connectors/ktt/src/index.ts
Normal file
2
packages/connectors/ktt/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ingestFromFile, ingestFromApi } from "./ingest.js";
|
||||
export type { KttIngestFromFileOptions, KttIngestFromApiOptions } from "./ingest.js";
|
||||
32
packages/connectors/ktt/src/ingest.ts
Normal file
32
packages/connectors/ktt/src/ingest.ts
Normal 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 };
|
||||
}
|
||||
1
packages/connectors/ktt/tsconfig.json
Normal file
1
packages/connectors/ktt/tsconfig.json
Normal 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"]}
|
||||
18
packages/core/package.json
Normal file
18
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
78
packages/core/src/adapter-interface.ts
Normal file
78
packages/core/src/adapter-interface.ts
Normal 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>;
|
||||
}
|
||||
4
packages/core/src/index.ts
Normal file
4
packages/core/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./types.js";
|
||||
export * from "./validation.js";
|
||||
export * from "./protocol_registry/index.js";
|
||||
export * from "./adapter-interface.js";
|
||||
97
packages/core/src/protocol_registry/artifacts.ts
Normal file
97
packages/core/src/protocol_registry/artifacts.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
packages/core/src/protocol_registry/index.ts
Normal file
3
packages/core/src/protocol_registry/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types.js";
|
||||
export * from "./validators.js";
|
||||
export * from "./artifacts.js";
|
||||
39
packages/core/src/protocol_registry/types.ts
Normal file
39
packages/core/src/protocol_registry/types.ts
Normal 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;
|
||||
}
|
||||
36
packages/core/src/protocol_registry/validators.ts
Normal file
36
packages/core/src/protocol_registry/validators.ts
Normal 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
181
packages/core/src/types.ts
Normal 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[];
|
||||
}
|
||||
107
packages/core/src/validation.ts
Normal file
107
packages/core/src/validation.ts
Normal 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);
|
||||
}
|
||||
16
packages/core/tsconfig.json
Normal file
16
packages/core/tsconfig.json
Normal 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"]
|
||||
}
|
||||
22
packages/resolver/package.json
Normal file
22
packages/resolver/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
packages/resolver/src/artifact-resolve.ts
Normal file
49
packages/resolver/src/artifact-resolve.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
43
packages/resolver/src/cache.ts
Normal file
43
packages/resolver/src/cache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
5
packages/resolver/src/index.ts
Normal file
5
packages/resolver/src/index.ts
Normal 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";
|
||||
146
packages/resolver/src/pipeline.ts
Normal file
146
packages/resolver/src/pipeline.ts
Normal 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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
166
packages/resolver/src/resolver.ts
Normal file
166
packages/resolver/src/resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
packages/resolver/tsconfig.json
Normal file
16
packages/resolver/tsconfig.json
Normal 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"]
|
||||
}
|
||||
0
packages/storage/migrations/.gitkeep
Normal file
0
packages/storage/migrations/.gitkeep
Normal file
91
packages/storage/migrations/001_initial.sql
Normal file
91
packages/storage/migrations/001_initial.sql
Normal 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);
|
||||
16
packages/storage/migrations/002_routing_artifacts.sql
Normal file
16
packages/storage/migrations/002_routing_artifacts.sql
Normal 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);
|
||||
20
packages/storage/migrations/003_edges.sql
Normal file
20
packages/storage/migrations/003_edges.sql
Normal 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);
|
||||
23
packages/storage/package.json
Normal file
23
packages/storage/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
0
packages/storage/postgres/.gitkeep
Normal file
0
packages/storage/postgres/.gitkeep
Normal file
0
packages/storage/sqlite/.gitkeep
Normal file
0
packages/storage/sqlite/.gitkeep
Normal file
54
packages/storage/src/admin-port.ts
Normal file
54
packages/storage/src/admin-port.ts
Normal 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>;
|
||||
}
|
||||
8
packages/storage/src/index.ts
Normal file
8
packages/storage/src/index.ts
Normal 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";
|
||||
242
packages/storage/src/memory-store.ts
Normal file
242
packages/storage/src/memory-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
packages/storage/src/port.ts
Normal file
38
packages/storage/src/port.ts
Normal 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[]>;
|
||||
}
|
||||
388
packages/storage/src/postgres/postgres-store.ts
Normal file
388
packages/storage/src/postgres/postgres-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
79
packages/storage/src/postgres/routing-artifact-store.ts
Normal file
79
packages/storage/src/postgres/routing-artifact-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
packages/storage/src/routing-artifact-memory.ts
Normal file
37
packages/storage/src/routing-artifact-memory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
packages/storage/src/routing-artifact-port.ts
Normal file
18
packages/storage/src/routing-artifact-port.ts
Normal 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[]>;
|
||||
}
|
||||
16
packages/storage/tsconfig.json
Normal file
16
packages/storage/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user