Initial commit: AS4/411 directory and discovery service for Sankofa Marketplace
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user