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

0
.cursor/.gitkeep Normal file
View File

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

23
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,23 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
env: { node: true, es2022: true },
parser: "@typescript-eslint/parser",
parserOptions: { ecmaVersion: 2022, sourceType: "module", project: true },
plugins: ["@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
],
ignorePatterns: ["dist/", "node_modules/", "*.cjs"],
overrides: [
{
files: ["**/*.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
],
};

38
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run format:check
build:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run build

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Dependencies
node_modules/
.pnpm-store/
# Build
dist/
build/
*.tsbuildinfo
out/
# Environment
.env
.env.local
.env.*.local
# IDE / Editor
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs and temp
*.log
npm-debug.log*
tmp/
temp/
# Test / Coverage
coverage/
.nyc_output/
# Optional
*.local

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
pnpm-lock.yaml

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

43
README.md Normal file
View File

@@ -0,0 +1,43 @@
# as4-411
Standards-aware directory and discovery service that cross-maps identifiers and endpoints across AS4, SS7, and additional messaging ecosystems. Enables deterministic routing, dynamic discovery, and policy-driven interoperability for messaging gateways.
## Goals
- **Unified Directory Core:** Store and resolve "who/where/how" for messaging participants across networks.
- **Cross-Discovery:** Translate identifiers between domains (e.g., AS4 PartyId ↔ PEPPOL ParticipantId ↔ E.164/GT ↔ SS7 PC/SSN). FI-to-FI: ISO 20022 over AS4 (PartyId → endpoint) documented in [docs/protocols/iso20022-over-as4.md](docs/protocols/iso20022-over-as4.md).
- **Routing Outputs:** Produce normalized routing directives for gateways (endpoint URL, transport profile, security material references, QoS).
- **Pluggable Ecosystems:** Add new protocol domains via adapters.
- **Gateway Submodule:** Embed as a library and/or sidecar service into gateway stacks (AS4 MSH, SS7 STP/SCP, API gateways).
## Repository Structure
- **[docs/](docs/)** — Architecture, ADRs, API specs, security, operations. Scope and non-goals: [ADR-000](docs/adr/000-scope-and-non-goals.md).
- **packages/core** — Domain model, validation, policy engine.
- **packages/resolver** — Resolution pipeline, scoring, caching.
- **packages/storage** — Persistence (Postgres, SQLite, in-memory).
- **packages/api** — REST and gRPC APIs.
- **packages/connectors** — SMP/SML, DNS, file, SS7 ingest.
- **packages/client** — TypeScript, Python, Go clients.
- **packages/cli** — Admin and import/export tools.
- **examples/** — Gateway sidecar and embedded-library usage.
## Submodule Integration
Gateways can include `as4-411` as a git submodule (e.g. under `vendor/as4-411` or `modules/as4-411`) and consume:
- **Library:** `packages/core` + `packages/resolver` for embedded resolution with a local store (SQLite/Postgres).
- **Service:** `packages/api/rest` (or gRPC) as a sidecar or shared network service.
## Development
```bash
pnpm install
pnpm run build
pnpm run lint
pnpm run test
```
## License
See LICENSE file.

0
docs/adr/.gitkeep Normal file
View File

View File

@@ -0,0 +1,31 @@
# ADR-000: Scope and Non-Goals
## Status
Accepted.
## Context
as4-411 must have a locked scope so that "interact" is not interpreted as brokering, orchestration, or config generation. The system boundary and trust model depend on this.
## Decision
### In Scope
- as4-411 is a **directory + discovery + routing directive generator**.
- It stores participants, identifiers, endpoints, capabilities, credentials references, and policies.
- It resolves identifiers to **routing directives** (target protocol, address, profile, security refs, QoS). Gateways **execute** these directives; as4-411 does **not** transmit messages on their behalf.
### Out of Scope (Unless Explicitly Added Later)
- **Brokering / orchestration:** Sending or relaying messages between parties is out of scope. If added in the future, it must be a **separate component** (e.g. `as4-411-broker`) with a separate trust boundary so the directory's integrity and confidentiality are not contaminated.
- **Config generation for multiple gateway stacks:** Generating full gateway configuration (e.g. PMode files, STP config) may be added as a separate tool or module; it is not part of the core directory/resolver.
### Integration Default
- Gateways may consume as4-411 as an **embedded library** (core + resolver + storage) or as a **sidecar/shared service** (REST or gRPC). The default pattern is documented in the README and deployment docs; both are supported.
## Consequences
- All feature work stays within directory, discovery, and directive generation.
- Brokering or message transmission, if ever required, is a distinct service with its own security and compliance story.

View File

@@ -0,0 +1,44 @@
# ADR-001: Adapter Interface and Semantic Versioning
## Status
Accepted.
## Context
Multi-rail support requires a strict plugin boundary so that each rail has a single adapter, version negotiation is clear, and compatibility is guaranteed. The protocol registry must define the minimum interface surface and versioning rules.
## Decision
### ProtocolAdapter Interface
Every rail adapter implements the following (see `packages/core` adapter-interface.ts):
- **validateIdentifier(type, value): boolean** — Validate format for the rail.
- **normalizeIdentifier(type, value): string | null** — Return normalized value for lookup/storage, or null if invalid.
- **resolveCandidates(ctx, request, options): Promise<AdapterCandidate[]]** — Use the supplied context (directory view) to return candidate participant+endpoint pairs.
- **evaluateCapabilities(candidate, serviceContext): boolean** — Whether the candidate matches the requested service/action/process.
- **renderRouteDirective(candidate, options): RouteDirective** — Build the canonical directive from a candidate.
- **ingestSource?(config): Promise<IngestResult>** — Optional; for connectors that pull from external directories (SMP, file, etc.).
The resolver (or a registry) supplies an **AdapterContext** to adapters; the context exposes findParticipantsByIdentifiers, getEndpointsByParticipantId, getCapabilitiesByParticipantId. The storage layer implements this context.
### Plugin Boundaries
- One adapter per rail (or per protocol family). Adapters are discovered by config or package layout (e.g. registered by protocol name or identifier type prefix).
- No adapter depends on another adapter; shared logic lives in core or a shared utility package.
### Semantic Versioning
- The **adapter interface** (ProtocolAdapter) follows semantic versioning. Backward-compatible changes only: new optional methods, new optional fields on types. Breaking changes require a new major version of the interface.
- Each **adapter implementation** has its own version (e.g. `version: "1.0.0"`). Registry can enforce minimum interface version when loading adapters.
### Compatibility Guarantees
- New optional methods or optional parameters do not break existing adapters.
- New required methods or required fields are breaking; they belong to a new major version of the interface contract.
## Consequences
- Rails can be added by implementing ProtocolAdapter and registering; the resolver delegates to the appropriate adapter by identifier type or protocol.
- Version mismatches can be detected at load time; operators can pin adapter or interface versions.

View File

@@ -0,0 +1,29 @@
# ADR-001: Persistence and Caching Strategy
## Status
Accepted.
## Context
as4-411 needs canonical persistence for directory data (tenants, participants, identifiers, endpoints, capabilities, credentials, policies) and a caching strategy for resolution results to support low-latency gateway lookups and resilience.
## Decision
### Persistence
- **Primary store: PostgreSQL.** Chosen for ACID guarantees, relational model matching the [data model](../architecture/data-model.md), and operational familiarity (replication, backups, tooling).
- **Migrations:** SQL migrations live under `packages/storage/migrations/` (e.g. `001_initial.sql`). Applied out-of-band or via a migration runner; no automatic migrate on startup by default.
- **Alternatives:** In-memory store for development and tests; SQLite for embedded/library deployments where Postgres is not available. Both implement the same `DirectoryStore`/`AdminStore` port.
### Caching
- **Resolution cache:** In-process TTL cache (e.g. `InMemoryResolveCache`) keyed by canonical `ResolveRequest` (identifiers, serviceContext, constraints, tenant). Positive and negative results are cached; negative TTL is shorter (e.g. 60s) to avoid prolonged stale “not found.”
- **Cache key:** Deterministic and stable for same inputs (see [ADR-002](002-resolution-scoring-determinism.md)).
- **Invalidation:** On directory mutation (participant/identifier/endpoint/policy change), invalidate by tenant or by cache key prefix when a proper event or hook is available; until then, rely on TTL.
- **Optional:** Redis or similar for shared cache across multiple resolver instances; same interface `ResolveCache`.
## Consequences
- Gateways can rely on Postgres for durability and use in-memory or Redis cache for latency.
- Embedded use cases can use SQLite or in-memory without Postgres dependency.

View File

@@ -0,0 +1,28 @@
# ADR-002: Resolution Scoring and Determinism
## Status
Accepted.
## Context
Resolution must return a stable, ordered list of routing directives for the same inputs and store state.
## Decision
### Determinism
- Same normalized request + same directory state implies same ordered list of RouteDirectives.
- Tie-break when scores are equal: (1) explicit priority higher first, (2) lexical by endpoint id then participant id.
### Scoring
- Factors: endpoint priority, endpoint status (active preferred over draining over inactive). No randomness; same inputs imply same scores and order.
### Cache Key
- Derived from canonical request (sorted identifiers, serialized serviceContext and constraints, tenant).
## Consequences
- Caching and retries are reproducible and safe.

View File

@@ -0,0 +1,30 @@
# ADR-003: Multi-Tenancy and RLS Strategy
## Status
Accepted.
## Context
Tenant scoping is required for isolation. Shared (global) data (e.g. BIC, LEI) and tenant-private data must be clearly separated, and access enforced at the database and application layer.
## Decision
### Model
- **Global objects:** Identifiers or metadata that are public or shared (e.g. BIC, LEI, BIN range metadata). Stored with `tenant_id` null or a dedicated global tenant. Readable by all tenants for resolution when the identifier is public.
- **Tenant-private objects:** All participant-specific data, contractual endpoints, MID/TID, and tenant-specific routing artifacts. Must be scoped by `tenant_id`; only the owning tenant can read/write.
### Enforcement
- **Postgres Row Level Security (RLS):** Enable on tenant-scoped tables. Policy: restrict to rows where `tenant_id` matches the session/connection tenant (set after auth). Allow read of global rows (`tenant_id IS NULL`) where applicable.
- **Application:** Resolver and Admin API set tenant context from JWT or request; all queries filter by tenant. No cross-tenant data in responses.
- **Per-tenant encryption:** For confidential data (Tier 2+), use per-tenant keys so compromise is isolated (see ADR-004).
### Caching
- Cache key includes tenant. Per-tenant TTL and invalidation optional.
## Consequences
- Tenants cannot see each other's private data. Global data remains available for public identifier resolution. RLS provides defense in depth alongside application checks.

View File

@@ -0,0 +1,29 @@
# ADR-003: Policy Engine Model (ABAC)
## Status
Accepted.
## Context
Resolution must respect tenant scope and allow/deny rules using an attribute-based model.
## Decision
### Model
- Policies are stored per tenant with rule_json (ABAC attributes), effect (allow/deny), and priority.
- Tenant is enforced by restricting resolution to that tenant when request.tenant is set.
### MVP Rule Shape
- Deny: rule_json.participantId or rule_json.participantIds — exclude those participants.
- Allow (restrictive): if any allow policy exists, rule_json.participantId/participantIds — only include those participants.
### Ordering
- Deny applied first; then allow restriction. Policies loaded by tenant and ordered by priority.
## Consequences
- Simple allow/deny by participant supported; ABAC can be extended via rule_json and filter logic.

View File

@@ -0,0 +1,19 @@
# ADR-004: Sensitive Data Classification and Encryption
## Status
Accepted.
## Context
The directory holds mixed sensitivity data: public identifiers (BIC, LEI), internal endpoints and participant data, and confidential or regulated data (MID/TID, contract routing, key references). We need a clear classification and enforcement policy so that storage and access controls are consistent and auditable.
## Decision
- **Four tiers:** Tier 0 (public), Tier 1 (internal), Tier 2 (confidential), Tier 3 (regulated/secrets). See [data-classification.md](../security/data-classification.md) for definitions and examples.
- **Enforcement:** Field-level encryption for Tier 2+ at rest; strict RBAC/ABAC; immutable audit logs for mutations and Tier 2+ access. Tier 3: only references (e.g. vault_ref) stored; no private keys or tokens in the directory.
- **Mapping:** All tables and fields used for directory and routing artifacts are mapped to a tier. New fields require a tier before merge. Per-tenant encryption keys for Tier 2+ are recommended (see ADR-003).
## Consequences
- Operators and developers have a single reference for how to handle each data type. Compliance and security reviews can align on tier and controls.

View File

@@ -0,0 +1,30 @@
# ADR-005: Connector Trust and Caching Strategy
## Status
Accepted.
## Context
Connectors ingest data from external or file-based sources (SMP/SML, file, SS7 feeds). Trust anchors, signature validation, caching, and resilience must be defined so that bad or stale data does not compromise resolution.
## Decision
### Per-Connector Requirements
For each connector (SMP/SML, file, SS7, etc.) the following must be defined and documented (see [connectors.md](../architecture/connectors.md)):
- **Trust anchors and signature validation:** Which certificates or keys are trusted for signed payloads; how to validate signatures on ingested bundles. Pinning and trust anchor refresh policy.
- **Caching and refresh:** TTL for cached data, jitter to avoid thundering herd, negative caching (how long to cache "not found" or fetch failure).
- **Resilience:** Timeouts, retries, circuit-breaker thresholds. Behavior on failure: fall back to cached only, fail closed, or fail open (document per connector).
- **Data provenance tagging:** Every ingested record or edge must be tagged with source (e.g. "smp", "file", "gtt_feed"), last_verified (or fetched_at), and optional confidence score. Exposed in resolution evidence and resolution_trace.
### SMP/SML Specifics
- Cache TTL policy: document default TTL for SMP metadata and SML lookups; jitter on refresh.
- Pinning and trust anchors: SML and SMP TLS and optional payload signing; which CAs or pins are accepted.
- Failure behavior: on network or SMP failure, fall back to cached data only; do not serve stale beyond max stale window (document). No silent fallback to unrelated data.
## Consequences
- Operators can configure trust and cache per connector. Provenance is always available for audit and explainability.

0
docs/api/.gitkeep Normal file
View File

18
docs/api/README.md Normal file
View File

@@ -0,0 +1,18 @@
# API definitions
- **OpenAPI:** [openapi.yaml](openapi.yaml) — REST API for resolve, bulk-resolve, admin, system.
- **Route directive schema:** [route-directive.schema.json](route-directive.schema.json) — JSON Schema for RouteDirective and ResolveResponse.
- **Protobuf:** [proto/resolver.proto](proto/resolver.proto) — Resolver service and messages (ResolveRequest, ResolveResponse, RouteDirective). Package `as411.resolver.v1`.
## Generating stubs from Proto
From the repo root, with `protoc` installed:
```bash
# Example (adjust paths for your language)
protoc -I docs/api/proto docs/api/proto/resolver.proto --go_out=paths=source_relative:.
# Or with buf (if using buf.gen.yaml):
# buf generate docs/api/proto
```
gRPC server implementation is optional; the Proto file defines the contract for clients and future gRPC support.

530
docs/api/openapi.yaml Normal file
View File

@@ -0,0 +1,530 @@
openapi: 3.0.3
info:
title: as4-411 Directory and Resolver API
description: |
Standards-aware directory and discovery service for AS4, SS7, and messaging gateways.
See [data-model](../architecture/data-model.md) and [resolution-algorithm](../architecture/resolution-algorithm.md).
version: 0.1.0
servers:
- url: /api
description: API base path
tags:
- name: Resolver
description: Gateway-facing resolution
- name: Admin
description: Directory management
- name: System
description: Health and metrics
paths:
# --- Resolver API (gateway-facing) ---
/v1/resolve:
post:
tags: [Resolver]
summary: Resolve identifiers to routing directives
description: |
For ISO 20022 FI-to-FI, use service = `iso20022.fi` and action = `credit.transfer`, `fi.credit.transfer`, `payment.status`, `payment.cancellation`, `resolution.of.investigation`, `statement`, or `notification`.
Profile returned: `as4.fifi.iso20022.v1`.
operationId: resolve
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ResolveRequest" }
example:
identifiers:
- type: as4.partyId
value: BANKUS33XXX
scope: BIC
serviceContext:
service: iso20022.fi
action: credit.transfer
responses:
"200":
description: Resolution result
content:
application/json:
schema: { $ref: "#/components/schemas/ResolveResponse" }
example:
primary:
target_protocol: as4
target_address: https://as4.bankus.com/fi
transport_profile: as4.fifi.iso20022.v1
security:
signRequired: true
encryptRequired: true
keyRefs: [vault://certs/bankus/iso20022]
service_context:
service: iso20022.fi
action: credit.transfer
resolution_trace:
- source: internal directory
"400":
description: Invalid request
"503":
description: Resolver unavailable
/v1/bulk-resolve:
post:
tags: [Resolver]
summary: Batch resolve multiple requests
operationId: bulkResolve
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [requests]
properties:
requests:
type: array
items: { $ref: "#/components/schemas/ResolveRequest" }
responses:
"200":
description: Batch resolution results
content:
application/json:
schema:
type: object
properties:
results:
type: array
items: { $ref: "#/components/schemas/ResolveResponse" }
traceId: { type: string, format: uuid }
# --- System ---
/v1/health:
get:
tags: [System]
summary: Health check
operationId: health
responses:
"200":
description: Service healthy
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [ok, degraded] }
version: { type: string }
checks: { type: object }
/v1/metrics:
get:
tags: [System]
summary: Prometheus metrics
operationId: metrics
responses:
"200":
description: Prometheus text format
content:
text/plain: {}
# --- Admin API (CRUD) ---
/v1/admin/tenants:
get:
tags: [Admin]
summary: List tenants
operationId: listTenants
responses:
"200": { description: List of tenants }
post:
tags: [Admin]
summary: Create tenant
operationId: createTenant
requestBody:
{ content: { "application/json": { schema: { $ref: "#/components/schemas/Tenant" } } } }
responses:
"201": { description: Created }
"400": { description: Validation error }
/v1/admin/tenants/{tenantId}:
get:
tags: [Admin]
summary: Get tenant
operationId: getTenant
parameters: [{ name: tenantId, in: path, required: true, schema: { type: string } }]
responses:
"200": { description: Tenant }
"404": { description: Not found }
put:
tags: [Admin]
summary: Update tenant
operationId: updateTenant
parameters: [{ name: tenantId, in: path, required: true, schema: { type: string } }]
requestBody:
{ content: { "application/json": { schema: { $ref: "#/components/schemas/Tenant" } } } }
responses:
"200": { description: Updated }
"404": { description: Not found }
delete:
tags: [Admin]
summary: Delete tenant
operationId: deleteTenant
parameters: [{ name: tenantId, in: path, required: true, schema: { type: string } }]
responses:
"204": { description: Deleted }
"404": { description: Not found }
/v1/admin/participants:
get:
tags: [Admin]
summary: List participants
parameters:
- name: tenantId
in: query
schema: { type: string }
responses:
"200": { description: List of participants }
post:
tags: [Admin]
summary: Create participant
operationId: createParticipant
requestBody:
{
content: { "application/json": { schema: { $ref: "#/components/schemas/Participant" } } },
}
responses:
"201": { description: Created }
"400": { description: Validation error }
/v1/admin/participants/{participantId}:
get:
tags: [Admin]
summary: Get participant
operationId: getParticipant
parameters: [{ name: participantId, in: path, required: true, schema: { type: string } }]
responses:
"200": { description: Participant }
"404": { description: Not found }
put:
tags: [Admin]
summary: Update participant
operationId: updateParticipant
parameters: [{ name: participantId, in: path, required: true, schema: { type: string } }]
requestBody:
{
content: { "application/json": { schema: { $ref: "#/components/schemas/Participant" } } },
}
responses:
"200": { description: Updated }
"404": { description: Not found }
delete:
tags: [Admin]
summary: Delete participant
operationId: deleteParticipant
parameters: [{ name: participantId, in: path, required: true, schema: { type: string } }]
responses:
"204": { description: Deleted }
"404": { description: Not found }
/v1/admin/participants/{participantId}/identifiers:
get:
tags: [Admin]
summary: List identifiers for participant
parameters: [{ name: participantId, in: path, required: true, schema: { type: string } }]
responses:
"200": { description: List of identifiers }
post:
tags: [Admin]
summary: Add identifier
operationId: createIdentifier
parameters: [{ name: participantId, in: path, required: true, schema: { type: string } }]
requestBody:
{ content: { "application/json": { schema: { $ref: "#/components/schemas/Identifier" } } } }
responses:
"201": { description: Created }
"400": { description: Validation error }
/v1/admin/participants/{participantId}/endpoints:
get:
tags: [Admin]
summary: List endpoints for participant
parameters: [{ name: participantId, in: path, required: true, schema: { type: string } }]
responses:
"200": { description: List of endpoints }
post:
tags: [Admin]
summary: Add endpoint
operationId: createEndpoint
parameters: [{ name: participantId, in: path, required: true, schema: { type: string } }]
requestBody:
{ content: { "application/json": { schema: { $ref: "#/components/schemas/Endpoint" } } } }
responses:
"201": { description: Created }
"400": { description: Validation error }
/v1/admin/participants/{participantId}/credentials:
get:
tags: [Admin]
summary: List credential refs for participant
parameters: [{ name: participantId, in: path, required: true, schema: { type: string } }]
responses:
"200": { description: List of credential refs }
post:
tags: [Admin]
summary: Add credential reference
operationId: createCredential
parameters: [{ name: participantId, in: path, required: true, schema: { type: string } }]
requestBody:
{
content:
{ "application/json": { schema: { $ref: "#/components/schemas/CredentialRef" } } },
}
responses:
"201": { description: Created }
"400": { description: Validation error }
/v1/admin/policies:
get:
tags: [Admin]
summary: List policies
parameters:
- name: tenantId
in: query
schema: { type: string }
responses:
"200": { description: List of policies }
post:
tags: [Admin]
summary: Create policy
operationId: createPolicy
requestBody:
{ content: { "application/json": { schema: { $ref: "#/components/schemas/Policy" } } } }
responses:
"201": { description: Created }
"400": { description: Validation error }
/v1/admin/policies/{policyId}:
get:
tags: [Admin]
summary: Get policy
parameters: [{ name: policyId, in: path, required: true, schema: { type: string } }]
responses:
"200": { description: Policy }
"404": { description: Not found }
put:
tags: [Admin]
summary: Update policy
operationId: updatePolicy
parameters: [{ name: policyId, in: path, required: true, schema: { type: string } }]
requestBody:
{ content: { "application/json": { schema: { $ref: "#/components/schemas/Policy" } } } }
responses:
"200": { description: Updated }
"404": { description: Not found }
delete:
tags: [Admin]
summary: Delete policy
operationId: deletePolicy
parameters: [{ name: policyId, in: path, required: true, schema: { type: string } }]
responses:
"204": { description: Deleted }
"404": { description: Not found }
components:
schemas:
# --- Resolver request/response (aligned with data-model and resolution-algorithm) ---
ResolveRequest:
type: object
required: [identifiers]
properties:
identifiers:
type: array
minItems: 1
items: { $ref: "#/components/schemas/IdentifierInput" }
serviceContext:
$ref: "#/components/schemas/ServiceContext"
constraints:
$ref: "#/components/schemas/ResolveConstraints"
tenant: { type: string, description: "Tenant scope for resolution" }
desiredProtocols:
type: array
items: { type: string }
description: "Preferred protocol domains (e.g. as4, ss7, peppol)"
IdentifierInput:
type: object
required: [type, value]
properties:
type:
{
type: string,
description: "Identifier type (e.g. as4.partyId, e164, peppol.participantId)",
}
value: { type: string }
scope: { type: string }
ServiceContext:
type: object
description: |
For ISO 20022 FI-to-FI (profile as4.fifi.iso20022.v1), service = `iso20022.fi` and action is one of
credit.transfer, fi.credit.transfer, payment.status, payment.cancellation, resolution.of.investigation, statement, notification.
properties:
service: { type: string }
action: { type: string }
process: { type: string }
documentType: { type: string }
ResolveConstraints:
type: object
properties:
trustDomain: { type: string }
region: { type: string }
jurisdiction: { type: string }
maxResults: { type: integer, minimum: 1 }
networkBrand:
type: string
description: "Card network (visa, mastercard, amex, discover, diners)"
tenantContract: { type: string, description: "Tenant contract for routing" }
connectivityGroup: { type: string }
requiredCapability: { type: string }
messageType: { type: string, description: "e.g. ISO8583 MTI or AS4 service/action" }
ResolveResponse:
type: object
required: [directives]
properties:
primary: { $ref: "#/components/schemas/RouteDirective" }
alternates:
type: array
items: { $ref: "#/components/schemas/DirectiveWithReason" }
directives:
type: array
items: { $ref: "#/components/schemas/RouteDirective" }
ttl: { type: integer, description: "Cache TTL in seconds" }
traceId: { type: string, format: uuid }
correlationId: { type: string }
failure_policy: { $ref: "#/components/schemas/FailurePolicy" }
negative_cache_ttl: { type: integer, description: "TTL for negative cache when no match" }
resolution_trace:
type: array
items: { $ref: "#/components/schemas/ResolutionTraceEntry" }
DirectiveWithReason:
type: object
required: [directive]
properties:
directive: { $ref: "#/components/schemas/RouteDirective" }
reason: { type: string }
FailurePolicy:
type: object
properties:
retry: { type: boolean }
backoff: { type: string }
circuitBreak: { type: boolean }
ResolutionTraceEntry:
type: object
properties:
source: { type: string }
directiveIndex: { type: integer }
message: { type: string }
RouteDirective:
description: "Normalized routing output; see architecture/route-directive.md and route-directive.schema.json"
type: object
required: [target_protocol, target_address]
properties:
target_protocol: { type: string }
target_address: { type: string }
transport_profile: { type: string }
security:
type: object
properties:
signRequired: { type: boolean }
encryptRequired: { type: boolean }
keyRefs: { type: array, items: { type: string } }
algorithms: { type: object }
service_context:
type: object
properties:
service: { type: string }
action: { type: string }
serviceIndicator: { type: string }
qos:
type: object
properties:
retries: { type: integer }
receiptsRequired: { type: boolean }
ordering: { type: string }
ttl_seconds: { type: integer }
evidence:
oneOf:
- type: object
properties:
source: { type: string }
lastVerified: { type: string, format: date-time }
confidenceScore: { type: number }
signature: { type: string }
- type: array
items:
type: object
properties:
source: { type: string }
freshness: { type: string, format: date-time }
confidence: { type: number }
signature: { type: string }
# --- Admin entities (aligned with data-model) ---
Tenant:
type: object
properties:
id: { type: string }
name: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
Participant:
type: object
properties:
id: { type: string }
tenantId: { type: string }
name: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
Identifier:
type: object
properties:
id: { type: string }
participantId: { type: string }
identifier_type: { type: string }
value: { type: string }
scope: { type: string }
priority: { type: integer }
verified_at: { type: string, format: date-time }
Endpoint:
type: object
properties:
id: { type: string }
participantId: { type: string }
protocol: { type: string }
address: { type: string }
profile: { type: string }
priority: { type: integer }
status: { type: string, enum: [active, inactive, draining] }
CredentialRef:
type: object
properties:
id: { type: string }
participantId: { type: string }
credential_type: { type: string, enum: [tls, sign, encrypt] }
vault_ref: { type: string }
fingerprint: { type: string }
valid_from: { type: string, format: date-time }
valid_to: { type: string, format: date-time }
Policy:
type: object
properties:
id: { type: string }
tenantId: { type: string }
rule_json: { type: object, description: "ABAC rule" }
effect: { type: string, enum: [allow, deny] }
priority: { type: integer }

View File

@@ -0,0 +1,120 @@
syntax = "proto3";
package as411.resolver.v1;
option go_package = "github.com/as4-411/api/proto/resolver/v1;resolverv1";
// ResolverService provides resolution of identifiers to routing directives.
// Aligned with REST /v1/resolve and /v1/bulk-resolve; see OpenAPI and route-directive.schema.json.
service ResolverService {
rpc Resolve(ResolveRequest) returns (ResolveResponse);
rpc BulkResolve(BulkResolveRequest) returns (BulkResolveResponse);
}
message ResolveRequest {
repeated IdentifierInput identifiers = 1;
ServiceContext service_context = 2;
ResolveConstraints constraints = 3;
string tenant = 4;
repeated string desired_protocols = 5;
}
message IdentifierInput {
string type = 1; // e.g. as4.partyId, e164, peppol.participantId
string value = 2;
string scope = 3; // e.g. BIC, LEI
}
message ServiceContext {
string service = 1; // e.g. iso20022.fi
string action = 2; // e.g. credit.transfer
string process = 3;
string document_type = 4;
}
message ResolveConstraints {
string trust_domain = 1;
string region = 2;
string jurisdiction = 3;
int32 max_results = 4;
string network_brand = 5;
string tenant_contract = 6;
string connectivity_group = 7;
string required_capability = 8;
string message_type = 9;
}
message ResolveResponse {
RouteDirective primary = 1;
repeated DirectiveWithReason alternates = 2;
repeated RouteDirective directives = 3;
int32 ttl = 4;
string trace_id = 5;
string correlation_id = 6;
FailurePolicy failure_policy = 7;
int32 negative_cache_ttl = 8;
repeated ResolutionTraceEntry resolution_trace = 9;
}
message RouteDirective {
string target_protocol = 1;
string target_address = 2;
string transport_profile = 3; // e.g. as4.fifi.iso20022.v1
RouteDirectiveSecurity security = 4;
RouteDirectiveServiceContext service_context = 5;
RouteDirectiveQos qos = 6;
int32 ttl_seconds = 7;
repeated EvidenceItem evidence = 8;
}
message EvidenceItem {
string source = 1;
string freshness = 2; // date-time
double confidence = 3;
string signature = 4;
}
message RouteDirectiveSecurity {
bool sign_required = 1;
bool encrypt_required = 2;
repeated string key_refs = 3;
map<string, string> algorithms = 4;
}
message RouteDirectiveServiceContext {
string service = 1;
string action = 2;
string service_indicator = 3;
}
message RouteDirectiveQos {
int32 retries = 1;
bool receipts_required = 2;
string ordering = 3;
}
message DirectiveWithReason {
RouteDirective directive = 1;
string reason = 2;
}
message FailurePolicy {
bool retry = 1;
string backoff = 2;
bool circuit_break = 3;
}
message ResolutionTraceEntry {
string source = 1;
int32 directive_index = 2;
string message = 3;
}
message BulkResolveRequest {
repeated ResolveRequest requests = 1;
}
message BulkResolveResponse {
repeated ResolveResponse results = 1;
string trace_id = 2;
}

View File

@@ -0,0 +1,110 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://as4-411.example/schemas/route-directive.json",
"title": "RouteDirective and ResolveResponse",
"description": "Formal schema for routing directive and resolve response. See docs/architecture/route-directive.md.",
"definitions": {
"RouteDirectiveSecurity": {
"type": "object",
"properties": {
"signRequired": { "type": "boolean" },
"encryptRequired": { "type": "boolean" },
"keyRefs": { "type": "array", "items": { "type": "string" } },
"algorithms": { "type": "object" }
}
},
"RouteDirectiveServiceContext": {
"type": "object",
"properties": {
"service": { "type": "string" },
"action": { "type": "string" },
"serviceIndicator": { "type": "string" }
}
},
"RouteDirectiveQos": {
"type": "object",
"properties": {
"retries": { "type": "integer" },
"receiptsRequired": { "type": "boolean" },
"ordering": { "type": "string" }
}
},
"EvidenceItem": {
"type": "object",
"properties": {
"source": { "type": "string" },
"freshness": { "type": "string", "format": "date-time" },
"confidence": { "type": "number" },
"signature": { "type": "string" }
}
},
"RouteDirective": {
"type": "object",
"required": ["target_protocol", "target_address"],
"properties": {
"target_protocol": { "type": "string" },
"target_address": { "type": "string" },
"transport_profile": { "type": "string" },
"security": { "$ref": "#/definitions/RouteDirectiveSecurity" },
"service_context": { "$ref": "#/definitions/RouteDirectiveServiceContext" },
"qos": { "$ref": "#/definitions/RouteDirectiveQos" },
"ttl_seconds": { "type": "integer" },
"evidence": {
"type": "array",
"items": { "$ref": "#/definitions/EvidenceItem" }
}
}
},
"DirectiveWithReason": {
"type": "object",
"required": ["directive"],
"properties": {
"directive": { "$ref": "#/definitions/RouteDirective" },
"reason": { "type": "string" }
}
},
"FailurePolicy": {
"type": "object",
"properties": {
"retry": { "type": "boolean" },
"backoff": { "type": "string" },
"circuitBreak": { "type": "boolean" }
}
},
"ResolutionTraceEntry": {
"type": "object",
"properties": {
"source": { "type": "string" },
"directiveIndex": { "type": "integer" },
"message": { "type": "string" }
}
},
"ResolveResponse": {
"type": "object",
"properties": {
"primary": { "$ref": "#/definitions/RouteDirective" },
"alternates": {
"type": "array",
"items": { "$ref": "#/definitions/DirectiveWithReason" }
},
"directives": {
"type": "array",
"items": { "$ref": "#/definitions/RouteDirective" }
},
"ttl": { "type": "integer" },
"traceId": { "type": "string", "format": "uuid" },
"correlationId": { "type": "string" },
"failure_policy": { "$ref": "#/definitions/FailurePolicy" },
"negative_cache_ttl": { "type": "integer" },
"resolution_trace": {
"type": "array",
"items": { "$ref": "#/definitions/ResolutionTraceEntry" }
}
}
}
},
"oneOf": [
{ "$ref": "#/definitions/RouteDirective" },
{ "$ref": "#/definitions/ResolveResponse" }
]
}

View File

View File

@@ -0,0 +1,54 @@
# CBDC settlement adapter (design)
ISO 20022 remains the **instruction layer**; token/CBDC rails provide the **settlement layer**. as4-411 does not perform settlement; it may expose routing and settlement-rail metadata so that a **settlement adapter** (outside the core directory) can choose and invoke the correct settlement channel.
---
## Model
- **Instruction layer:** ISO 20022 messages (e.g. pacs.008, pacs.009) over AS4; unchanged.
- **Settlement layer:** One of RTGS | CBDC ledger | tokenized deposit. The directory can store a **settlement rail** capability per participant or endpoint (or per routing artifact).
- **Settlement adapter:** A component (gateway-side or separate service) that receives the resolved directive plus an instruction reference, and performs or triggers settlement on the appropriate rail. It is **outside** as4-411 core.
---
## Directory extensions
- **Optional capability or metadata:** e.g. `settlement_rail` = `RTGS` | `CBDC` | `tokenized_deposit`.
- **Optional wallet/DLT endpoint:** For CBDC, the directory may store a wallet or DLT endpoint (or reference) per participant; as4-411 resolves PartyId → AS4 endpoint (unchanged) and may optionally return `settlement_rail` and `wallet_endpoint` (or equivalent) in the directive or in extended metadata for the settlement adapter to use.
- **RouteDirective extension:** See [route-directive.md](route-directive.md). Optional fields: `settlement_rail`, `wallet_endpoint` (or `settlement_endpoint`). Not required for MVP; add when CBDC/tokenized flows are in scope.
---
## Dual-track processing
```mermaid
flowchart LR
ISO["ISO 20022 instruction"]
AS4["AS4 transport"]
Dir["as4-411 directory"]
Adapter["Settlement adapter"]
RTGS["RTGS"]
CBDC["CBDC ledger"]
ISO --> AS4
Dir -->|"endpoint + settlement_rail"| Adapter
AS4 --> Adapter
Adapter --> RTGS
Adapter --> CBDC
```
1. Sender resolves PartyId via as4-411 → gets AS4 endpoint and optionally settlement_rail (and wallet/DLT endpoint if stored).
2. Sender sends ISO 20022 over AS4 to receiver.
3. Receiver (or a settlement adapter) uses the instruction plus optional settlement_rail / wallet_endpoint from directory to choose: settle via RTGS or via CBDC/tokenized ledger.
---
## Settlement adapter contract (minimal)
A **settlement adapter** is a component that:
- **Input:** Resolved RouteDirective (or equivalent), instruction reference (e.g. message id, business id), and optionally payload or reference to the ISO 20022 instruction.
- **Output:** Settlement result or callback (e.g. accepted, rejected, pending). Format is out of scope of as4-411; defined by the gateway or scheme.
- **Responsibility:** Map directive + instruction to the correct rail (RTGS, CBDC, tokenized deposit) and invoke the appropriate settlement API or ledger.
as4-411 does **not** implement this interface; it only provides routing directives and, when extended, optional settlement_rail and wallet_endpoint so that an external adapter can be implemented. No full implementation of a CBDC settlement adapter is required in this add-on; a stub or placeholder may be added in packages/connectors or packages/core for tests if desired.

View File

@@ -0,0 +1,40 @@
# Connector Specifications
This document describes ingest formats and behaviors for directory connectors. Each connector pulls or receives data from an external source and maps it into the core directory model (participants, identifiers, endpoints, capabilities, credentials, policies). **Trust, caching, and resilience:** see [ADR-005](../adr/005-connector-trust-and-caching.md). Each connector must define: trust anchors and signature validation; cache TTL and refresh (with jitter); timeouts, retries, circuit-breaker; and data provenance tagging (source, last_verified, confidence).
## SMP/SML (PEPPOL)
- **Source:** SML (Service Metadata Locator) for participant ID → SMP URL; SMP (Service Metadata Publisher) for document/process and endpoint + certificate.
- **Ingest:** Resolve participant ID via SML, fetch SMP metadata, map to:
- One participant per PEPPOL participant ID.
- Identifiers: `peppol.participantId`, optional `peppol.documentTypeId` / `peppol.processId`.
- Endpoints: HTTPS URL + transport profile (e.g. AS4).
- Credentials: certificate reference (fingerprint, validity); store only ref or fingerprint, not private key.
- **Refresh:** On-demand or periodic TTL; cache in directory for resilience. Evidence fields: `source: "smp"`, `lastVerified`, `confidenceScore`.
- **Trust (SMP/SML):** TLS and optional payload signing; document which CAs or pins are accepted. On SMP/SML failure, fall back to cached data only; do not serve stale beyond a configured max stale window.
## SS7 (GTT / Point Code)
- **Source:** GTT (Global Title Translation) tables, point code routing tables, optional number portability/range feeds.
- **Ingest:** Map E.164/GT → PC/SSN (and translation type) into directory or into **routing artifacts** (see data model and resolution algorithm). Participants may represent nodes or ranges; endpoints carry `protocol: ss7` and address as PC/SSN or route set reference.
- **Format:** Vendor-specific (CSV, JSON, or proprietary); connector normalizes to internal graph edges and artifact payloads. Tag all edges with provenance and validity; SS7 mapping is only as good as ingested sources (no implied authority).
## File / GitOps
- **Source:** File system or Git repo (YAML/JSON). Used for BIN tables, participant maps, and signed routing artifact bundles.
- **Ingest:**
- **BIN tables:** CSV or JSON with BIN range, brand, region, routing target, optional tenant override; stored as `routing_artifacts` with `artifact_type: bin_table`.
- **Participant/endpoint config:** YAML or JSON matching directory schema; validate and apply via Admin API or direct store writes.
- **Signed artifacts:** Payload + signature/fingerprint, `effective_from`/`effective_to`; validate and persist as routing artifacts.
- **Refresh:** Watch file or webhook; re-ingest on change. Optional version tags for rollback.
## KTT (Placeholder)
- **Source:** TBD per sector. Placeholder connector supports file + API ingest stubs.
- **Identifier types:** `ktt.*`; see [protocols/ktt.md](../protocols/ktt.md) when defined.
## Common Requirements
- All connectors must map into the same core entities; no rail-specific tables for “directory” data beyond optional routing_artifacts.
- Credentials: only references (vault_ref, fingerprint); never private keys.
- Audit: log ingest runs and failures; optional hash-chain for artifact integrity.

View File

@@ -0,0 +1,222 @@
# Data Model
Canonical persistence model for the as4-411 directory. Aligned with [OpenAPI schemas](../api/openapi.yaml) and the [resolution algorithm](resolution-algorithm.md).
## Tables (relational baseline)
### tenants
Multi-tenant isolation. All participant data is scoped by tenant.
| Column | Type | Description |
| ---------- | --------- | ----------------- |
| id | PK | Tenant identifier |
| name | string | Display name |
| created_at | timestamp | |
| updated_at | timestamp | |
### participants
Logical entity capable of sending/receiving messages (organization, node, service).
| Column | Type | Description |
| ---------- | --------- | ---------------------- |
| id | PK | Participant identifier |
| tenant_id | FK | References tenants |
| name | string | Display name |
| created_at | timestamp | |
| updated_at | timestamp | |
### identifiers
Typed identifier bound to a participant. Used for resolution lookup and cross-mapping.
| Column | Type | Description |
| --------------- | --------- | --------------------------------------------- |
| id | PK | |
| participant_id | FK | References participants |
| identifier_type | string | See [Identifier types](#identifier-types) |
| value | string | Identifier value |
| scope | string | Optional scope (e.g. scheme) |
| priority | integer | Resolution priority (higher = preferred) |
| verified_at | timestamp | When value was last verified (e.g. SMP fetch) |
### endpoints
Routable address for a protocol domain (HTTPS URL, MLLP, MQ queue, SS7 point code, etc.).
| Column | Type | Description |
| -------------- | ------- | --------------------------------------------------- |
| id | PK | |
| participant_id | FK | References participants |
| protocol | string | as4, ss7, smp, http, mq, etc. |
| address | string | Protocol-specific address (URL, PC/SSN, queue name) |
| profile | string | Transport profile (e.g. AS4 profile name) |
| priority | integer | Ranking for resolution |
| status | string | active, inactive, draining |
### capabilities
Supported services/actions/processes/document types and constraints.
| Column | Type | Description |
| ---------------- | ------ | ----------------------- |
| id | PK | |
| participant_id | FK | References participants |
| service | string | e.g. AS4 service value |
| action | string | e.g. AS4 action |
| process | string | e.g. PEPPOL process ID |
| document_type | string | e.g. document type ID |
| constraints_json | JSON | Additional constraints |
### credentials
References to key material in vault/KMS. No private keys stored in DB.
| Column | Type | Description |
| --------------- | --------- | --------------------------- |
| id | PK | |
| participant_id | FK | References participants |
| credential_type | string | tls, sign, encrypt |
| vault_ref | string | URI to vault/KMS |
| fingerprint | string | Certificate/key fingerprint |
| valid_from | timestamp | |
| valid_to | timestamp | |
### policies
Rules controlling resolution (tenant scope, trust domains, allow/deny, priority).
| Column | Type | Description |
| --------- | ------- | -------------------- |
| id | PK | |
| tenant_id | FK | References tenants |
| rule_json | JSON | ABAC rule definition |
| effect | string | allow, deny |
| priority | integer | Evaluation order |
### audit_log
Append-only audit trail for all modifications. Optional hash-chain for tamper-evidence.
| Column | Type | Description |
| ----------- | --------- | ------------------------------------- |
| id | PK | |
| at | timestamp | |
| actor | string | Who made the change |
| action | string | create, update, delete |
| resource | string | tenants, participants, etc. |
| resource_id | string | |
| payload | JSON | Before/after or diff |
| hash_prev | string | Optional: previous row hash for chain |
## Relationships
```mermaid
erDiagram
tenants ||--o{ participants : "has"
participants ||--o{ identifiers : "has"
participants ||--o{ endpoints : "has"
participants ||--o{ capabilities : "has"
participants ||--o{ credentials : "has"
tenants ||--o{ policies : "has"
participants {
string id PK
string tenant_id FK
string name
}
identifiers {
string id PK
string participant_id FK
string identifier_type
string value
int priority
}
endpoints {
string id PK
string participant_id FK
string protocol
string address
string status
}
```
- **Tenant** scopes all participants and policies.
- **Participant** has many identifiers, endpoints, capabilities, and credential refs.
- Resolution uses identifiers to find participants, then endpoints + capabilities + policies to produce directives.
## Graph layer (edges)
Cross-mapping and provenance are modeled as an explicit graph. An **edges** table (or equivalent) represents relationships with provenance and validity.
### edges
| Column | Type | Description |
| ----------- | --------- | ------------------------------------------ |
| id | PK | |
| from_type | string | Entity type (identifier, participant, etc.) |
| from_id | string | Source entity id |
| to_type | string | Target entity type |
| to_id | string | Target entity id |
| relation | string | Relation kind (e.g. resolves_to, has_endpoint) |
| confidence | number | 01 confidence score |
| source | string | Provenance (internal, smp, gtt_feed, etc.) |
| valid_from | timestamp | |
| valid_to | timestamp | Optional |
Relationship types: identifier → participant (resolves_to), participant → endpoint (has_endpoint), participant → capability (has_capability). When two sources give different data for the same logical edge, conflict resolution applies.
### Conflict resolution (deterministic)
When multiple candidates or edges exist, apply in order:
1. **Explicit priority** (from endpoint/identifier priority column)
2. **Policy** (allow/deny and policy priority)
3. **Freshness** (updated_at / verified_at / valid_from)
4. **Confidence** (edge or evidence confidence score)
5. **Lexical** (stable sort by id)
Documented in [resolution-algorithm.md](resolution-algorithm.md).
## RouteDirective (output schema)
Normalized object returned by the resolver. Must match [OpenAPI RouteDirective](../api/openapi.yaml#components/schemas/RouteDirective).
| Field | Type | Description |
| ----------------- | ------ | -------------------------------------------------- |
| target_protocol | string | e.g. as4, ss7 |
| target_address | string | Endpoint address (URL, PC/SSN, etc.) |
| transport_profile | string | Profile name |
| security | object | signRequired, encryptRequired, keyRefs, algorithms |
| service_context | object | service, action, or SS7 service indicator |
| qos | object | retries, receiptsRequired, ordering |
| ttl_seconds | int | Cache TTL for this directive |
| evidence | object | source, lastVerified, confidenceScore |
## Identifier types
Reference values for `identifier_type` and resolution input. See protocol domains in the master plan.
### AS4 domain
- `as4.partyId` (with optional partyIdType in scope)
- `as4.role` (initiator/responder)
- `as4.service`, `as4.action`, `as4.mpc`
For FI-to-FI, PartyId type is often BIC or LEI (see [iso20022-over-as4](../protocols/iso20022-over-as4.md)).
### PEPPOL / SMP
- `peppol.participantId`
- `peppol.documentTypeId`
- `peppol.processId`
### SS7 domain
- `e164` (MSISDN, E.164 format)
- `gt` (Global Title)
- `pc` (Point Code)
- `ssn` (Subsystem Number)
- `mccmnc` (mobile network identifiers where relevant)
Cross-mapping examples: `as4.partyId: "0088:123456789"``peppol.participantId: "0088:123456789"`; `e164``gt``pc/ssn` via GTT.

View File

@@ -0,0 +1,77 @@
# Resolution Algorithm
Deterministic resolution pipeline that produces ordered routing directives. Input/output contracts are defined in the [OpenAPI spec](../api/openapi.yaml); persistence shape is in the [data model](data-model.md).
## Precedence ladder (per rail)
When multiple sources can contribute a directive, apply this order. The first successful source wins unless overridden by tenant/contract config:
1. **Tenant override** — Tenant-specific routing artifact or endpoint override.
2. **Contract-specific config** — Contract or connectivity-group mapping.
3. **Internal curated directory** — Participants/endpoints stored in the directory (admin or connector).
4. **External authoritative directory** — SMP/SML, GTT feed, or other external source (cached).
5. **Fallback heuristics** — Optional, disabled by default (e.g. default route).
Log and expose **resolution_trace** in the response so callers see which source(s) contributed (e.g. "tenant override", "internal directory", "SMP cache"). See [route-directive.md](route-directive.md).
**Source-driven mappings (e.g. SS7):** Data from connectors (GTT, NP/range feeds) is only as good as the ingested sources. Expose confidence and `last_verified` in directives; tag edges with provenance. No implied authority—see [connectors.md](connectors.md).
## Pipeline (steps 19)
1. **Normalize input**
Parse and validate all identifiers in the request. Validate formats per type (E.164, PartyId, PC/SSN, etc.). Reject invalid or unsupported types early.
2. **Expand context**
Infer candidate identifier equivalences using the mapping graph (same participant: multiple identifier types pointing to the same participant). Build a set of "equivalent" identifiers for lookup.
3. **Candidate retrieval**
Query the directory store for participants and endpoints matching any of the normalized/expanded identifiers, within the requested tenant and constraints.
4. **Capability filter**
Retain only participants/endpoints whose capabilities match the requested service context (service, action, process, document type) and any constraints in the request. Constraints may include `requiredCapability`, `messageType` (e.g. ISO8583 MTI), and `networkBrand` for card rails.
5. **Policy filter**
Apply tenant-scoped policies (ABAC). Enforce trust domain, geo, compliance, and allow/deny rules. Remove any candidate that is denied or out of scope.
6. **Score and rank**
Score remaining candidates (see [Scoring](#scoring)). Sort by score descending; apply [tie-break rules](#determinism-and-tie-break) for stable ordering.
7. **Assemble directives**
For each ranked candidate, build a `RouteDirective`: map endpoint + participant to `target_protocol`, `target_address`, `transport_profile`, attach security refs (from credentials), `service_context`, `qos`, `ttl_seconds`, and `evidence` (source, lastVerified, confidenceScore).
8. **Sign response (optional)**
In multi-party setups, optionally sign the response for non-repudiation. Not required for MVP.
9. **Cache**
Store result in positive cache (keyed by normalized request + tenant) with TTL. On cache hit, return cached directives and skip steps 27. Negative results (no candidates after filters) may be cached with shorter TTL and invalidation hooks.
## Determinism and tie-break
- **Invariant:** Same inputs + same store state ⇒ same ordered list of directives.
- **Tie-break order** when scores are equal (aligned with [data-model conflict resolution](data-model.md#conflict-resolution-deterministic)):
1. **Explicit priority** (endpoint/identifier priority from store) — higher first.
2. **Policy** (allow/deny and policy priority).
3. **Freshness** (updated_at / verified_at / valid_from).
4. **Confidence** (edge or evidence confidence score).
5. **Lexical** — stable sort by deterministic key (e.g. participant id + endpoint id).
Implementation must use a fixed ordering (e.g. sort by `(score DESC, priority DESC, updated_at DESC, id ASC)`).
## Scoring
Factors that contribute to the score (combined by weighted sum or ordered rules; exact weights are implementation/config):
| Factor | Description |
| ---------------------- | ----------------------------------------------------------------------- |
| Explicit priority | From `identifiers.priority` / `endpoints.priority` in the store. |
| Endpoint health/status | Prefer `active` over `draining` over `inactive`. |
| Freshness/verification | Higher score when `identifiers.verified_at` or evidence is recent. |
| Trust domain affinity | Match between requested trust domain and endpoint/participant metadata. |
Scoring must be deterministic: same inputs and same data ⇒ same scores and thus same order after tie-break.
## Caching
- **Positive cache:** Key = hash or canonical form of (normalized identifiers, serviceContext, constraints, tenant). Value = ordered list of directives + TTL. Reuse until TTL expires or explicit invalidation.
- **Negative cache:** When no candidates survive filters, cache "no result" with a shorter TTL to avoid thundering herd on missing keys. Invalidation: on participant/identifier/endpoint/policy change for that tenant or key scope.
- **Invalidation hooks:** Connectors or admin updates that change participants/endpoints/policies should invalidate affected cache keys (by tenant, participant id, or key prefix). Optional: publish events for external caches.

View File

@@ -0,0 +1,34 @@
# RouteDirective Contract
Schema: [../api/route-directive.schema.json](../api/route-directive.schema.json). OpenAPI: [../api/openapi.yaml](../api/openapi.yaml).
## Response Shape
- **primary:** One directive (best match). **alternates:** ordered fallback list with optional **reason** per entry.
- **directives:** Backward compat: `[primary, ...alternates]`.
- **failure_policy:** Optional retry, backoff, circuitBreak.
- **evidence[]:** source, freshness, confidence, optional signature (array for multiple sources).
- **negative_cache_ttl:** TTL for negative (no-match) cache.
- **resolution_trace:** Which source(s) contributed (tenant override, internal directory, SMP cache, etc.).
- **Idempotency:** Same request + same store ⇒ same ordering. Optional correlationId.
## Multi-Hop
Multi-hop (intermediary) routing is out of scope for MVP.
## Failover
Gateway uses primary first; on failure may try alternates in order. failure_policy is advisory.
## Optional extensions (settlement)
For CBDC/tokenized settlement overlays, a directive may include optional metadata for a settlement adapter (see [cbdc-settlement-adapter.md](cbdc-settlement-adapter.md)):
- **settlement_rail:** One of `RTGS` | `CBDC` | `tokenized_deposit` (when stored per participant/endpoint).
- **wallet_endpoint** (or **settlement_endpoint**): Optional URL or reference for wallet/DLT when settlement_rail is CBDC or tokenized. Not required for MVP; schema and OpenAPI may be extended when in scope.
## Invariants
1. Match: at least one of primary or directives present. No match: empty and negative_cache_ttl set.
2. When primary present, directives[0] equals primary.
3. evidence and resolution_trace must not contain sensitive data.

View File

@@ -0,0 +1,32 @@
# Tenant Model and Row-Level Security
## Global vs Tenant-Private
- **Global objects:** Public or shared data that can be read across tenants (e.g. BIC, LEI, BIN range metadata). Stored with `tenant_id` null or a dedicated "global" tenant. Used for cross-tenant lookup when the rail has a public identifier scheme.
- **Tenant-private objects:** Participant-specific data, merchant IDs, terminal IDs, contractual endpoints. Always scoped by `tenant_id`. Queries must supply tenant (from auth or request) so that only that tenant's rows are visible.
## Enforcement
- **Postgres Row Level Security (RLS):** Enable RLS on `participants`, `identifiers`, `endpoints`, `capabilities`, `credentials`, `policies`, and optionally `routing_artifacts`. Policies: `tenant_id = current_setting('app.current_tenant_id')` or equivalent. Global rows: allow when `tenant_id IS NULL` or when reading public identifiers.
- **Application layer:** Resolver and Admin API must set tenant context (e.g. from JWT or request parameter) before querying. Never return rows from another tenant.
- **Per-tenant encryption:** For Tier 2+ data (see [data-classification](../security/data-classification.md)), use per-tenant encryption keys so that key compromise affects only one tenant.
## Caching
- Cache key **includes tenant**: same request for different tenants must not share a cache entry.
- Optional per-tenant TTL or invalidation rules (e.g. shorter TTL for high-churn tenants).
- Negative cache: key includes tenant; invalidate on any change for that tenant.
## RLS Policy Summary
| Table | Policy (conceptual) |
| ---------------- | -------------------------------------------------------- |
| participants | WHERE tenant_id = current_tenant OR tenant_id IS NULL |
| identifiers | JOIN participants; same tenant or global |
| endpoints | JOIN participants; same tenant |
| capabilities | JOIN participants; same tenant |
| credentials | JOIN participants; same tenant |
| policies | WHERE tenant_id = current_tenant |
| routing_artifacts| WHERE tenant_id = current_tenant OR tenant_id IS NULL |
Apply `current_tenant` from connection/session (e.g. set by API after auth).

View File

@@ -0,0 +1,26 @@
# Testing Strategy
Testing approach for the as4-411 directory and resolver. Ensures determinism, protocol correctness, integration, and resilience.
## Property-based tests for determinism
- **Invariant:** Same normalized request + same store state ⇒ same ordered list of directives (see [resolution-algorithm](resolution-algorithm.md) and [ADR-002](../adr/002-resolution-scoring-determinism.md)).
- Use property-based testing (e.g. fast-check, Hypothesis) to generate many (request, store snapshot) pairs and assert that repeated resolution runs produce identical outputs. Vary identifiers, tenant, constraints, and store contents within valid ranges.
- Tie-break and scoring must be deterministic; tests should catch any dependence on iteration order or non-deterministic randomness.
## Golden test vectors per rail
- Each rail (or protocol adapter) should have **golden test vectors** derived from the [\_rail-template](../protocols/_rail-template.md) “Sample payloads and test vectors” section.
- Tests: given a fixed request and a small, fixed store (or artifact set), the resolver output must match the golden directive list (primary + alternates, protocol, address, evidence). Update goldens only when the spec or algorithm intentionally changes; review changes.
## Integration harness
- **Stack:** Postgres (migrations applied) + resolver service + sample gateway client (or mock that calls resolve and validates response shape).
- **Scenarios:** Create participants, identifiers, endpoints, and routing artifacts via Admin API or store; call resolve with various identifiers and constraints; assert directives and resolution_trace. Include multi-tenant isolation: data for tenant A must not appear in resolve for tenant B.
- Run in CI; use container or test DB so that migrations and seed data are reproducible.
## Chaos tests for connectors
- **Timeouts and retries:** Simulate connector backends (SMP, file, GTT) that delay or fail. Assert timeout and retry behavior per [ADR-005](../adr/005-connector-trust-and-caching.md) and [connectors](connectors.md).
- **Circuit-breaker:** After N failures, connector should open circuit and (per policy) fall back to cache-only or fail closed. Tests should verify circuit state and that no unbounded retries occur.
- **Fallback to cache:** When external source is unavailable, resolver should use cached data only within max stale window; tests assert no stale data beyond that and correct resolution_trace (e.g. “SMP cache” when SMP is down).

View File

@@ -0,0 +1,61 @@
# as4-411 resolution examples for ISO 20022 over AS4 (FI-to-FI)
# Profile: as4.fifi.iso20022.v1. Service: iso20022.fi. Actions: credit.transfer, fi.credit.transfer, etc.
# BIC/LEI Tier 0/1 per security/data-classification.md.
bic_example:
request:
identifiers:
- type: as4.partyId
value: BANKUS33XXX
scope: BIC
serviceContext:
service: iso20022.fi
action: credit.transfer
response_excerpt:
primary:
target_protocol: as4
target_address: https://as4.bankus.com/fi
transport_profile: as4.fifi.iso20022.v1
security:
signRequired: true
encryptRequired: true
keyRefs:
- vault://certs/bankus/iso20022
service_context:
service: iso20022.fi
action: credit.transfer
evidence:
source: internal directory
lastVerified: "2025-02-07T12:00:00Z"
resolution_trace:
- source: internal directory
# Output: Endpoint https://as4.bankus.com/fi, EncryptionCert vault://certs/bankus/iso20022, Profile as4.fifi.iso20022.v1, Receipts signed
lei_example:
request:
identifiers:
- type: as4.partyId
value: "5493001KJTIIGC8Y1R12"
scope: LEI
serviceContext:
service: iso20022.fi
action: fi.credit.transfer
response_excerpt:
primary:
target_protocol: as4
target_address: https://as4.bankus.com/fi
transport_profile: as4.fifi.iso20022.v1
security:
signRequired: true
encryptRequired: true
keyRefs:
- vault://certs/bankus/iso20022
service_context:
service: iso20022.fi
action: fi.credit.transfer
evidence:
source: internal directory
message: "LEI to BIC mapping applied"
resolution_trace:
- source: internal directory
# Mapping: LEI -> BIC(s) -> AS4 Endpoint. Evidence includes LEI->BIC mapping source.

0
docs/operations/.gitkeep Normal file
View File

View File

@@ -0,0 +1,22 @@
# Promotion and Sync (GitOps)
Staging to validated to production promotion for directory and routing artifacts, with signed bundles and CLI workflows.
## Model
- **Staging:** Editable branch or workspace where artifacts (participant/endpoint config, BIN tables, signed routing bundles) are authored and validated.
- **Validated:** Output of validation (schema, lint, and rail-specific checks). Artifacts are signed and ready for promotion.
- **Production:** Deployed state consumed by the resolver and gateways. Updated only via promote from validated; rollback to a previous validated bundle when needed.
Signed bundles carry payload plus signature/fingerprint and optional effective_from / effective_to. Use the existing signed-bundle and routing artifact format (see [data model](../architecture/data-model.md) and [connectors](../architecture/connectors.md)).
## CLI commands
When [packages/cli](../../packages/cli) (or equivalent) is present, support these workflows:
- **as4-411-cli diff** — Compare staging artifact set (or branch) against current production (or another ref). Output human- and machine-readable diff (participants, endpoints, routing_artifacts, policies).
- **as4-411-cli validate** — Validate staging: schema validation and linting per rail (using [\_rail-template](../protocols/_rail-template.md) and protocol validators). Exit non-zero on failure; report errors by file and rule.
- **as4-411-cli promote** — Promote validated, signed bundle to production. Verify signatures and effective dates; apply to store (or write to production artifact store). Record promotion in audit_log.
- **as4-411-cli rollback** — Rollback production to a previous validated revision (by tag or bundle id). Re-apply that revision's artifacts and invalidate affected caches.
Schema validation and linting must run per rail so that protocol-specific rules (e.g. BIN format, identifier types) are enforced before promotion.

View File

@@ -0,0 +1,49 @@
# Rail Definition Template
**Required before implementing a new rail or protocol adapter.** Copy this template, fill every section, and ensure the rail doc is complete before merge. See [catalog.md](catalog.md).
---
## 1. Owner and Authority
- **Governing body / owner:**
- **Normative specifications (links):**
- **Message families / standards:**
## 2. Identifier Scheme(s)
- **Identifier types** (e.g. partyId, BIC, BIN):
- **Validation rules** (format, length, allowed characters):
- **Uniqueness and scope** (global vs tenant-scoped):
## 3. Addressing and Endpoint Rules
- **Endpoint types** (URL, queue, point code, etc.):
- **Address format** (regex or pattern):
- **Transport profiles** (if any):
## 4. Security Model
- **Authentication** (how participants are authenticated):
- **Integrity** (signing, hashing):
- **Confidentiality** (encryption, TLS):
## 5. Discovery Model
- **Public directory?** (yes / no / partial)
- **Contractual only?** (e.g. BIN tables, member config)
- **Authoritative source** (SML/SMP, vendor API, internal only):
## 6. Compliance Constraints
- **Regulatory** (e.g. PCI-DSS, data residency):
- **Data handling** (what must not be stored, encryption requirements):
## 7. Sample Payloads and Test Vectors
- **Sample request/response or message** (anonymized):
- **Test vectors** (identifier in → expected directive or behavior):
---
*Block merge of new rails until this template is completed for the rail.*

39
docs/protocols/cards.md Normal file
View File

@@ -0,0 +1,39 @@
# Card Networks (Visa, Mastercard, Amex, Discover, Diners)
## Scope
Card rails are **private routing artifacts** (BIN tables, acquirer routing). There is **no public "discover Visa endpoint"** behavior. Ingestion is from internal systems only; strong encryption and access controls apply. The directory stores routing tables and returns directives to an ISO8583/API switch. Never store PAN; BIN ranges only. Merchant ID (MID), Terminal ID (TID), and contract identifiers are **Tier 2** (confidential)—encrypt at rest and restrict access. See [data-classification](../security/data-classification.md).
## Identifier Taxonomy
- **pan.bin** — BIN/IIN range (68 digits only); never full PAN.
- **mid**, **tid**, **caid** — Merchant/terminal/card-acceptor IDs (tenant-scoped).
- **processorId** / **acquirerId** — Tenant/contract scoped.
- **network.brand** — Constraint: visa, mastercard, amex, discover, diners.
Do not store PAN or token values in plaintext.
## Endpoints
- **iso8583.tcp** — Host:port, mTLS/VPN.
- **api.https** — Base URL + auth.
- **file.sftp** — Clearing files.
- **mq** — Internal switch.
Profile indicates channel (e.g. visa-base1, mc-mip).
## BIN-Table Model
- Artifact type: **bin_table**. Payload: versioned entries with binPrefix, binLength, brand, region, routingTarget, optional tenantId.
- Resolver matches request BIN to longest-matching prefix and returns directive with target_address = routingTarget. Per-tenant overrides supported.
## Directive Outputs
- ISO8583: target_protocol iso8583, target_address host:port.
- API: target_protocol api/https, target_address base URL.
Capabilities: auth.request/response, clearing.presentment, chargeback, reversal, advice, tokenization, 3ds.
## Security
- Store BIN ranges only; no PAN/token. Field-level encryption for merchant/terminal IDs. Strict RBAC and audit for card-related records. See security/key-reference-model.md.

23
docs/protocols/catalog.md Normal file
View File

@@ -0,0 +1,23 @@
# Protocol and Rail Catalog
Extensibility catalog for additional rails. **New rails require a completed doc derived from [_rail-template.md](_rail-template.md) before merge.**
Adapters are added incrementally; each defines identifier types, endpoint profiles, capability taxonomy, and optional connector in `packages/connectors/`.
## Implemented
- **AS4 / PEPPOL** — See data-model identifier types; SMP/SML connector spec in docs/architecture/connectors.md.
- **ISO 20022 over AS4 (FI-to-FI)** — [iso20022-over-as4.md](iso20022-over-as4.md). PartyId (BIC, LEI) → endpoint + cert + AS4 profile; Service/Action for pacs/camt; directory-only (no ISO 20022 parsing, no settlement). Scheme-specific profiles: [iso20022-scheme-profiles.md](iso20022-scheme-profiles.md) (TARGET2, Fedwire, CHAPS).
- **SS7** — e164, gt, pc, ssn; GTT via routing artifacts.
- **Card networks** — [cards.md](cards.md); pan.bin, BIN table, ISO8583.
- **DTC / Digital securities** — [dtc.md](dtc.md); lei, bic, dtc.participantId, dtc.accountId.
- **KTT** — [ktt.md](ktt.md); placeholder rail.
## Candidate Families (priority order)
- **Payments:** ISO 20022 over AS4 (FI-to-FI) documented; ISO 20022 over API, SWIFT, Fedwire-like remain candidates.
- **EDI / B2B:** AS2, SFTP EDI, RosettaNet.
- **Healthcare:** HL7 MLLP, FHIR endpoints (endpoint registry + certs).
- **Identity:** DID, DNSSEC, PKI directories.
- **Message brokers:** Kafka topics, NATS subjects, AMQP queues (logical addresses + ACL).
Integration: add identifier validators in core, register profile in protocol_registry, optional connector; document in a new docs/protocols/*.md.

44
docs/protocols/dtc.md Normal file
View File

@@ -0,0 +1,44 @@
# DTC / Digital Securities (DTCC Ecosystem)
## Overview
DTC and DTC-related messaging cover securities post-trade and custody. Participants include broker-dealers, custodian banks, and clearing members. Directory use is often **client-owned configuration**; public directory availability is limited.
## Scope
**Identity mapping** (LEI, BIC, participant IDs) plus **contractual endpoint profiles**. Optional import from customer-managed config (files or APIs). Do **not** claim automated discovery unless an authoritative or licensed directory feed exists. Prefer routing artifacts and admin API for participant/endpoint maps.
## Identifier Taxonomy
| Type | Description | Scope |
|------|-------------|--------|
| `lei` | Legal Entity Identifier | Public/registry |
| `bic` | Bank Identifier Code (SWIFT) | Market identifier |
| `dtc.participantId` | DTC/internal participant ID | Tenant/confidential |
| `dtc.accountId` | Custody/account ID (proprietary) | Tenant/confidential |
Historical: `duns` (D&B) where still in use.
## Endpoint Profiles
| Profile | Protocol | Description |
|---------|----------|-------------|
| `dtcc-mq` | MQ | DTCC connectivity / message system |
| `sftp.*` | SFTP | File-based instructions |
| `https.*` | HTTPS/AS2/AS4 | API or EDI over HTTP |
Address format is vendor- or channel-specific (queue name, path, URL).
## Capability Taxonomy
- `securities.settlement.*` — Settlement instructions and messages.
- `securities.corpactions.*` — Corporate actions.
- `securities.assetservices.*` — Asset servicing.
Used in resolution to match service context (e.g. request for settlement instructions).
## Tenancy and Confidentiality
- **Strong tenant scoping:** DTC and account identifiers are frequently confidential. Resolution must be scoped by tenant; no cross-tenant leakage.
- **Prefer integration via client-owned configuration:** Ingest from client-provided files or APIs rather than assuming a public directory. Use [routing artifacts](../architecture/data-model.md) and admin API for participant/endpoint maps.
- **Audit:** All access to DTC-related participant and endpoint data must be audited.

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Anonymized AS4/ebMS3-style sample for ISO 20022 FI-to-FI.
References: OASIS ebMS 3.0, AS4. Payload is opaque (ISO 20022 XML).
Placeholders: SENDERBICXXX, RECEIVERBICXXX; endpoint resolved via directory. -->
<Envelope xmlns="http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/">
<Header>
<Messaging>
<UserMessage>
<MessageInfo>
<MessageId>uuid-sample-message-id-001</MessageId>
<Timestamp>2025-02-07T12:00:00Z</Timestamp>
<RefToMessageId>optional-ref</RefToMessageId>
</MessageInfo>
<PartyInfo>
<From>
<PartyId>SENDERBICXXX</PartyId>
<Role>http://example.org/roles/sender</Role>
</From>
<To>
<PartyId>RECEIVERBICXXX</PartyId>
<Role>http://example.org/roles/receiver</Role>
</To>
</PartyInfo>
<CollaborationInfo>
<AgreementRef>as4.fifi.iso20022.v1</AgreementRef>
<Service type="application">iso20022.fi</Service>
<Action>credit.transfer</Action>
<ConversationId>conv-sample-001</ConversationId>
</CollaborationInfo>
<PayloadInfo>
<PartInfo href="cid:pacs008.xml">
<Schema location="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08"/>
</PartInfo>
</PayloadInfo>
</UserMessage>
</Messaging>
</Header>
<!-- Body: multipart with signed+encrypted payload (omitted here; AS4 treats as opaque) -->
</Envelope>

View File

@@ -0,0 +1,299 @@
# ISO 20022 over AS4 (FI-to-FI)
Canonical reference for ISO 20022 messages transported via AS4 between financial institutions. Used by gateway teams, auditors, and scheme designers. as4-411 provides **directory and resolution only**; it does not parse ISO 20022, perform settlement, or replace AS4 MSH (see [ADR-000](../adr/000-scope-and-non-goals.md)).
---
## 1. Overview and layer model
ISO 20022 is the **business payload** (pacs, camt); AS4 (ebMS 3.0) is the **messaging envelope**; HTTPS is the **transport**. Identity-based addressing: PartyId (BIC, LEI) is resolved by the directory to endpoint URL, certificates, and profile. No URL in the envelope—only PartyId.
```mermaid
flowchart TB
subgraph business [Business Layer]
ISO20022["ISO 20022 XML (pacs, camt)"]
end
subgraph messaging [Messaging Layer]
AS4["ebMS 3.0 / AS4 Envelope"]
AS4_detail["PartyId, Service, Action, MPC, Receipts"]
end
subgraph transport [Transport Layer]
HTTPS["HTTPS + TLS"]
end
ISO20022 --> AS4
AS4 --> AS4_detail
AS4_detail --> HTTPS
```
**Principle:** ISO 20022 never handles transport. AS4 never interprets business content.
---
## 2. Message classes (pacs, camt)
| ISO 20022 Message | Description |
| ----------------- | ----------- |
| pacs.008 | Customer Credit Transfer |
| pacs.009 | Financial Institution Credit Transfer |
| pacs.002 | Payment Status |
| camt.056 | Payment Cancellation |
| camt.029 | Resolution of Investigation |
| camt.053 | Statement |
| camt.054 | Debit/Credit Notification |
Payload: XML, UTF-8, strict XSD validation. May include BICs, LEIs, clearing member IDs. **AS4 treats payload as opaque.**
---
## 3. AS4 envelope mapping
ebMS headers: From.PartyId, To.PartyId, Service, Action, ConversationId, MessageId, MPC. **Profile ID:** `as4.fifi.iso20022.v1`. **Service:** `iso20022.fi`. **Action:** one per ISO 20022 message type (see §4).
Header skeleton (simplified):
```xml
<eb:Messaging>
<eb:UserMessage>
<eb:MessageInfo>
<eb:MessageId>uuid</eb:MessageId>
<eb:ConversationId>business-id</eb:ConversationId>
</eb:MessageInfo>
<eb:PartyInfo>
<eb:From>
<eb:PartyId type="BIC">BANKDEFFXXX</eb:PartyId>
</eb:From>
<eb:To>
<eb:PartyId type="BIC">BANKUS33XXX</eb:PartyId>
</eb:To>
</eb:PartyInfo>
<eb:CollaborationInfo>
<eb:Service>iso20022.fi</eb:Service>
<eb:Action>credit.transfer</eb:Action>
</eb:CollaborationInfo>
<eb:PayloadInfo>
<eb:PartInfo href="cid:pacs008.xml"/>
</eb:PayloadInfo>
</eb:UserMessage>
</eb:Messaging>
```
Payload: detached MIME; **signed → encrypted → attached**. Full sample: [iso20022-as4-sample-envelope.xml](iso20022-as4-sample-envelope.xml).
---
## 4. Addressing and identity
- **PartyId types:** BIC, LEI, internal.bank.code, scheme-specific (e.g. TARGET2, RTGS).
- **Directory:** maps PartyId → HTTPS endpoint URL + transport profile + receiver encryption cert + receipt requirements. **Profile:** `as4.fifi.iso20022.v1`.
- Discovery is directory-driven (contractual or internal); no public “discover any BIC” without directory data. See [data-model](../architecture/data-model.md) (`as4.partyId`, scope/partyIdType).
### AS4 FI-to-FI profile definition
| Feature | Requirement |
| ------- | ----------- |
| ebMS Version | ebMS 3.0 |
| Transport | HTTPS |
| Payload | ISO 20022 XML |
| Signing | Mandatory |
| Encryption | Mandatory |
| Receipts | Signed Receipts |
| Duplicate Detection | Enabled |
| Reliability | Exactly-once delivery |
### MPC usage
| MPC | Purpose |
| --- | ------- |
| `default` | Normal traffic |
| `urgent` | Time-critical payments |
| `bulk` | High-volume settlement batches |
### Service / Action taxonomy
**Service namespace:** `iso20022.fi`. **Rule:** one ISO 20022 message type = one AS4 Action.
| ISO 20022 Message | Action |
| ----------------- | ------ |
| pacs.008 | credit.transfer |
| pacs.009 | fi.credit.transfer |
| pacs.002 | payment.status |
| camt.056 | payment.cancellation |
| camt.029 | resolution.of.investigation |
| camt.053 | statement |
| camt.054 | notification |
---
## 5. Security model
- **Message-level:** XML Digital Signature (sender), XML Encryption (receiver); mandatory for profile `as4.fifi.iso20022.v1`. Optional compression.
- **Transport:** HTTPS, TLS; optional mTLS; network allowlisting.
- See [key-reference-model](../security/key-reference-model.md).
---
## 6. Reliability and receipts
- **Signed receipts** required (non-repudiation).
- **Duplicate detection** enabled.
- **Exactly-once delivery** per profile.
- Retries on transient failure; receipt stored by sender.
---
## 7. Processing lifecycle
```
ISO 20022 XML created
AS4 envelope built (PartyId, Service, Action)
Directory resolves PartyId → endpoint + cert
AS4 signs + encrypts
HTTPS transmission
Receiver AS4 gateway validates + decrypts
ISO 20022 payload extracted
ISO 20022 engine processes message
```
---
## 8. Error separation (transport vs business)
| Layer | Handled by | Examples |
| ----- | ---------- | -------- |
| Transport | AS4 only | Retries, receipts, duplicate suppression. |
| Business | ISO 20022 | pacs.002 (status), camt.056 (cancellation), scheme rejects. |
**Never mix transport errors with business rejects.**
---
## 9. as4-411 integration
**Provides:** PartyId → endpoint resolution; Service/Action constraints; certificate references; multi-rail identity (BIC ↔ LEI ↔ internal); deterministic, auditable routing directives.
**Does not:** Parse ISO 20022; perform settlement; replace AS4 MSH.
---
## 10. Compliance and audit notes
- Receipts and **resolution_trace** support audit (which source contributed the directive).
- BIC/LEI are Tier 0/1 per [data-classification](../security/data-classification.md).
- Who may call resolve and what they see: [trust-model](../security/trust-model.md).
---
## 11. Sample AS4 envelopes
See §3 for header skeleton. Full anonymized envelope: [iso20022-as4-sample-envelope.xml](iso20022-as4-sample-envelope.xml). Resolution examples: [../examples/iso20022-as4-resolution-examples.yaml](../examples/iso20022-as4-resolution-examples.yaml).
---
## 12. as4-411 resolution examples
### Example 1 — BIC-based resolution
**Input**
- To.PartyId = BANKUS33XXX
- PartyIdType = BIC
- Service = iso20022.fi
- Action = credit.transfer
**Resolution output**
- Endpoint: https://as4.bankus.com/fi
- EncryptionCert: vault://certs/bankus/iso20022
- Profile: as4.fifi.iso20022.v1
- Receipts: signed
### Example 2 — LEI-based resolution
**Input**
- To.PartyId = 5493001KJTIIGC8Y1R12
- PartyIdType = LEI
**Mapping**
- LEI → BIC(s) → AS4 Endpoint
**Output**
- Same directive structure as BIC.
- Evidence includes LEI→BIC mapping source.
(JSON request/response shapes in [../examples/iso20022-as4-resolution-examples.yaml](../examples/iso20022-as4-resolution-examples.yaml) and [OpenAPI](../api/openapi.yaml).)
---
## 13. RTGS-specific nuances
- **Characteristics:** Real-time settlement, tight SLA windows, liquidity constraints.
- **AS4 adjustments:**
| Aspect | RTGS requirement |
| ------ | ----------------- |
| MPC | `urgent` |
| Retry | Minimal / controlled |
| Timeouts | Aggressive |
| Receipts | Mandatory, immediate |
- **Message patterns:** pacs.008 / pacs.009; immediate pacs.002 response expected.
---
## 14. Cross-border correspondent banking
- **Topology:** Bank A → Correspondent X → Correspondent Y → Bank B.
- **as4-411 role:** Resolve **next hop**, not final destination; maintain correspondent routing tables; apply jurisdiction and currency policies.
- **Envelope:** Each hop = new AS4 envelope; business ConversationId preserved; transport MessageId regenerated.
---
## 15. CBDC / tokenized settlement overlays
- **Overlay model:** ISO 20022 remains the **instruction layer**; token/CBDC rails provide the **settlement layer**.
- **Directory extensions:** Map PartyId → wallet/DLT endpoint; store settlement rail capability (RTGS, CBDC, tokenized deposit). See [cbdc-settlement-adapter](../architecture/cbdc-settlement-adapter.md).
- **Dual-track:** ISO 20022 instruction → AS4 transport → settlement adapter (RTGS or CBDC ledger).
---
## 16. Appendix: ISO 20022 over AS4 vs over API
| Dimension | AS4 | API |
| --------- | --- | --- |
| Reliability | Guaranteed | Best-effort |
| Non-repudiation | Built-in | Custom |
| Identity | PartyId-based | URL/token-based |
| Audit | Native | Add-on |
| Regulatory fit | High | Medium |
| Latency | Higher | Lower |
**Guidance:** AS4 for interbank, regulated, cross-border. API for internal, fintech, low-latency. **Hybrid:** API inside the bank; AS4 between banks.
---
## 17. Rail-template alignment
| Section | Content |
| ------- | ------- |
| Owner/authority | ISO 20022, OASIS ebMS 3.0 / AS4; as4-411 directory only. |
| Identifier scheme | BIC, LEI; as4.partyId with scope/partyIdType. |
| Addressing | HTTPS endpoint; profile as4.fifi.iso20022.v1. |
| Security | Mandatory sign + encrypt; HTTPS; optional mTLS. |
| Discovery | Directory-driven; no public discover-any-BIC. |
| Compliance | data-classification (BIC/LEI Tier 0/1). |
| Sample payloads | §1112; test-vectors and scheme profiles. |
Scheme-specific profiles (TARGET2, Fedwire, CHAPS): [iso20022-scheme-profiles.md](iso20022-scheme-profiles.md).

View File

@@ -0,0 +1,39 @@
# Scheme-specific profiles (ISO 20022 over AS4)
Constraint layers on top of the base FI-to-FI profile **as4.fifi.iso20022.v1** (service **iso20022.fi**, same [Action taxonomy](iso20022-over-as4.md#service--action-taxonomy)). The directory may store scheme in constraints or as transport_profile variant (e.g. `as4.fifi.iso20022.v1.target2`).
---
## TARGET2
- **Base profile:** as4.fifi.iso20022.v1
- **Identifiers:** BIC; TARGET2 participant ID (scheme-specific)
- **MPC:** `urgent` for RTGS traffic
- **SLA / timeouts:** Per ECB TARGET2 documentation; tight windows for real-time settlement
- **Reference:** ECB TARGET2 documentation and rulebooks
---
## Fedwire
- **Base profile:** as4.fifi.iso20022.v1
- **Identifiers:** Fedwire-specific participant / routing identifiers; US-facing
- **MPC:** As per scheme (e.g. `default` or `urgent` for time-critical)
- **SLA / timeouts:** Per Fedwire rules
- **Reference:** Fedwire documentation and operator rules
---
## CHAPS
- **Base profile:** as4.fifi.iso20022.v1
- **Identifiers:** UK CHAPS participant identifiers; BIC where applicable
- **MPC:** As per CHAPS rules (e.g. `urgent` for same-day)
- **SLA / timeouts:** Per CHAPS rules
- **Reference:** CHAPS documentation and Bank of England rules
---
## Catalog
See [catalog.md](catalog.md). ISO 20022 over AS4 (FI-to-FI) links to this document for scheme-specific profiles.

30
docs/protocols/ktt.md Normal file
View File

@@ -0,0 +1,30 @@
# KTT Rail (Placeholder)
## Status
KTT is a **rail plugin slot** until sector-specific definition. The name is used in different sectors for different systems; this document reserves the slot and describes placeholder behavior.
## Identifier Types
- **ktt.id** — Generic KTT identifier; format: alphanumeric, optional `.` `_` `-`.
- **ktt.participantId** — Same validation as ktt.id.
Validation is in `@as4-411/core` (validation.ts). A valid `ktt.*` identifier can be stored and resolved to a RouteDirective like any other identifier once directory data exists.
## Endpoints
To be defined when the rail is specified. Use standard protocol values (https, mq, etc.) and a profile name indicating the KTT channel.
## Trust and Directory Source
- **Authoritative directory source(s):** TBD per sector.
- **Trust constraints:** TBD. Prefer tenant scoping and explicit allow/deny.
## Connector
- **packages/connectors/ktt**: Placeholder adapter with `ingestFromFile` and `ingestFromApi` stubs. When the rail is defined, implement ingest that maps external participants/identifiers/endpoints into the core directory model.
## Acceptance
- A valid `ktt.*` identifier resolves to at least one RouteDirective when directory data is present.
- Adapter supports ingest (file + API modes) as stubs; full implementation when KTT is clarified.

View File

@@ -0,0 +1,9 @@
# ISO 20022 over AS4 — Golden test vectors
Use these as golden request/response pairs for resolver tests. See [testing-strategy](../../architecture/testing-strategy.md).
- **bic-resolution.json** — BIC + iso20022.fi + credit.transfer → primary directive with profile as4.fifi.iso20022.v1.
- **lei-resolution.json** — LEI resolution; evidence may include LEI→BIC mapping.
- **negative-unknown-identifier.json** — Unknown identifier → empty directives, negative_cache_ttl set.
Tests: seed store (or routing artifact) with participant/endpoint for BIC/LEI; run resolve with request; assert output matches expectedResponse (or key fields). Placeholder URLs and cert refs (e.g. https://as4.bankus.com/fi) are for assertion only; replace with test fixtures as needed.

View File

@@ -0,0 +1,29 @@
{
"description": "BIC-based resolution for ISO 20022 FI-to-FI. Service iso20022.fi, action credit.transfer.",
"request": {
"identifiers": [
{ "type": "as4.partyId", "value": "BANKUS33XXX", "scope": "BIC" }
],
"serviceContext": {
"service": "iso20022.fi",
"action": "credit.transfer"
}
},
"expectedResponse": {
"primary": {
"target_protocol": "as4",
"target_address": "https://as4.bankus.com/fi",
"transport_profile": "as4.fifi.iso20022.v1",
"security": {
"signRequired": true,
"encryptRequired": true,
"keyRefs": ["vault://certs/bankus/iso20022"]
},
"service_context": {
"service": "iso20022.fi",
"action": "credit.transfer"
}
},
"resolution_trace": [{ "source": "internal directory" }]
}
}

View File

@@ -0,0 +1,30 @@
{
"description": "LEI-based resolution; directory maps LEI to BIC(s) then to AS4 endpoint. Evidence may include LEI->BIC mapping source.",
"request": {
"identifiers": [
{ "type": "as4.partyId", "value": "5493001KJTIIGC8Y1R12", "scope": "LEI" }
],
"serviceContext": {
"service": "iso20022.fi",
"action": "fi.credit.transfer"
}
},
"expectedResponse": {
"primary": {
"target_protocol": "as4",
"target_address": "https://as4.bankus.com/fi",
"transport_profile": "as4.fifi.iso20022.v1",
"security": {
"signRequired": true,
"encryptRequired": true,
"keyRefs": ["vault://certs/bankus/iso20022"]
},
"service_context": {
"service": "iso20022.fi",
"action": "fi.credit.transfer"
},
"evidence": [{ "source": "internal directory", "message": "LEI to BIC mapping applied" }]
},
"resolution_trace": [{ "source": "internal directory" }]
}
}

View File

@@ -0,0 +1,16 @@
{
"description": "Unknown BIC/LEI: no match. Response must have no primary (or empty directives) and negative_cache_ttl set.",
"request": {
"identifiers": [
{ "type": "as4.partyId", "value": "UNKNOWNBICXXX", "scope": "BIC" }
],
"serviceContext": {
"service": "iso20022.fi",
"action": "credit.transfer"
}
},
"expectedResponse": {
"directives": [],
"negative_cache_ttl": 60
}
}

0
docs/security/.gitkeep Normal file
View File

View File

@@ -0,0 +1,32 @@
# Sensitive Data Classification
Data in the as4-411 directory is classified into tiers. Storage, access control, and encryption must follow these tiers. See [ADR-004](../adr/004-sensitive-data-classification.md).
## Tiers
| Tier | Name | Examples | Storage / access |
|------|-----------------|-----------------------------------------------|------------------|
| **0** | Public | BIC, LEI, public BIN range metadata | No encryption required; may be shared across tenants where applicable |
| **1** | Internal | PartyId, endpoint URL, participant name | Access-controlled; tenant-scoped; encrypt in transit |
| **2** | Confidential | MID, TID, contract routing, DTC participant/account IDs | Field-level encryption at rest; strict RBAC/ABAC; per-tenant keys preferred |
| **3** | Regulated/secrets | Tokens, key refs, PII-like attributes | Strongest controls; vault refs only; immutable audit; never log in plaintext |
## Mapping: tables and fields
- **identifiers:** `value` is Tier 0 when type is BIC/LEI/public; Tier 2 when type is mid, tid, dtc.participantId, dtc.accountId, or other contract-scoped IDs. `identifier_type` and `scope` are Tier 1.
- **endpoints:** `address` and `profile` are Tier 1 (internal). If they encode tenant-specific routes, treat as Tier 2 in policy.
- **credentials:** Only references (vault_ref, fingerprint)—Tier 3 for the ref; no private material in DB.
- **routing_artifacts:** Payload content may include Tier 2 (e.g. BIN table overrides with tenant/MID). Encrypt payload or use per-tenant encryption for Tier 2 content.
- **participants / tenants:** Names and IDs are Tier 1; tenant-private participant data is Tier 1 or Tier 2 depending on protocol (see protocol docs).
- **policies / audit_log:** Tier 1; audit_log must be immutable and optionally hash-chained.
## Enforcement
- **Field-level encryption:** Tier 2+ fields must be encrypted at rest (application-level or TDE with per-tenant keys where required). Tier 3: store only references; material in vault/KMS.
- **RBAC/ABAC:** Strict role- and attribute-based access; resolution and admin APIs enforce tenant scope and policy. See [tenant-model](../architecture/tenant-model.md) and [ADR-003](../adr/003-multi-tenancy-and-rls.md).
- **Audit:** All access to Tier 2+ and all mutations must be logged in audit_log; logs must not contain Tier 3 material in plaintext.
- **Allowed storage and access:** Document per table in operations runbooks; new fields must be assigned a tier before merge.
## Trust model for resolve consumers
Who may call resolve, what they can see, and how to prevent endpoint enumeration are described in [trust-model.md](trust-model.md).

View File

@@ -0,0 +1,34 @@
# Key Reference Model and Rotation
## Overview
as4-411 does not store private keys or raw secrets. It stores **references** to key material held in a vault or KMS. This document describes the reference model and rotation procedure.
## Key Reference Model
### Stored Data
- **credentials** table (per participant):
- `credential_type`: `tls` | `sign` | `encrypt`
- `vault_ref`: URI or identifier for the key in the vault/KMS (e.g. `vault://secret/tenant1/cert-id`, or KMS key ARN).
- `fingerprint`: Optional certificate or public key fingerprint for verification.
- `valid_from` / `valid_to`: Validity window for the referenced material.
- No private key material, no PEM bodies, and no long-lived secrets are stored in the directory database.
### Resolution Output
- `RouteDirective.security.keyRefs` can carry the same vault/KMS references (or short-lived tokens) so that gateways resolve “which key” and then fetch material from the vault within their trust boundary.
## Rotation Procedure
1. **Stage new cert/key** in vault/KMS; obtain new `vault_ref` and optional `fingerprint`.
2. **Add or update** credential record with new `vault_ref`, `valid_from` set to now (or overlap start).
3. **Dual-valid overlap:** Keep previous credential valid until cutover. Configure overlap window so gateways can refresh to the new key.
4. **Cutover:** Set old credentials `valid_to` to end of overlap (or mark inactive). Prefer new credential via higher priority or by updating endpoint/participant metadata.
5. **Revoke/archive** old key in vault per policy; remove or expire old credential record.
## Compliance Notes
- Audit log records changes to credential refs (who/what/when).
- Per-rail requirements (e.g. card networks, DTC) may impose additional constraints on key lifecycle and storage; see [protocol docs](../protocols/) where applicable.

View File

@@ -0,0 +1,23 @@
# Trust Model for Resolve Consumers
This document describes who can call the resolve API, what they can see, and how we limit abuse (e.g. endpoint enumeration). See also [data-classification.md](data-classification.md) and the [API spec](../api/openapi.yaml).
## Who can call resolve
- **Authenticated callers:** Resolve must be gated by authentication. Support at least: API keys or JWT scoped to a tenant (and optionally to a set of protocols or contracts). Per-tenant auth claims ensure that a caller only receives directives for data they are entitled to.
- **Authorization:** After authentication, apply tenant scope and ABAC policies so that the result set only includes participants, endpoints, and routing artifacts the caller is allowed to use. No cross-tenant leakage.
## What callers can see
- Responses are **filtered by entitlement:** Only protocols and endpoints the caller is entitled to appear in the directive list. Internal identifiers or participant details not needed for routing may be omitted or redacted in the response.
- **Evidence and trace:** Resolution evidence and `resolution_trace` may expose source names (e.g. "internal directory", "SMP cache"). Do not include raw confidential data (Tier 2+) in trace; use source labels and optional correlation IDs only where needed for debugging.
## Preventing endpoint enumeration
- **Rate limits and anomaly detection:** Apply per-tenant (and optionally per-API-key) rate limits to resolve and bulk-resolve. Detect and throttle anomalous patterns (e.g. large volumes of distinct identifiers in short windows) to reduce enumeration risk.
- **Response filtering:** Only return directives for identifiers and contracts the caller is authorized for. Return a generic "not found" or empty result for unauthorized or missing keys where appropriate, without leaking existence of data the caller cannot access.
- **Optional proof of possession:** For high-assurance deployments, require mTLS client certificates or signed tokens so that only approved gateways or clients can call resolve. Document in API and operations.
## Operations
- Document required auth method (e.g. API key, JWT, mTLS) in deployment and API docs. Document rate limits and any per-tenant TTL or quota in operations runbooks.

0
examples/.gitkeep Normal file
View File

View File

@@ -0,0 +1,30 @@
import { Resolver, InMemoryResolveCache } from "@as4-411/resolver";
import { InMemoryDirectoryStore } from "@as4-411/storage";
const store = new InMemoryDirectoryStore();
store.addTenant({ id: "t1", name: "Example Tenant" });
store.addParticipant({ id: "p1", tenantId: "t1", name: "Example Participant" });
store.addIdentifier({
id: "i1",
participantId: "p1",
identifier_type: "as4.partyId",
value: "0088:123456789",
priority: 1,
});
store.addEndpoint({
id: "e1",
participantId: "p1",
protocol: "as4",
address: "https://example.com/as4",
profile: "peppol-as4",
priority: 1,
status: "active",
});
const resolver = new Resolver({ store, cache: new InMemoryResolveCache() });
const result = await resolver.resolve({
identifiers: [{ type: "as4.partyId", value: "0088:123456789" }],
tenant: "t1",
});
console.log("Sidecar resolve OK. Directives:", result.directives.length);
console.log("Target URL:", result.directives[0]?.target_address);

View File

@@ -0,0 +1 @@
{"name":"example-as4-gateway-sidecar","type":"module","private":true,"dependencies":{"@as4-411/core":"workspace:*","@as4-411/resolver":"workspace:*","@as4-411/storage":"workspace:*"}}

View File

@@ -0,0 +1,39 @@
/**
* Example: embedded library usage.
* Imports core + resolver + in-memory store; no REST API. Resolution runs in-process.
*/
import { Resolver, InMemoryResolveCache } from "@as4-411/resolver";
import { InMemoryDirectoryStore } from "@as4-411/storage";
const store = new InMemoryDirectoryStore();
store.addTenant({ id: "default", name: "Default" });
store.addParticipant({ id: "local-1", tenantId: "default", name: "Local Participant" });
store.addIdentifier({
id: "id-1",
participantId: "local-1",
identifier_type: "e164",
value: "+15551234567",
priority: 1,
});
store.addEndpoint({
id: "ep-1",
participantId: "local-1",
protocol: "https",
address: "https://local.example.com/receive",
priority: 1,
status: "active",
});
const resolver = new Resolver({
store,
cache: new InMemoryResolveCache(),
defaultTtlSeconds: 60,
});
const result = await resolver.resolve({
identifiers: [{ type: "e164", value: "+15551234567" }],
tenant: "default",
});
console.log("Embedded resolve:", result.directives.length, "directive(s)");
console.log(result.directives[0]?.target_address ?? "none");

View File

@@ -0,0 +1,10 @@
{
"name": "example-embedded-library",
"type": "module",
"private": true,
"dependencies": {
"@as4-411/core": "workspace:*",
"@as4-411/resolver": "workspace:*",
"@as4-411/storage": "workspace:*"
}
}

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "as4-411",
"version": "0.1.0",
"private": true,
"description": "Directory and discovery service for AS4, SS7, and messaging gateways",
"scripts": {
"build": "pnpm -r run build",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write \"**/*.{ts,tsx,json,md,yaml,yml}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,json,md,yaml,yml}\"",
"test": "pnpm -r run test"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.2.0",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

View File

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More