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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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