Initial commit: AS4/411 directory and discovery service for Sankofa Marketplace
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0
.cursor/.gitkeep
Normal file
0
.cursor/.gitkeep
Normal file
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
23
.eslintrc.cjs
Normal 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
38
.github/workflows/ci.yml
vendored
Normal 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
37
.gitignore
vendored
Normal 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
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
43
README.md
Normal file
43
README.md
Normal 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
0
docs/adr/.gitkeep
Normal file
31
docs/adr/000-scope-and-non-goals.md
Normal file
31
docs/adr/000-scope-and-non-goals.md
Normal 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.
|
||||
44
docs/adr/001-adapter-interface-and-versioning.md
Normal file
44
docs/adr/001-adapter-interface-and-versioning.md
Normal 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.
|
||||
29
docs/adr/001-persistence-caching.md
Normal file
29
docs/adr/001-persistence-caching.md
Normal 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.
|
||||
28
docs/adr/002-resolution-scoring-determinism.md
Normal file
28
docs/adr/002-resolution-scoring-determinism.md
Normal 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.
|
||||
30
docs/adr/003-multi-tenancy-and-rls.md
Normal file
30
docs/adr/003-multi-tenancy-and-rls.md
Normal 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.
|
||||
29
docs/adr/003-policy-engine-abac.md
Normal file
29
docs/adr/003-policy-engine-abac.md
Normal 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.
|
||||
19
docs/adr/004-sensitive-data-classification.md
Normal file
19
docs/adr/004-sensitive-data-classification.md
Normal 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.
|
||||
30
docs/adr/005-connector-trust-and-caching.md
Normal file
30
docs/adr/005-connector-trust-and-caching.md
Normal 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
0
docs/api/.gitkeep
Normal file
18
docs/api/README.md
Normal file
18
docs/api/README.md
Normal 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
530
docs/api/openapi.yaml
Normal 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 }
|
||||
120
docs/api/proto/resolver.proto
Normal file
120
docs/api/proto/resolver.proto
Normal 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;
|
||||
}
|
||||
110
docs/api/route-directive.schema.json
Normal file
110
docs/api/route-directive.schema.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
0
docs/architecture/.gitkeep
Normal file
0
docs/architecture/.gitkeep
Normal file
54
docs/architecture/cbdc-settlement-adapter.md
Normal file
54
docs/architecture/cbdc-settlement-adapter.md
Normal 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.
|
||||
40
docs/architecture/connectors.md
Normal file
40
docs/architecture/connectors.md
Normal 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.
|
||||
222
docs/architecture/data-model.md
Normal file
222
docs/architecture/data-model.md
Normal 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 | 0–1 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.
|
||||
77
docs/architecture/resolution-algorithm.md
Normal file
77
docs/architecture/resolution-algorithm.md
Normal 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 1–9)
|
||||
|
||||
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 2–7. 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.
|
||||
34
docs/architecture/route-directive.md
Normal file
34
docs/architecture/route-directive.md
Normal 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.
|
||||
32
docs/architecture/tenant-model.md
Normal file
32
docs/architecture/tenant-model.md
Normal 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).
|
||||
26
docs/architecture/testing-strategy.md
Normal file
26
docs/architecture/testing-strategy.md
Normal 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).
|
||||
61
docs/examples/iso20022-as4-resolution-examples.yaml
Normal file
61
docs/examples/iso20022-as4-resolution-examples.yaml
Normal 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
0
docs/operations/.gitkeep
Normal file
22
docs/operations/promotion-and-sync.md
Normal file
22
docs/operations/promotion-and-sync.md
Normal 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.
|
||||
49
docs/protocols/_rail-template.md
Normal file
49
docs/protocols/_rail-template.md
Normal 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
39
docs/protocols/cards.md
Normal 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 (6–8 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
23
docs/protocols/catalog.md
Normal 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
44
docs/protocols/dtc.md
Normal 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.
|
||||
39
docs/protocols/iso20022-as4-sample-envelope.xml
Normal file
39
docs/protocols/iso20022-as4-sample-envelope.xml
Normal 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>
|
||||
299
docs/protocols/iso20022-over-as4.md
Normal file
299
docs/protocols/iso20022-over-as4.md
Normal 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 | §11–12; test-vectors and scheme profiles. |
|
||||
|
||||
Scheme-specific profiles (TARGET2, Fedwire, CHAPS): [iso20022-scheme-profiles.md](iso20022-scheme-profiles.md).
|
||||
39
docs/protocols/iso20022-scheme-profiles.md
Normal file
39
docs/protocols/iso20022-scheme-profiles.md
Normal 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
30
docs/protocols/ktt.md
Normal 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.
|
||||
9
docs/protocols/test-vectors/iso20022-as4/README.md
Normal file
9
docs/protocols/test-vectors/iso20022-as4/README.md
Normal 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.
|
||||
29
docs/protocols/test-vectors/iso20022-as4/bic-resolution.json
Normal file
29
docs/protocols/test-vectors/iso20022-as4/bic-resolution.json
Normal 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" }]
|
||||
}
|
||||
}
|
||||
30
docs/protocols/test-vectors/iso20022-as4/lei-resolution.json
Normal file
30
docs/protocols/test-vectors/iso20022-as4/lei-resolution.json
Normal 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" }]
|
||||
}
|
||||
}
|
||||
@@ -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
0
docs/security/.gitkeep
Normal file
32
docs/security/data-classification.md
Normal file
32
docs/security/data-classification.md
Normal 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).
|
||||
34
docs/security/key-reference-model.md
Normal file
34
docs/security/key-reference-model.md
Normal 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 credential’s `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.
|
||||
23
docs/security/trust-model.md
Normal file
23
docs/security/trust-model.md
Normal 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
0
examples/.gitkeep
Normal file
30
examples/as4-gateway-sidecar/index.js
Normal file
30
examples/as4-gateway-sidecar/index.js
Normal 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);
|
||||
1
examples/as4-gateway-sidecar/package.json
Normal file
1
examples/as4-gateway-sidecar/package.json
Normal 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:*"}}
|
||||
39
examples/embedded-library/index.js
Normal file
39
examples/embedded-library/index.js
Normal 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");
|
||||
10
examples/embedded-library/package.json
Normal file
10
examples/embedded-library/package.json
Normal 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
25
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
packages/api/rest/package.json
Normal file
27
packages/api/rest/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@as4-411/api-rest",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "REST API for as4-411 resolver and admin",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"test": "node --test dist/**/*.test.js 2>/dev/null || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@as4-411/core": "workspace:*",
|
||||
"@as4-411/resolver": "workspace:*",
|
||||
"@as4-411/storage": "workspace:*",
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
17
packages/api/rest/src/app.ts
Normal file
17
packages/api/rest/src/app.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import express from "express";
|
||||
import { createResolverRouter } from "./routes/resolver.js";
|
||||
import { createSystemRouter } from "./routes/system.js";
|
||||
import { createAdminRouter } from "./routes/admin.js";
|
||||
import type { Resolver } from "@as4-411/resolver";
|
||||
import type { AdminStore } from "@as4-411/storage";
|
||||
|
||||
export function createApp(resolver: Resolver, adminStore?: AdminStore | null): express.Application {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.use(createResolverRouter(resolver));
|
||||
app.use(createSystemRouter());
|
||||
app.use(createAdminRouter(adminStore ?? null));
|
||||
|
||||
return app;
|
||||
}
|
||||
4
packages/api/rest/src/index.ts
Normal file
4
packages/api/rest/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { createApp } from "./app.js";
|
||||
export { createResolverRouter } from "./routes/resolver.js";
|
||||
export { createSystemRouter } from "./routes/system.js";
|
||||
export { createAdminRouter } from "./routes/admin.js";
|
||||
276
packages/api/rest/src/routes/admin.ts
Normal file
276
packages/api/rest/src/routes/admin.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { AdminStore } from "@as4-411/storage";
|
||||
import type {
|
||||
Tenant,
|
||||
Participant,
|
||||
Identifier,
|
||||
Endpoint,
|
||||
CredentialRef,
|
||||
Policy,
|
||||
} from "@as4-411/core";
|
||||
|
||||
function randomId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin CRUD routes. If adminStore is not provided, returns 501.
|
||||
* Paths match OpenAPI: /v1/admin/tenants, participants, identifiers, endpoints, credentials, policies.
|
||||
*/
|
||||
export function createAdminRouter(adminStore: AdminStore | null): Router {
|
||||
const router = Router();
|
||||
|
||||
if (!adminStore) {
|
||||
const notImplemented = (_req: Request, res: Response) => {
|
||||
res.status(501).json({ error: "Admin API not implemented yet" });
|
||||
};
|
||||
router.get("/v1/admin/tenants", notImplemented);
|
||||
router.post("/v1/admin/tenants", notImplemented);
|
||||
router.get("/v1/admin/tenants/:tenantId", notImplemented);
|
||||
router.put("/v1/admin/tenants/:tenantId", notImplemented);
|
||||
router.delete("/v1/admin/tenants/:tenantId", notImplemented);
|
||||
router.get("/v1/admin/participants", notImplemented);
|
||||
router.post("/v1/admin/participants", notImplemented);
|
||||
router.get("/v1/admin/participants/:participantId", notImplemented);
|
||||
router.put("/v1/admin/participants/:participantId", notImplemented);
|
||||
router.delete("/v1/admin/participants/:participantId", notImplemented);
|
||||
router.get("/v1/admin/participants/:participantId/identifiers", notImplemented);
|
||||
router.post("/v1/admin/participants/:participantId/identifiers", notImplemented);
|
||||
router.get("/v1/admin/participants/:participantId/endpoints", notImplemented);
|
||||
router.post("/v1/admin/participants/:participantId/endpoints", notImplemented);
|
||||
router.get("/v1/admin/participants/:participantId/credentials", notImplemented);
|
||||
router.post("/v1/admin/participants/:participantId/credentials", notImplemented);
|
||||
router.get("/v1/admin/policies", notImplemented);
|
||||
router.post("/v1/admin/policies", notImplemented);
|
||||
router.get("/v1/admin/policies/:policyId", notImplemented);
|
||||
router.put("/v1/admin/policies/:policyId", notImplemented);
|
||||
router.delete("/v1/admin/policies/:policyId", notImplemented);
|
||||
return router;
|
||||
}
|
||||
|
||||
// Tenants
|
||||
router.get("/v1/admin/tenants", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const list = await adminStore.listTenants();
|
||||
res.json(list);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/v1/admin/tenants", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Tenant>;
|
||||
const id = body.id ?? randomId();
|
||||
const name = body.name ?? "";
|
||||
await adminStore.createTenant({ id, name });
|
||||
res.status(201).json({ id, name });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/v1/admin/tenants/:tenantId", async (req: Request, res: Response) => {
|
||||
const t = await adminStore.getTenant(req.params.tenantId);
|
||||
if (!t) return res.status(404).json({ error: "Not found" });
|
||||
res.json(t);
|
||||
});
|
||||
|
||||
router.put("/v1/admin/tenants/:tenantId", async (req: Request, res: Response) => {
|
||||
const ok = await adminStore.updateTenant(req.params.tenantId, {
|
||||
name: (req.body as { name?: string }).name ?? "",
|
||||
});
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
const t = await adminStore.getTenant(req.params.tenantId);
|
||||
res.json(t);
|
||||
});
|
||||
|
||||
router.delete("/v1/admin/tenants/:tenantId", async (req: Request, res: Response) => {
|
||||
const ok = await adminStore.deleteTenant(req.params.tenantId);
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// Participants
|
||||
router.get("/v1/admin/participants", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.query.tenantId as string | undefined;
|
||||
const list = await adminStore.listParticipants({ tenantId });
|
||||
res.json(list);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/v1/admin/participants", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Participant>;
|
||||
const id = body.id ?? randomId();
|
||||
const tenantId = body.tenantId ?? "";
|
||||
const name = body.name ?? "";
|
||||
await adminStore.createParticipant({ id, tenantId, name });
|
||||
res.status(201).json({ id, tenantId, name });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/v1/admin/participants/:participantId", async (req: Request, res: Response) => {
|
||||
const p = await adminStore.getParticipant(req.params.participantId);
|
||||
if (!p) return res.status(404).json({ error: "Not found" });
|
||||
res.json(p);
|
||||
});
|
||||
|
||||
router.put("/v1/admin/participants/:participantId", async (req: Request, res: Response) => {
|
||||
const body = req.body as { name?: string };
|
||||
const ok = await adminStore.updateParticipant(req.params.participantId, {
|
||||
name: body.name ?? "",
|
||||
});
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
const p = await adminStore.getParticipant(req.params.participantId);
|
||||
res.json(p);
|
||||
});
|
||||
|
||||
router.delete("/v1/admin/participants/:participantId", async (req: Request, res: Response) => {
|
||||
const ok = await adminStore.deleteParticipant(req.params.participantId);
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// Identifiers (nested under participant)
|
||||
router.get("/v1/admin/participants/:participantId/identifiers", async (req: Request, res: Response) => {
|
||||
const list = await adminStore.getIdentifiersByParticipantId(req.params.participantId);
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
router.post("/v1/admin/participants/:participantId/identifiers", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Identifier>;
|
||||
const id = body.id ?? randomId();
|
||||
const participantId = req.params.participantId;
|
||||
const identifier: Identifier = {
|
||||
id,
|
||||
participantId,
|
||||
identifier_type: body.identifier_type ?? "",
|
||||
value: body.value ?? "",
|
||||
scope: body.scope,
|
||||
priority: body.priority ?? 0,
|
||||
verified_at: body.verified_at,
|
||||
};
|
||||
await adminStore.createIdentifier(identifier);
|
||||
res.status(201).json(identifier);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Endpoints
|
||||
router.get("/v1/admin/participants/:participantId/endpoints", async (req: Request, res: Response) => {
|
||||
const list = await adminStore.getEndpointsByParticipantId(req.params.participantId);
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
router.post("/v1/admin/participants/:participantId/endpoints", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Endpoint>;
|
||||
const id = body.id ?? randomId();
|
||||
const participantId = req.params.participantId;
|
||||
const endpoint: Endpoint = {
|
||||
id,
|
||||
participantId,
|
||||
protocol: body.protocol ?? "",
|
||||
address: body.address ?? "",
|
||||
profile: body.profile,
|
||||
priority: body.priority ?? 0,
|
||||
status: body.status ?? "active",
|
||||
};
|
||||
await adminStore.createEndpoint(endpoint);
|
||||
res.status(201).json(endpoint);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Credentials
|
||||
router.get("/v1/admin/participants/:participantId/credentials", async (req: Request, res: Response) => {
|
||||
const list = await adminStore.getCredentialsByParticipantId(req.params.participantId);
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
router.post("/v1/admin/participants/:participantId/credentials", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<CredentialRef>;
|
||||
const id = body.id ?? randomId();
|
||||
const participantId = req.params.participantId;
|
||||
const credential: CredentialRef = {
|
||||
id,
|
||||
participantId,
|
||||
credential_type: body.credential_type ?? "tls",
|
||||
vault_ref: body.vault_ref ?? "",
|
||||
fingerprint: body.fingerprint,
|
||||
valid_from: body.valid_from,
|
||||
valid_to: body.valid_to,
|
||||
};
|
||||
await adminStore.createCredential(credential);
|
||||
res.status(201).json(credential);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Policies
|
||||
router.get("/v1/admin/policies", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.query.tenantId as string | undefined;
|
||||
const list = await adminStore.listPolicies({ tenantId });
|
||||
res.json(list);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/v1/admin/policies", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as Partial<Policy>;
|
||||
const id = body.id ?? randomId();
|
||||
const tenantId = body.tenantId ?? "";
|
||||
const policy: Policy = {
|
||||
id,
|
||||
tenantId,
|
||||
rule_json: body.rule_json ?? {},
|
||||
effect: body.effect ?? "allow",
|
||||
priority: body.priority ?? 0,
|
||||
};
|
||||
await adminStore.createPolicy(policy);
|
||||
res.status(201).json(policy);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/v1/admin/policies/:policyId", async (req: Request, res: Response) => {
|
||||
const p = await adminStore.getPolicy(req.params.policyId);
|
||||
if (!p) return res.status(404).json({ error: "Not found" });
|
||||
res.json(p);
|
||||
});
|
||||
|
||||
router.put("/v1/admin/policies/:policyId", async (req: Request, res: Response) => {
|
||||
const body = req.body as Partial<Policy>;
|
||||
const ok = await adminStore.updatePolicy(req.params.policyId, {
|
||||
rule_json: body.rule_json ?? {},
|
||||
effect: body.effect ?? "allow",
|
||||
priority: body.priority ?? 0,
|
||||
});
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
const p = await adminStore.getPolicy(req.params.policyId);
|
||||
res.json(p);
|
||||
});
|
||||
|
||||
router.delete("/v1/admin/policies/:policyId", async (req: Request, res: Response) => {
|
||||
const ok = await adminStore.deletePolicy(req.params.policyId);
|
||||
if (!ok) return res.status(404).json({ error: "Not found" });
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
46
packages/api/rest/src/routes/resolver.ts
Normal file
46
packages/api/rest/src/routes/resolver.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Resolver } from "@as4-411/resolver";
|
||||
import type { ResolveRequest } from "@as4-411/core";
|
||||
|
||||
export function createResolverRouter(resolver: Resolver): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/v1/resolve", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as ResolveRequest;
|
||||
if (!body?.identifiers?.length) {
|
||||
res.status(400).json({ error: "identifiers required and must be non-empty" });
|
||||
return;
|
||||
}
|
||||
const result = await resolver.resolve(body);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(503).json({
|
||||
error: "Resolution failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/v1/bulk-resolve", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { requests } = req.body as { requests?: ResolveRequest[] };
|
||||
if (!Array.isArray(requests)) {
|
||||
res.status(400).json({ error: "requests array required" });
|
||||
return;
|
||||
}
|
||||
const results = await Promise.all(requests.map((r) => resolver.resolve(r)));
|
||||
res.json({
|
||||
results,
|
||||
traceId: crypto.randomUUID(),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(503).json({
|
||||
error: "Bulk resolution failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
20
packages/api/rest/src/routes/system.ts
Normal file
20
packages/api/rest/src/routes/system.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
|
||||
export function createSystemRouter(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get("/v1/health", (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
version: "0.1.0",
|
||||
checks: {},
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/v1/metrics", (_req: Request, res: Response) => {
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.send("# as4-411 metrics (placeholder)\n");
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
15
packages/api/rest/src/server.ts
Normal file
15
packages/api/rest/src/server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createApp } from "./app.js";
|
||||
import { Resolver } from "@as4-411/resolver";
|
||||
import { InMemoryDirectoryStore } from "@as4-411/storage";
|
||||
import { InMemoryResolveCache } from "@as4-411/resolver";
|
||||
|
||||
const store = new InMemoryDirectoryStore();
|
||||
const cache = new InMemoryResolveCache();
|
||||
const resolver = new Resolver({ store, cache });
|
||||
|
||||
const app = createApp(resolver, store);
|
||||
const port = Number(process.env.PORT) || 4110;
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`as4-411 API listening on http://localhost:${port}`);
|
||||
});
|
||||
16
packages/api/rest/tsconfig.json
Normal file
16
packages/api/rest/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
1
packages/connectors/file/package.json
Normal file
1
packages/connectors/file/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"@as4-411/connector-file","type":"module","version":"0.1.0","main":"dist/index.js","types":"dist/index.d.ts","scripts":{"build":"tsc"},"dependencies":{"@as4-411/core":"workspace:*","@as4-411/storage":"workspace:*"},"devDependencies":{"typescript":"^5.3.0"}}
|
||||
72
packages/connectors/file/src/bin-table.ts
Normal file
72
packages/connectors/file/src/bin-table.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { RoutingArtifactStore } from "@as4-411/storage";
|
||||
import type { RoutingArtifact } from "@as4-411/core";
|
||||
import type { BinTableEntry } from "@as4-411/core";
|
||||
|
||||
export interface BinTableIngestOptions {
|
||||
tenantId?: string;
|
||||
artifactId?: string;
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
signature?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export async function ingestBinTableFromJson(
|
||||
artifactStore: RoutingArtifactStore,
|
||||
data: { entries: BinTableEntry[]; version?: string },
|
||||
options: BinTableIngestOptions = {}
|
||||
): Promise<void> {
|
||||
const id = options.artifactId ?? `bin_table_${Date.now()}`;
|
||||
const payload = {
|
||||
version: data.version ?? "1.0",
|
||||
data: { entries: data.entries },
|
||||
signature: options.signature,
|
||||
fingerprint: options.fingerprint,
|
||||
};
|
||||
const artifact: RoutingArtifact = {
|
||||
id,
|
||||
tenantId: options.tenantId,
|
||||
artifactType: "bin_table",
|
||||
payload,
|
||||
effectiveFrom: options.effectiveFrom ?? new Date().toISOString(),
|
||||
effectiveTo: options.effectiveTo,
|
||||
};
|
||||
await artifactStore.put(artifact);
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
return line.split(",").map((c) => c.trim());
|
||||
}
|
||||
|
||||
export async function ingestBinTableFromCsv(
|
||||
artifactStore: RoutingArtifactStore,
|
||||
csvText: string,
|
||||
options: BinTableIngestOptions = {}
|
||||
): Promise<void> {
|
||||
const lines = csvText.split(/\r?\n/).filter((l) => l.trim());
|
||||
if (lines.length < 2) {
|
||||
await ingestBinTableFromJson(artifactStore, { entries: [] }, options);
|
||||
return;
|
||||
}
|
||||
const header = parseCsvLine(lines[0]).map((h) => h.toLowerCase().replace(/\s/g, ""));
|
||||
const entries: BinTableEntry[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = parseCsvLine(lines[i]);
|
||||
const row: Record<string, string> = {};
|
||||
header.forEach((h, j) => {
|
||||
row[h] = values[j] ?? "";
|
||||
});
|
||||
const binPrefix = row["binprefix"] ?? row["bin_prefix"] ?? "";
|
||||
const routingTarget = row["routingtarget"] ?? row["routing_target"] ?? "";
|
||||
if (!binPrefix || !routingTarget) continue;
|
||||
entries.push({
|
||||
binPrefix,
|
||||
binLength: parseInt(row["binlength"] ?? row["bin_length"] ?? "6", 10) || 6,
|
||||
brand: row["brand"] || undefined,
|
||||
region: row["region"] || undefined,
|
||||
routingTarget,
|
||||
tenantId: (row["tenantid"] ?? row["tenant_id"]) || undefined,
|
||||
});
|
||||
}
|
||||
await ingestBinTableFromJson(artifactStore, { entries }, options);
|
||||
}
|
||||
4
packages/connectors/file/src/index.ts
Normal file
4
packages/connectors/file/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ingestBinTableFromJson, ingestBinTableFromCsv } from "./bin-table.js";
|
||||
export type { BinTableIngestOptions } from "./bin-table.js";
|
||||
export { ingestSignedArtifact } from "./signed-artifact.js";
|
||||
export type { SignedArtifactBundle } from "./signed-artifact.js";
|
||||
41
packages/connectors/file/src/signed-artifact.ts
Normal file
41
packages/connectors/file/src/signed-artifact.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { RoutingArtifactStore } from "@as4-411/storage";
|
||||
import type { RoutingArtifact, RoutingArtifactType } from "@as4-411/core";
|
||||
import { isKnownArtifactType, validateArtifactPayload } from "@as4-411/core";
|
||||
|
||||
export interface SignedArtifactBundle {
|
||||
id: string;
|
||||
tenantId?: string;
|
||||
artifactType: string;
|
||||
payload: {
|
||||
version: string;
|
||||
data: unknown;
|
||||
signature?: string;
|
||||
fingerprint?: string;
|
||||
};
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest a signed artifact bundle (e.g. from file or API). Validates type and payload shape, then persists.
|
||||
*/
|
||||
export async function ingestSignedArtifact(
|
||||
artifactStore: RoutingArtifactStore,
|
||||
bundle: SignedArtifactBundle
|
||||
): Promise<void> {
|
||||
if (!isKnownArtifactType(bundle.artifactType)) {
|
||||
throw new Error(`Unknown artifact type: ${bundle.artifactType}`);
|
||||
}
|
||||
if (!validateArtifactPayload(bundle.artifactType as RoutingArtifactType, bundle.payload)) {
|
||||
throw new Error(`Invalid payload for artifact type ${bundle.artifactType}`);
|
||||
}
|
||||
const artifact: RoutingArtifact = {
|
||||
id: bundle.id,
|
||||
tenantId: bundle.tenantId,
|
||||
artifactType: bundle.artifactType as RoutingArtifact["artifactType"],
|
||||
payload: bundle.payload,
|
||||
effectiveFrom: bundle.effectiveFrom,
|
||||
effectiveTo: bundle.effectiveTo,
|
||||
};
|
||||
await artifactStore.put(artifact);
|
||||
}
|
||||
1
packages/connectors/file/tsconfig.json
Normal file
1
packages/connectors/file/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","rootDir":"src","strict":true,"declaration":true,"skipLibCheck":true},"include":["src/**/*"],"exclude":["node_modules","dist"]}
|
||||
1
packages/connectors/ktt/package.json
Normal file
1
packages/connectors/ktt/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"@as4-411/connector-ktt","type":"module","version":"0.1.0","main":"dist/index.js","types":"dist/index.d.ts","scripts":{"build":"tsc"},"dependencies":{"@as4-411/core":"workspace:*","@as4-411/storage":"workspace:*"},"devDependencies":{"typescript":"^5.3.0"}}
|
||||
2
packages/connectors/ktt/src/index.ts
Normal file
2
packages/connectors/ktt/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ingestFromFile, ingestFromApi } from "./ingest.js";
|
||||
export type { KttIngestFromFileOptions, KttIngestFromApiOptions } from "./ingest.js";
|
||||
32
packages/connectors/ktt/src/ingest.ts
Normal file
32
packages/connectors/ktt/src/ingest.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { AdminStore } from "@as4-411/storage";
|
||||
|
||||
/**
|
||||
* KTT connector placeholder. Ingest from file or API (stubs).
|
||||
* When KTT is defined per sector, implement authoritative directory source and identifier formats.
|
||||
*/
|
||||
|
||||
export interface KttIngestFromFileOptions {
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
/** Placeholder: ingest from file (e.g. YAML/JSON). Stub implementation. */
|
||||
export async function ingestFromFile(
|
||||
_store: AdminStore,
|
||||
_filePath: string,
|
||||
_options?: KttIngestFromFileOptions
|
||||
): Promise<{ participants: number; identifiers: number }> {
|
||||
return { participants: 0, identifiers: 0 };
|
||||
}
|
||||
|
||||
export interface KttIngestFromApiOptions {
|
||||
tenantId?: string;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
/** Placeholder: ingest from API. Stub implementation. */
|
||||
export async function ingestFromApi(
|
||||
_store: AdminStore,
|
||||
_options?: KttIngestFromApiOptions
|
||||
): Promise<{ participants: number; identifiers: number }> {
|
||||
return { participants: 0, identifiers: 0 };
|
||||
}
|
||||
1
packages/connectors/ktt/tsconfig.json
Normal file
1
packages/connectors/ktt/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","rootDir":"src","strict":true,"declaration":true,"skipLibCheck":true},"include":["src/**/*"],"exclude":["node_modules","dist"]}
|
||||
18
packages/core/package.json
Normal file
18
packages/core/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@as4-411/core",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "Domain model, validation, and policy types for as4-411",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --test dist/**/*.test.js 2>/dev/null || true"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
78
packages/core/src/adapter-interface.ts
Normal file
78
packages/core/src/adapter-interface.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Protocol adapter plugin interface. Each rail implements this contract.
|
||||
* See ADR-001 (adapter-interface-and-versioning).
|
||||
*/
|
||||
import type {
|
||||
Participant,
|
||||
Endpoint,
|
||||
Capability,
|
||||
Identifier,
|
||||
RouteDirective,
|
||||
ResolveRequest,
|
||||
ServiceContext,
|
||||
} from "./types.js";
|
||||
|
||||
/** Minimal read-only view supplied by the resolver (e.g. DirectoryStore). */
|
||||
export interface AdapterContext {
|
||||
findParticipantsByIdentifiers(
|
||||
identifiers: Array<{ type: string; value: string }>,
|
||||
options?: { tenantId?: string }
|
||||
): Promise<Participant[]>;
|
||||
getEndpointsByParticipantId(
|
||||
participantId: string,
|
||||
options?: { protocol?: string; status?: string }
|
||||
): Promise<Endpoint[]>;
|
||||
getCapabilitiesByParticipantId(participantId: string): Promise<Capability[]>;
|
||||
}
|
||||
|
||||
/** A single candidate (participant + endpoint) produced by an adapter. */
|
||||
export interface AdapterCandidate {
|
||||
participant: Participant;
|
||||
endpoint: Endpoint;
|
||||
capability?: Capability;
|
||||
identifier?: Identifier;
|
||||
}
|
||||
|
||||
/** Result of optional ingest. */
|
||||
export interface IngestResult {
|
||||
participants: number;
|
||||
identifiers: number;
|
||||
endpoints?: number;
|
||||
}
|
||||
|
||||
/** Options when rendering a directive (e.g. default TTL). */
|
||||
export interface RenderDirectiveOptions {
|
||||
defaultTtlSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol adapter: one per rail. Registry discovers/loads by protocol or identifier type.
|
||||
* Semantic versioning of this interface: backward-compatible additions only (new optional methods or optional fields).
|
||||
*/
|
||||
export interface ProtocolAdapter {
|
||||
/** Adapter semantic version (e.g. "1.0.0"). */
|
||||
readonly version: string;
|
||||
/** Protocol or rail name (e.g. "as4", "ss7", "iso8583"). */
|
||||
readonly protocol: string;
|
||||
|
||||
validateIdentifier(type: string, value: string): boolean;
|
||||
|
||||
/** Return normalized value for storage/lookup, or null if invalid. */
|
||||
normalizeIdentifier(type: string, value: string): string | null;
|
||||
|
||||
/** Return candidates for the request using the provided context. */
|
||||
resolveCandidates(
|
||||
ctx: AdapterContext,
|
||||
request: ResolveRequest,
|
||||
options?: { tenantId?: string }
|
||||
): Promise<AdapterCandidate[]>;
|
||||
|
||||
/** Whether the candidate matches the service context (e.g. capability). */
|
||||
evaluateCapabilities(candidate: AdapterCandidate, serviceContext?: ServiceContext): boolean;
|
||||
|
||||
/** Build a RouteDirective from a candidate. */
|
||||
renderRouteDirective(candidate: AdapterCandidate, options?: RenderDirectiveOptions): RouteDirective;
|
||||
|
||||
/** Optional: ingest from external source (SMP, file, etc.). */
|
||||
ingestSource?(config: unknown): Promise<IngestResult>;
|
||||
}
|
||||
4
packages/core/src/index.ts
Normal file
4
packages/core/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./types.js";
|
||||
export * from "./validation.js";
|
||||
export * from "./protocol_registry/index.js";
|
||||
export * from "./adapter-interface.js";
|
||||
97
packages/core/src/protocol_registry/artifacts.ts
Normal file
97
packages/core/src/protocol_registry/artifacts.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Routing artifact type definitions and payload shapes.
|
||||
* Versioned, optionally signed.
|
||||
*/
|
||||
|
||||
import type { RoutingArtifactType, RoutingArtifactPayload } from "./types.js";
|
||||
|
||||
export interface BinTableEntry {
|
||||
binPrefix: string;
|
||||
binLength: number;
|
||||
brand?: string;
|
||||
region?: string;
|
||||
routingTarget: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface BinTablePayload {
|
||||
version: string;
|
||||
data: { entries: BinTableEntry[] };
|
||||
signature?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface GttTableEntry {
|
||||
globalTitle: string;
|
||||
pointCode?: string;
|
||||
ssn?: string;
|
||||
translationType?: string;
|
||||
}
|
||||
|
||||
export interface GttTablePayload {
|
||||
version: string;
|
||||
data: { entries: GttTableEntry[] };
|
||||
signature?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface ParticipantMapEntry {
|
||||
identifierType: string;
|
||||
identifierValue: string;
|
||||
participantId: string;
|
||||
endpointId?: string;
|
||||
}
|
||||
|
||||
export interface ParticipantMapPayload {
|
||||
version: string;
|
||||
data: { entries: ParticipantMapEntry[] };
|
||||
signature?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface FallbackRule {
|
||||
match: Record<string, unknown>;
|
||||
targetParticipantId?: string;
|
||||
targetEndpointId?: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface FallbackRulesPayload {
|
||||
version: string;
|
||||
data: { rules: FallbackRule[] };
|
||||
signature?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export const ARTIFACT_TYPES: RoutingArtifactType[] = [
|
||||
"bin_table",
|
||||
"gtt_table",
|
||||
"participant_map",
|
||||
"fallback_rules",
|
||||
];
|
||||
|
||||
export function isKnownArtifactType(t: string): t is RoutingArtifactType {
|
||||
return ARTIFACT_TYPES.includes(t as RoutingArtifactType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payload shape for artifact type. Returns true if valid or type unknown.
|
||||
*/
|
||||
export function validateArtifactPayload(
|
||||
artifactType: RoutingArtifactType,
|
||||
payload: RoutingArtifactPayload
|
||||
): boolean {
|
||||
if (!payload?.version || typeof payload.data !== "object") return false;
|
||||
switch (artifactType) {
|
||||
case "bin_table":
|
||||
return Array.isArray((payload.data as { entries?: unknown }).entries);
|
||||
case "gtt_table":
|
||||
return Array.isArray((payload.data as { entries?: unknown }).entries);
|
||||
case "participant_map":
|
||||
return Array.isArray((payload.data as { entries?: unknown }).entries);
|
||||
case "fallback_rules":
|
||||
return Array.isArray((payload.data as { rules?: unknown }).rules);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
3
packages/core/src/protocol_registry/index.ts
Normal file
3
packages/core/src/protocol_registry/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types.js";
|
||||
export * from "./validators.js";
|
||||
export * from "./artifacts.js";
|
||||
39
packages/core/src/protocol_registry/types.ts
Normal file
39
packages/core/src/protocol_registry/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** Rail profile and routing artifact types. */
|
||||
|
||||
export type ProtocolFamily =
|
||||
| "as4"
|
||||
| "ss7"
|
||||
| "iso8583"
|
||||
| "mq"
|
||||
| "sftp"
|
||||
| "api"
|
||||
| "https";
|
||||
|
||||
export interface RailProfile {
|
||||
name: string;
|
||||
protocolFamily: ProtocolFamily;
|
||||
addressPattern?: string | RegExp;
|
||||
allowedIdentifierTypes?: string[];
|
||||
}
|
||||
|
||||
export type RoutingArtifactType =
|
||||
| "bin_table"
|
||||
| "gtt_table"
|
||||
| "participant_map"
|
||||
| "fallback_rules";
|
||||
|
||||
export interface RoutingArtifactPayload {
|
||||
version: string;
|
||||
data: unknown;
|
||||
signature?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface RoutingArtifact {
|
||||
id: string;
|
||||
tenantId?: string;
|
||||
artifactType: RoutingArtifactType;
|
||||
payload: RoutingArtifactPayload;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
}
|
||||
36
packages/core/src/protocol_registry/validators.ts
Normal file
36
packages/core/src/protocol_registry/validators.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { RailProfile } from "./types.js";
|
||||
|
||||
const BUILTIN: RailProfile[] = [
|
||||
{ name: "peppol-as4", protocolFamily: "as4", allowedIdentifierTypes: ["as4.partyId", "peppol.participantId"] },
|
||||
{ name: "visa-base1", protocolFamily: "iso8583", allowedIdentifierTypes: ["pan.bin", "mid", "tid"] },
|
||||
{ name: "dtcc-mq", protocolFamily: "mq", allowedIdentifierTypes: ["lei", "bic", "dtc.participantId"] },
|
||||
{ name: "as4", protocolFamily: "as4", allowedIdentifierTypes: ["as4.partyId"] },
|
||||
{ name: "ss7", protocolFamily: "ss7", allowedIdentifierTypes: ["e164", "gt", "pc", "ssn"] },
|
||||
];
|
||||
|
||||
const registry = new Map<string, RailProfile>(BUILTIN.map((p) => [p.name, p]));
|
||||
|
||||
export function registerProfile(profile: RailProfile): void {
|
||||
registry.set(profile.name, profile);
|
||||
}
|
||||
|
||||
export function getProfile(name: string): RailProfile | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
export function listProfiles(): RailProfile[] {
|
||||
return Array.from(registry.values());
|
||||
}
|
||||
|
||||
export function validateAddressForProfile(profileName: string, address: string): boolean {
|
||||
const profile = registry.get(profileName);
|
||||
if (!profile?.addressPattern) return true;
|
||||
const re = typeof profile.addressPattern === "string" ? new RegExp(profile.addressPattern) : profile.addressPattern;
|
||||
return re.test(address);
|
||||
}
|
||||
|
||||
export function isIdentifierTypeAllowed(profileName: string, identifierType: string): boolean {
|
||||
const profile = registry.get(profileName);
|
||||
if (!profile?.allowedIdentifierTypes) return true;
|
||||
return profile.allowedIdentifierTypes.includes(identifierType);
|
||||
}
|
||||
181
packages/core/src/types.ts
Normal file
181
packages/core/src/types.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Domain types aligned with OpenAPI and data-model.
|
||||
* No I/O; pure domain and validation.
|
||||
*/
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Identifier {
|
||||
id: string;
|
||||
participantId: string;
|
||||
identifier_type: string;
|
||||
value: string;
|
||||
scope?: string;
|
||||
priority: number;
|
||||
verified_at?: string;
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
id: string;
|
||||
participantId: string;
|
||||
protocol: string;
|
||||
address: string;
|
||||
profile?: string;
|
||||
priority: number;
|
||||
status: "active" | "inactive" | "draining";
|
||||
}
|
||||
|
||||
export interface Capability {
|
||||
id: string;
|
||||
participantId: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
process?: string;
|
||||
document_type?: string;
|
||||
constraints_json?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CredentialRef {
|
||||
id: string;
|
||||
participantId: string;
|
||||
credential_type: "tls" | "sign" | "encrypt";
|
||||
vault_ref: string;
|
||||
fingerprint?: string;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
rule_json: Record<string, unknown>;
|
||||
effect: "allow" | "deny";
|
||||
priority: number;
|
||||
}
|
||||
|
||||
/** Input identifier for resolve request */
|
||||
export interface IdentifierInput {
|
||||
type: string;
|
||||
value: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface ServiceContext {
|
||||
service?: string;
|
||||
action?: string;
|
||||
process?: string;
|
||||
documentType?: string;
|
||||
}
|
||||
|
||||
export interface ResolveConstraints {
|
||||
trustDomain?: string;
|
||||
region?: string;
|
||||
jurisdiction?: string;
|
||||
maxResults?: number;
|
||||
/** Card network brand: visa, mastercard, amex, discover, diners */
|
||||
networkBrand?: string;
|
||||
/** Tenant contract or connectivity group for per-tenant/contract routing */
|
||||
tenantContract?: string;
|
||||
connectivityGroup?: string;
|
||||
/** Explicit required capability filter */
|
||||
requiredCapability?: string;
|
||||
/** Message type (e.g. ISO8583 MTI or AS4 service/action) */
|
||||
messageType?: string;
|
||||
}
|
||||
|
||||
export interface ResolveRequest {
|
||||
identifiers: IdentifierInput[];
|
||||
serviceContext?: ServiceContext;
|
||||
constraints?: ResolveConstraints;
|
||||
tenant?: string;
|
||||
desiredProtocols?: string[];
|
||||
}
|
||||
|
||||
/** Security block in RouteDirective */
|
||||
export interface RouteDirectiveSecurity {
|
||||
signRequired?: boolean;
|
||||
encryptRequired?: boolean;
|
||||
keyRefs?: string[];
|
||||
algorithms?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Service context in RouteDirective */
|
||||
export interface RouteDirectiveServiceContext {
|
||||
service?: string;
|
||||
action?: string;
|
||||
serviceIndicator?: string;
|
||||
}
|
||||
|
||||
/** QoS in RouteDirective */
|
||||
export interface RouteDirectiveQos {
|
||||
retries?: number;
|
||||
receiptsRequired?: boolean;
|
||||
ordering?: string;
|
||||
}
|
||||
|
||||
/** Evidence in RouteDirective (single or array for multiple sources) */
|
||||
export interface RouteDirectiveEvidence {
|
||||
source?: string;
|
||||
lastVerified?: string;
|
||||
confidenceScore?: number;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface RouteDirective {
|
||||
target_protocol: string;
|
||||
target_address: string;
|
||||
transport_profile?: string;
|
||||
security?: RouteDirectiveSecurity;
|
||||
service_context?: RouteDirectiveServiceContext;
|
||||
qos?: RouteDirectiveQos;
|
||||
ttl_seconds?: number;
|
||||
evidence?: RouteDirectiveEvidence | RouteDirectiveEvidence[];
|
||||
}
|
||||
|
||||
/** Directive with optional reason (for alternates) */
|
||||
export interface DirectiveWithReason {
|
||||
directive: RouteDirective;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Advisory failure policy for gateway */
|
||||
export interface FailurePolicy {
|
||||
retry?: boolean;
|
||||
backoff?: string;
|
||||
circuitBreak?: boolean;
|
||||
}
|
||||
|
||||
/** Entry in resolution trace (which source contributed) */
|
||||
export interface ResolutionTraceEntry {
|
||||
source: string;
|
||||
directiveIndex?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ResolveResponse {
|
||||
/** Best match; when set, directives[0] should equal primary for backward compat */
|
||||
primary?: RouteDirective;
|
||||
/** Ordered fallback directives with reason */
|
||||
alternates?: DirectiveWithReason[];
|
||||
directives: RouteDirective[];
|
||||
ttl?: number;
|
||||
traceId?: string;
|
||||
correlationId?: string;
|
||||
failure_policy?: FailurePolicy;
|
||||
/** TTL for negative (no-match) cache in seconds */
|
||||
negative_cache_ttl?: number;
|
||||
resolution_trace?: ResolutionTraceEntry[];
|
||||
}
|
||||
107
packages/core/src/validation.ts
Normal file
107
packages/core/src/validation.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Identifier validation helpers (stubs / minimal regex-based).
|
||||
* Aligned with identifier types in data-model.md.
|
||||
*/
|
||||
|
||||
/** E.164: optional +, digits only, typically up to 15 digits */
|
||||
const E164_REGEX = /^\+?[1-9]\d{1,14}$/;
|
||||
|
||||
/** AS4 PartyId: common pattern scheme:value (e.g. 0088:123456789) */
|
||||
const PARTY_ID_REGEX = /^[^:]+:[^:]+$/;
|
||||
|
||||
/** PEPPOL participant ID: same format as PartyId (ISO 6523) */
|
||||
const PEPPOL_PARTICIPANT_REGEX = /^[0-9]{4}:[a-zA-Z0-9]+$/;
|
||||
|
||||
/** Point code: numeric, format depends on variant (ITU 14-bit, ANSI 24-bit); accept digits and dots */
|
||||
const PC_REGEX = /^[\d.]+$/;
|
||||
|
||||
/** SSN: 1-255 */
|
||||
const SSN_REGEX = /^(?:25[0-5]|2[0-4]\d|1?\d{1,2})$/;
|
||||
|
||||
/** KTT (placeholder rail): alphanumeric, optional separators */
|
||||
const KTT_ID_REGEX = /^[a-zA-Z0-9._-]+$/;
|
||||
|
||||
/** BIN/IIN: 6-8 digits only (never full PAN) */
|
||||
const PAN_BIN_REGEX = /^\d{6,8}$/;
|
||||
|
||||
/** Merchant ID / Terminal ID: alphanumeric, tenant-scoped format */
|
||||
const MID_TID_REGEX = /^[a-zA-Z0-9]+$/;
|
||||
|
||||
/** LEI: 20 alphanumeric */
|
||||
const LEI_REGEX = /^[A-Z0-9]{20}$/;
|
||||
|
||||
/** BIC: 8 or 11 alphanumeric */
|
||||
const BIC_REGEX = /^[A-Za-z0-9]{8}([A-Za-z0-9]{3})?$/;
|
||||
|
||||
/** DTC participant/account ID: alphanumeric, tenant-scoped */
|
||||
const DTC_ID_REGEX = /^[a-zA-Z0-9._-]+$/;
|
||||
|
||||
export function validateE164(value: string): boolean {
|
||||
return typeof value === "string" && E164_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
export function validateAs4PartyId(value: string): boolean {
|
||||
return typeof value === "string" && value.length > 0 && PARTY_ID_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
export function validatePeppolParticipantId(value: string): boolean {
|
||||
return typeof value === "string" && PEPPOL_PARTICIPANT_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
export function validatePointCode(value: string): boolean {
|
||||
return typeof value === "string" && PC_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
export function validateSsn(value: string): boolean {
|
||||
return typeof value === "string" && SSN_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
export function validateKttId(value: string): boolean {
|
||||
return typeof value === "string" && value.length > 0 && KTT_ID_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
export function validatePanBin(value: string): boolean {
|
||||
return typeof value === "string" && PAN_BIN_REGEX.test(value.replace(/\D/g, ""));
|
||||
}
|
||||
|
||||
export function validateMidOrTid(value: string): boolean {
|
||||
return typeof value === "string" && value.length > 0 && MID_TID_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
export function validateLei(value: string): boolean {
|
||||
return typeof value === "string" && LEI_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
export function validateBic(value: string): boolean {
|
||||
return typeof value === "string" && BIC_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
export function validateDtcId(value: string): boolean {
|
||||
return typeof value === "string" && value.length > 0 && DTC_ID_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
const VALIDATORS: Record<string, (v: string) => boolean> = {
|
||||
e164: validateE164,
|
||||
"as4.partyId": validateAs4PartyId,
|
||||
"peppol.participantId": validatePeppolParticipantId,
|
||||
pc: validatePointCode,
|
||||
ssn: validateSsn,
|
||||
"ktt.id": validateKttId,
|
||||
"ktt.participantId": validateKttId,
|
||||
"pan.bin": validatePanBin,
|
||||
mid: validateMidOrTid,
|
||||
tid: validateMidOrTid,
|
||||
lei: validateLei,
|
||||
bic: validateBic,
|
||||
"dtc.participantId": validateDtcId,
|
||||
"dtc.accountId": validateDtcId,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate an identifier by type. Returns true if valid or type has no validator (permissive).
|
||||
*/
|
||||
export function validateIdentifier(type: string, value: string): boolean {
|
||||
const fn = VALIDATORS[type];
|
||||
if (!fn) return typeof value === "string" && value.length > 0;
|
||||
return fn(value);
|
||||
}
|
||||
16
packages/core/tsconfig.json
Normal file
16
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
22
packages/resolver/package.json
Normal file
22
packages/resolver/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@as4-411/resolver",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "Resolution pipeline and caching for as4-411",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --test dist/**/*.test.js 2>/dev/null || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@as4-411/core": "workspace:*",
|
||||
"@as4-411/storage": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
49
packages/resolver/src/artifact-resolve.ts
Normal file
49
packages/resolver/src/artifact-resolve.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ResolveRequest, ResolveResponse, RouteDirective } from "@as4-411/core";
|
||||
import type { RoutingArtifactStore } from "@as4-411/storage";
|
||||
import type { BinTableEntry } from "@as4-411/core";
|
||||
|
||||
/**
|
||||
* Try to resolve using a routing artifact (e.g. BIN table). Returns directives if found, else null.
|
||||
*/
|
||||
export async function tryArtifactResolution(
|
||||
request: ResolveRequest,
|
||||
artifactStore: RoutingArtifactStore,
|
||||
defaultTtlSeconds: number
|
||||
): Promise<ResolveResponse | null> {
|
||||
const binId = request.identifiers.find((i) => i.type === "pan.bin");
|
||||
if (!binId?.value) return null;
|
||||
|
||||
const artifact = await artifactStore.get("bin_table", {
|
||||
tenantId: request.tenant ?? undefined,
|
||||
});
|
||||
if (!artifact?.payload?.data) return null;
|
||||
|
||||
const data = artifact.payload.data as { entries?: BinTableEntry[] };
|
||||
const entries = data.entries;
|
||||
if (!Array.isArray(entries) || entries.length === 0) return null;
|
||||
|
||||
const binValue = String(binId.value).replace(/\D/g, "").slice(0, 12);
|
||||
const entry = entries.find((e) => {
|
||||
const prefix = String(e.binPrefix).replace(/\D/g, "");
|
||||
const len = e.binLength ?? prefix.length;
|
||||
return binValue.startsWith(prefix) && binValue.length >= len;
|
||||
});
|
||||
if (!entry) return null;
|
||||
|
||||
const directive: RouteDirective = {
|
||||
target_protocol: "iso8583",
|
||||
target_address: entry.routingTarget,
|
||||
transport_profile: "bin_table",
|
||||
ttl_seconds: defaultTtlSeconds,
|
||||
evidence: {
|
||||
source: "routing_artifact",
|
||||
confidenceScore: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
directives: [directive],
|
||||
ttl: defaultTtlSeconds,
|
||||
traceId: crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
43
packages/resolver/src/cache.ts
Normal file
43
packages/resolver/src/cache.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ResolveRequest, ResolveResponse } from "@as4-411/core";
|
||||
|
||||
export interface ResolveCache {
|
||||
get(key: string): Promise<ResolveResponse | null>;
|
||||
set(key: string, value: ResolveResponse, ttlSeconds: number): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function cacheKey(request: ResolveRequest): string {
|
||||
const ids = request.identifiers
|
||||
.map((i) => `${i.type}:${i.value}:${i.scope ?? ""}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const ctx = request.serviceContext ? JSON.stringify(request.serviceContext) : "";
|
||||
const constraints = request.constraints ? JSON.stringify(request.constraints) : "";
|
||||
const tenant = request.tenant ?? "";
|
||||
return `resolve:${tenant}:${ids}:${ctx}:${constraints}`;
|
||||
}
|
||||
|
||||
export class InMemoryResolveCache implements ResolveCache {
|
||||
private store = new Map<string, { value: ResolveResponse; expiresAt: number }>();
|
||||
|
||||
async get(key: string): Promise<ResolveResponse | null> {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
async set(key: string, value: ResolveResponse, ttlSeconds: number): Promise<void> {
|
||||
this.store.set(key, {
|
||||
value,
|
||||
expiresAt: Date.now() + ttlSeconds * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
5
packages/resolver/src/index.ts
Normal file
5
packages/resolver/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { Resolver } from "./resolver.js";
|
||||
export type { ResolverOptions } from "./resolver.js";
|
||||
export { cacheKey, InMemoryResolveCache } from "./cache.js";
|
||||
export type { ResolveCache } from "./cache.js";
|
||||
export * from "./pipeline.js";
|
||||
146
packages/resolver/src/pipeline.ts
Normal file
146
packages/resolver/src/pipeline.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type {
|
||||
ResolveRequest,
|
||||
RouteDirective,
|
||||
Participant,
|
||||
Endpoint,
|
||||
Identifier,
|
||||
Capability,
|
||||
Policy,
|
||||
} from "@as4-411/core";
|
||||
import type { DirectoryStore } from "@as4-411/storage";
|
||||
import { validateIdentifier } from "@as4-411/core";
|
||||
|
||||
export interface PipelineContext {
|
||||
request: ResolveRequest;
|
||||
normalizedIdentifiers: Array<{ type: string; value: string; scope?: string }>;
|
||||
candidates: Array<{
|
||||
participant: Participant;
|
||||
endpoint: Endpoint;
|
||||
identifier?: Identifier;
|
||||
capability?: Capability;
|
||||
}>;
|
||||
policies: Policy[];
|
||||
directives: RouteDirective[];
|
||||
}
|
||||
|
||||
/** Step 1: Normalize and validate identifiers */
|
||||
export function normalizeInput(request: ResolveRequest): PipelineContext["normalizedIdentifiers"] {
|
||||
const out: Array<{ type: string; value: string; scope?: string }> = [];
|
||||
for (const id of request.identifiers) {
|
||||
const value = String(id.value).trim();
|
||||
if (!value) continue;
|
||||
if (!validateIdentifier(id.type, value)) continue;
|
||||
out.push({ type: id.type, value, scope: id.scope });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Step 2: Expand context — for MVP we use the same set; equivalence graph can be added later */
|
||||
export function expandContext(
|
||||
normalized: PipelineContext["normalizedIdentifiers"]
|
||||
): Array<{ type: string; value: string }> {
|
||||
return normalized.map((n) => ({ type: n.type, value: n.value }));
|
||||
}
|
||||
|
||||
/** Step 3: Candidate retrieval */
|
||||
export async function retrieveCandidates(
|
||||
store: DirectoryStore,
|
||||
identifierPairs: Array<{ type: string; value: string }>,
|
||||
tenantId?: string
|
||||
): Promise<Participant[]> {
|
||||
return store.findParticipantsByIdentifiers(identifierPairs, { tenantId });
|
||||
}
|
||||
|
||||
/** Step 4: Capability filter — keep participants that match service context */
|
||||
export async function filterByCapability(
|
||||
store: DirectoryStore,
|
||||
participantIds: string[],
|
||||
service?: string,
|
||||
action?: string
|
||||
): Promise<Set<string>> {
|
||||
const allowed = new Set<string>();
|
||||
for (const pid of participantIds) {
|
||||
const caps = await store.getCapabilitiesByParticipantId(pid);
|
||||
if (caps.length === 0) {
|
||||
allowed.add(pid);
|
||||
continue;
|
||||
}
|
||||
const match = caps.some((c) => {
|
||||
if (service != null && c.service !== service) return false;
|
||||
if (action != null && c.action !== action) return false;
|
||||
return true;
|
||||
});
|
||||
if (match) allowed.add(pid);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/** Step 5: Policy filter — tenant scoping and allow/deny by participant or identifier type */
|
||||
export function filterByPolicy(participants: Participant[], policies: Policy[]): Participant[] {
|
||||
const denyRules = policies.filter((p) => p.effect === "deny");
|
||||
const allowRules = policies.filter((p) => p.effect === "allow");
|
||||
|
||||
let out = participants;
|
||||
|
||||
// Deny: exclude participants listed in deny rule_json.participantId or rule_json.participantIds
|
||||
if (denyRules.length > 0) {
|
||||
const deniedIds = new Set<string>();
|
||||
for (const r of denyRules) {
|
||||
const j = r.rule_json ?? {};
|
||||
if (typeof j.participantId === "string") deniedIds.add(j.participantId as string);
|
||||
if (Array.isArray(j.participantIds))
|
||||
(j.participantIds as string[]).forEach((id) => deniedIds.add(id));
|
||||
}
|
||||
out = out.filter((p) => !deniedIds.has(p.id));
|
||||
}
|
||||
|
||||
// Allow (restrictive): if any allow rules exist, only include participants matching at least one
|
||||
if (allowRules.length > 0) {
|
||||
const allowedIds = new Set<string>();
|
||||
for (const r of allowRules) {
|
||||
const j = r.rule_json ?? {};
|
||||
if (typeof j.participantId === "string") allowedIds.add(j.participantId as string);
|
||||
if (Array.isArray(j.participantIds))
|
||||
(j.participantIds as string[]).forEach((id) => allowedIds.add(id));
|
||||
}
|
||||
if (allowedIds.size > 0) out = out.filter((p) => allowedIds.has(p.id));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Step 6: Score and rank (deterministic). Higher score first; tie-break: priority DESC, id ASC */
|
||||
export function scoreAndRank(
|
||||
candidates: PipelineContext["candidates"]
|
||||
): PipelineContext["candidates"] {
|
||||
return [...candidates].sort((a, b) => {
|
||||
let scoreA = a.endpoint.priority ?? 0;
|
||||
let scoreB = b.endpoint.priority ?? 0;
|
||||
if (a.endpoint.status === "active") scoreA += 100;
|
||||
if (b.endpoint.status === "active") scoreB += 100;
|
||||
if (a.endpoint.status === "draining") scoreA += 50;
|
||||
if (b.endpoint.status === "draining") scoreB += 50;
|
||||
if (scoreA !== scoreB) return scoreB - scoreA;
|
||||
const idCmp = (a.endpoint.id ?? "").localeCompare(b.endpoint.id ?? "");
|
||||
if (idCmp !== 0) return idCmp;
|
||||
return (a.participant.id ?? "").localeCompare(b.participant.id ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
/** Step 7: Assemble directives from ranked candidates */
|
||||
export function assembleDirectives(
|
||||
candidates: PipelineContext["candidates"],
|
||||
defaultTtlSeconds: number
|
||||
): RouteDirective[] {
|
||||
const maxResults = 10;
|
||||
return candidates.slice(0, maxResults).map((c) => ({
|
||||
target_protocol: c.endpoint.protocol,
|
||||
target_address: c.endpoint.address,
|
||||
transport_profile: c.endpoint.profile,
|
||||
ttl_seconds: defaultTtlSeconds,
|
||||
evidence: {
|
||||
source: "directory",
|
||||
confidenceScore: 1,
|
||||
},
|
||||
}));
|
||||
}
|
||||
166
packages/resolver/src/resolver.ts
Normal file
166
packages/resolver/src/resolver.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { ResolveRequest, ResolveResponse } from "@as4-411/core";
|
||||
import type { DirectoryStore, RoutingArtifactStore } from "@as4-411/storage";
|
||||
import { cacheKey } from "./cache.js";
|
||||
import type { ResolveCache } from "./cache.js";
|
||||
import { tryArtifactResolution } from "./artifact-resolve.js";
|
||||
import {
|
||||
normalizeInput,
|
||||
expandContext,
|
||||
retrieveCandidates,
|
||||
filterByCapability,
|
||||
filterByPolicy,
|
||||
scoreAndRank,
|
||||
assembleDirectives,
|
||||
} from "./pipeline.js";
|
||||
|
||||
const DEFAULT_TTL_SECONDS = 300;
|
||||
|
||||
export interface ResolverOptions {
|
||||
store: DirectoryStore;
|
||||
cache?: ResolveCache;
|
||||
artifactStore?: RoutingArtifactStore;
|
||||
defaultTtlSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver: runs the resolution pipeline and optionally caches results.
|
||||
* Same inputs + same store state => stable ordering (see resolution-algorithm.md).
|
||||
*/
|
||||
export class Resolver {
|
||||
constructor(private readonly options: ResolverOptions) {}
|
||||
|
||||
async resolve(request: ResolveRequest): Promise<ResolveResponse> {
|
||||
const traceId = crypto.randomUUID();
|
||||
const cache = this.options.cache;
|
||||
const key = cacheKey(request);
|
||||
|
||||
if (cache) {
|
||||
const cached = await cache.get(key);
|
||||
if (cached) {
|
||||
return { ...cached, traceId };
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Normalize input
|
||||
const normalized = normalizeInput(request);
|
||||
if (normalized.length === 0) {
|
||||
const empty: ResolveResponse = {
|
||||
directives: [],
|
||||
ttl: 0,
|
||||
traceId,
|
||||
negative_cache_ttl: 60,
|
||||
};
|
||||
if (cache) await cache.set(key, empty, 60);
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 1b. Artifact-based resolution (e.g. BIN table)
|
||||
const ttl = this.options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS;
|
||||
if (this.options.artifactStore) {
|
||||
const artifactResponse = await tryArtifactResolution(
|
||||
request,
|
||||
this.options.artifactStore,
|
||||
ttl
|
||||
);
|
||||
if (artifactResponse && artifactResponse.directives.length > 0) {
|
||||
const dirs = artifactResponse.directives;
|
||||
const out: ResolveResponse = {
|
||||
...artifactResponse,
|
||||
traceId,
|
||||
primary: dirs[0],
|
||||
alternates: dirs.slice(1).map((d) => ({ directive: d, reason: "fallback" })),
|
||||
resolution_trace: [{ source: "routing_artifact" }],
|
||||
};
|
||||
if (cache) await cache.set(key, out, ttl);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Expand context
|
||||
const identifierPairs = expandContext(normalized);
|
||||
|
||||
// 3. Candidate retrieval
|
||||
const participants = await retrieveCandidates(
|
||||
this.options.store,
|
||||
identifierPairs,
|
||||
request.tenant
|
||||
);
|
||||
if (participants.length === 0) {
|
||||
const empty: ResolveResponse = {
|
||||
directives: [],
|
||||
ttl: 60,
|
||||
traceId,
|
||||
negative_cache_ttl: 60,
|
||||
};
|
||||
if (cache) await cache.set(key, empty, 60);
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 4. Capability filter
|
||||
const service = request.serviceContext?.service;
|
||||
const action = request.serviceContext?.action;
|
||||
const allowedParticipantIds = await filterByCapability(
|
||||
this.options.store,
|
||||
participants.map((p) => p.id),
|
||||
service,
|
||||
action
|
||||
);
|
||||
const allowedParticipants = participants.filter((p) => allowedParticipantIds.has(p.id));
|
||||
if (allowedParticipants.length === 0) {
|
||||
const empty: ResolveResponse = {
|
||||
directives: [],
|
||||
ttl: 60,
|
||||
traceId,
|
||||
negative_cache_ttl: 60,
|
||||
};
|
||||
if (cache) await cache.set(key, empty, 60);
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 5. Policy filter
|
||||
const tenantId = request.tenant ?? allowedParticipants[0]?.tenantId;
|
||||
const policies = tenantId ? await this.options.store.getPoliciesByTenantId(tenantId) : [];
|
||||
const policyFiltered = filterByPolicy(allowedParticipants, policies);
|
||||
|
||||
// Build candidate list: participant + endpoint
|
||||
const candidates: Array<{
|
||||
participant: (typeof policyFiltered)[0];
|
||||
endpoint: import("@as4-411/core").Endpoint;
|
||||
identifier?: import("@as4-411/core").Identifier;
|
||||
capability?: import("@as4-411/core").Capability;
|
||||
}> = [];
|
||||
for (const participant of policyFiltered) {
|
||||
const endpoints = await this.options.store.getEndpointsByParticipantId(participant.id, {
|
||||
status: "active",
|
||||
});
|
||||
if (endpoints.length === 0) {
|
||||
const anyEndpoints = await this.options.store.getEndpointsByParticipantId(participant.id);
|
||||
for (const ep of anyEndpoints) {
|
||||
candidates.push({ participant, endpoint: ep });
|
||||
}
|
||||
} else {
|
||||
for (const ep of endpoints) {
|
||||
candidates.push({ participant, endpoint: ep });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Score and rank
|
||||
const ranked = scoreAndRank(candidates);
|
||||
|
||||
// 7. Assemble directives
|
||||
const directives = assembleDirectives(ranked, ttl);
|
||||
|
||||
const response: ResolveResponse = {
|
||||
directives,
|
||||
ttl,
|
||||
traceId,
|
||||
primary: directives[0],
|
||||
alternates: directives.slice(1).map((d) => ({ directive: d, reason: "priority" })),
|
||||
resolution_trace: [{ source: "internal directory" }],
|
||||
};
|
||||
|
||||
if (cache) await cache.set(key, response, ttl);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
16
packages/resolver/tsconfig.json
Normal file
16
packages/resolver/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
0
packages/storage/migrations/.gitkeep
Normal file
0
packages/storage/migrations/.gitkeep
Normal file
91
packages/storage/migrations/001_initial.sql
Normal file
91
packages/storage/migrations/001_initial.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
-- Initial schema for as4-411 directory (data-model.md)
|
||||
-- Run with psql or migration runner; uses snake_case for columns.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_participants_tenant_id ON participants(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identifiers (
|
||||
id TEXT PRIMARY KEY,
|
||||
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||
identifier_type TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
scope TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
verified_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_identifiers_lookup ON identifiers(identifier_type, value);
|
||||
CREATE INDEX idx_identifiers_participant_id ON identifiers(participant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS endpoints (
|
||||
id TEXT PRIMARY KEY,
|
||||
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||
protocol TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
profile TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'draining'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_endpoints_participant_id ON endpoints(participant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capabilities (
|
||||
id TEXT PRIMARY KEY,
|
||||
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||
service TEXT,
|
||||
action TEXT,
|
||||
process TEXT,
|
||||
document_type TEXT,
|
||||
constraints_json JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX idx_capabilities_participant_id ON capabilities(participant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||
credential_type TEXT NOT NULL CHECK (credential_type IN ('tls', 'sign', 'encrypt')),
|
||||
vault_ref TEXT NOT NULL,
|
||||
fingerprint TEXT,
|
||||
valid_from TIMESTAMPTZ,
|
||||
valid_to TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_credentials_participant_id ON credentials(participant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
rule_json JSONB NOT NULL DEFAULT '{}',
|
||||
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
|
||||
priority INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_policies_tenant_id ON policies(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
actor TEXT,
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
payload JSONB,
|
||||
hash_prev TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_resource ON audit_log(resource, resource_id);
|
||||
16
packages/storage/migrations/002_routing_artifacts.sql
Normal file
16
packages/storage/migrations/002_routing_artifacts.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Routing artifacts: BIN tables, GTT tables, participant maps, fallback rules.
|
||||
-- See docs/architecture/data-model and protocol_registry.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routing_artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
artifact_type TEXT NOT NULL CHECK (artifact_type IN ('bin_table', 'gtt_table', 'participant_map', 'fallback_rules')),
|
||||
artifact_payload JSONB NOT NULL,
|
||||
effective_from TIMESTAMPTZ NOT NULL,
|
||||
effective_to TIMESTAMPTZ,
|
||||
signature TEXT,
|
||||
fingerprint TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_routing_artifacts_tenant_type ON routing_artifacts(tenant_id, artifact_type);
|
||||
CREATE INDEX idx_routing_artifacts_effective ON routing_artifacts(effective_from, effective_to);
|
||||
20
packages/storage/migrations/003_edges.sql
Normal file
20
packages/storage/migrations/003_edges.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Graph layer: edges with provenance and validity (see data-model.md).
|
||||
-- Optional: used when explicit graph and conflict resolution are needed.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS edges (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_type TEXT NOT NULL,
|
||||
from_id TEXT NOT NULL,
|
||||
to_type TEXT NOT NULL,
|
||||
to_id TEXT NOT NULL,
|
||||
relation TEXT NOT NULL,
|
||||
confidence REAL,
|
||||
source TEXT,
|
||||
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
valid_to TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_edges_from ON edges(from_type, from_id);
|
||||
CREATE INDEX idx_edges_to ON edges(to_type, to_id);
|
||||
CREATE INDEX idx_edges_relation ON edges(relation);
|
||||
CREATE INDEX idx_edges_valid ON edges(valid_from, valid_to);
|
||||
23
packages/storage/package.json
Normal file
23
packages/storage/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@as4-411/storage",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "Directory store port and implementations for as4-411",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --test dist/**/*.test.js 2>/dev/null || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@as4-411/core": "workspace:*",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.10.9",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
0
packages/storage/postgres/.gitkeep
Normal file
0
packages/storage/postgres/.gitkeep
Normal file
0
packages/storage/sqlite/.gitkeep
Normal file
0
packages/storage/sqlite/.gitkeep
Normal file
54
packages/storage/src/admin-port.ts
Normal file
54
packages/storage/src/admin-port.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
Tenant,
|
||||
Participant,
|
||||
Identifier,
|
||||
Endpoint,
|
||||
Capability,
|
||||
CredentialRef,
|
||||
Policy,
|
||||
} from "@as4-411/core";
|
||||
import type { DirectoryStore } from "./port.js";
|
||||
|
||||
/**
|
||||
* Admin store: DirectoryStore read methods plus full CRUD for directory entities.
|
||||
* Used by Admin API; Postgres and InMemory can implement.
|
||||
*/
|
||||
export interface AdminStore extends DirectoryStore {
|
||||
// Tenants
|
||||
listTenants(): Promise<Tenant[]>;
|
||||
getTenant(id: string): Promise<Tenant | null>;
|
||||
createTenant(tenant: Tenant): Promise<void>;
|
||||
updateTenant(id: string, tenant: Omit<Tenant, "id">): Promise<boolean>;
|
||||
deleteTenant(id: string): Promise<boolean>;
|
||||
|
||||
// Participants
|
||||
listParticipants(options?: { tenantId?: string }): Promise<Participant[]>;
|
||||
getParticipant(id: string): Promise<Participant | null>;
|
||||
createParticipant(participant: Participant): Promise<void>;
|
||||
updateParticipant(id: string, participant: Omit<Participant, "id" | "tenantId">): Promise<boolean>;
|
||||
deleteParticipant(id: string): Promise<boolean>;
|
||||
|
||||
// Identifiers (create/list; list is getIdentifiersByParticipantId)
|
||||
createIdentifier(identifier: Identifier): Promise<void>;
|
||||
deleteIdentifier(id: string): Promise<boolean>;
|
||||
|
||||
// Endpoints
|
||||
createEndpoint(endpoint: Endpoint): Promise<void>;
|
||||
updateEndpoint(id: string, endpoint: Omit<Endpoint, "id" | "participantId">): Promise<boolean>;
|
||||
deleteEndpoint(id: string): Promise<boolean>;
|
||||
|
||||
// Capabilities
|
||||
createCapability(capability: Capability): Promise<void>;
|
||||
deleteCapability(id: string): Promise<boolean>;
|
||||
|
||||
// Credentials
|
||||
createCredential(credential: CredentialRef): Promise<void>;
|
||||
deleteCredential(id: string): Promise<boolean>;
|
||||
|
||||
// Policies
|
||||
listPolicies(options?: { tenantId?: string }): Promise<Policy[]>;
|
||||
getPolicy(id: string): Promise<Policy | null>;
|
||||
createPolicy(policy: Policy): Promise<void>;
|
||||
updatePolicy(id: string, policy: Omit<Policy, "id" | "tenantId">): Promise<boolean>;
|
||||
deletePolicy(id: string): Promise<boolean>;
|
||||
}
|
||||
8
packages/storage/src/index.ts
Normal file
8
packages/storage/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { DirectoryStore } from "./port.js";
|
||||
export type { AdminStore } from "./admin-port.js";
|
||||
export type { RoutingArtifactStore } from "./routing-artifact-port.js";
|
||||
export { InMemoryDirectoryStore } from "./memory-store.js";
|
||||
export { InMemoryRoutingArtifactStore } from "./routing-artifact-memory.js";
|
||||
export { PostgresDirectoryStore } from "./postgres/postgres-store.js";
|
||||
export { PostgresRoutingArtifactStore } from "./postgres/routing-artifact-store.js";
|
||||
export type { PostgresStoreConfig } from "./postgres/postgres-store.js";
|
||||
242
packages/storage/src/memory-store.ts
Normal file
242
packages/storage/src/memory-store.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type {
|
||||
Tenant,
|
||||
Participant,
|
||||
Identifier,
|
||||
Endpoint,
|
||||
Capability,
|
||||
CredentialRef,
|
||||
Policy,
|
||||
} from "@as4-411/core";
|
||||
import type { AdminStore } from "./admin-port.js";
|
||||
|
||||
/**
|
||||
* In-memory directory store for development and tests.
|
||||
* Implements AdminStore (read + write). Not persistent; no migrations.
|
||||
*/
|
||||
export class InMemoryDirectoryStore implements AdminStore {
|
||||
private tenants: Map<string, Tenant> = new Map();
|
||||
private participants: Map<string, Participant> = new Map();
|
||||
private identifiers: Identifier[] = [];
|
||||
private endpoints: Endpoint[] = [];
|
||||
private capabilities: Capability[] = [];
|
||||
private credentials: CredentialRef[] = [];
|
||||
private policies: Policy[] = [];
|
||||
|
||||
addParticipant(p: Participant): void {
|
||||
this.participants.set(p.id, p);
|
||||
}
|
||||
|
||||
addIdentifier(i: Identifier): void {
|
||||
this.identifiers.push(i);
|
||||
}
|
||||
|
||||
addEndpoint(e: Endpoint): void {
|
||||
this.endpoints.push(e);
|
||||
}
|
||||
|
||||
addCapability(c: Capability): void {
|
||||
this.capabilities.push(c);
|
||||
}
|
||||
|
||||
addCredential(c: CredentialRef): void {
|
||||
this.credentials.push(c);
|
||||
}
|
||||
|
||||
addPolicy(p: Policy): void {
|
||||
this.policies.push(p);
|
||||
}
|
||||
|
||||
addTenant(t: Tenant): void {
|
||||
this.tenants.set(t.id, t);
|
||||
}
|
||||
|
||||
async listTenants(): Promise<Tenant[]> {
|
||||
return Array.from(this.tenants.values());
|
||||
}
|
||||
|
||||
async getTenant(id: string): Promise<Tenant | null> {
|
||||
return this.tenants.get(id) ?? null;
|
||||
}
|
||||
|
||||
async createTenant(tenant: Tenant): Promise<void> {
|
||||
this.tenants.set(tenant.id, tenant);
|
||||
}
|
||||
|
||||
async updateTenant(id: string, tenant: Omit<Tenant, "id">): Promise<boolean> {
|
||||
const existing = this.tenants.get(id);
|
||||
if (!existing) return false;
|
||||
this.tenants.set(id, { ...existing, ...tenant });
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteTenant(id: string): Promise<boolean> {
|
||||
return this.tenants.delete(id);
|
||||
}
|
||||
|
||||
async listParticipants(options?: { tenantId?: string }): Promise<Participant[]> {
|
||||
let list = Array.from(this.participants.values());
|
||||
if (options?.tenantId)
|
||||
list = list.filter((p) => p.tenantId === options.tenantId);
|
||||
return list;
|
||||
}
|
||||
|
||||
async getParticipant(id: string): Promise<Participant | null> {
|
||||
return this.participants.get(id) ?? null;
|
||||
}
|
||||
|
||||
async createParticipant(participant: Participant): Promise<void> {
|
||||
this.participants.set(participant.id, participant);
|
||||
}
|
||||
|
||||
async updateParticipant(
|
||||
id: string,
|
||||
participant: Omit<Participant, "id" | "tenantId">
|
||||
): Promise<boolean> {
|
||||
const existing = this.participants.get(id);
|
||||
if (!existing) return false;
|
||||
this.participants.set(id, { ...existing, ...participant });
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteParticipant(id: string): Promise<boolean> {
|
||||
if (!this.participants.has(id)) return false;
|
||||
this.participants.delete(id);
|
||||
this.identifiers = this.identifiers.filter((i) => i.participantId !== id);
|
||||
this.endpoints = this.endpoints.filter((e) => e.participantId !== id);
|
||||
this.capabilities = this.capabilities.filter((c) => c.participantId !== id);
|
||||
this.credentials = this.credentials.filter((c) => c.participantId !== id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async createIdentifier(identifier: Identifier): Promise<void> {
|
||||
this.identifiers.push(identifier);
|
||||
}
|
||||
|
||||
async deleteIdentifier(id: string): Promise<boolean> {
|
||||
const idx = this.identifiers.findIndex((i) => i.id === id);
|
||||
if (idx === -1) return false;
|
||||
this.identifiers.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async createEndpoint(endpoint: Endpoint): Promise<void> {
|
||||
this.endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
async updateEndpoint(
|
||||
id: string,
|
||||
endpoint: Omit<Endpoint, "id" | "participantId">
|
||||
): Promise<boolean> {
|
||||
const idx = this.endpoints.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return false;
|
||||
this.endpoints[idx] = { ...this.endpoints[idx], ...endpoint };
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteEndpoint(id: string): Promise<boolean> {
|
||||
const idx = this.endpoints.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return false;
|
||||
this.endpoints.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async createCapability(capability: Capability): Promise<void> {
|
||||
this.capabilities.push(capability);
|
||||
}
|
||||
|
||||
async deleteCapability(id: string): Promise<boolean> {
|
||||
const idx = this.capabilities.findIndex((c) => c.id === id);
|
||||
if (idx === -1) return false;
|
||||
this.capabilities.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async createCredential(credential: CredentialRef): Promise<void> {
|
||||
this.credentials.push(credential);
|
||||
}
|
||||
|
||||
async deleteCredential(id: string): Promise<boolean> {
|
||||
const idx = this.credentials.findIndex((c) => c.id === id);
|
||||
if (idx === -1) return false;
|
||||
this.credentials.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async listPolicies(options?: { tenantId?: string }): Promise<Policy[]> {
|
||||
let list = Array.from(this.policies);
|
||||
if (options?.tenantId)
|
||||
list = list.filter((p) => p.tenantId === options.tenantId);
|
||||
return list.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
async getPolicy(id: string): Promise<Policy | null> {
|
||||
return this.policies.find((p) => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
async createPolicy(policy: Policy): Promise<void> {
|
||||
this.policies.push(policy);
|
||||
}
|
||||
|
||||
async updatePolicy(id: string, policy: Omit<Policy, "id" | "tenantId">): Promise<boolean> {
|
||||
const idx = this.policies.findIndex((p) => p.id === id);
|
||||
if (idx === -1) return false;
|
||||
this.policies[idx] = { ...this.policies[idx], ...policy };
|
||||
return true;
|
||||
}
|
||||
|
||||
async deletePolicy(id: string): Promise<boolean> {
|
||||
const idx = this.policies.findIndex((p) => p.id === id);
|
||||
if (idx === -1) return false;
|
||||
this.policies.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async findParticipantsByIdentifiers(
|
||||
identifiers: Array<{ type: string; value: string }>,
|
||||
options?: { tenantId?: string }
|
||||
): Promise<Participant[]> {
|
||||
const byKey = new Set<string>();
|
||||
for (const id of identifiers) {
|
||||
const matching = this.identifiers.filter(
|
||||
(i) =>
|
||||
i.identifier_type === id.type &&
|
||||
i.value === id.value &&
|
||||
(options?.tenantId == null ||
|
||||
this.participants.get(i.participantId)?.tenantId === options.tenantId)
|
||||
);
|
||||
for (const m of matching) byKey.add(m.participantId);
|
||||
}
|
||||
const out: Participant[] = [];
|
||||
for (const pid of byKey) {
|
||||
const p = this.participants.get(pid);
|
||||
if (p) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async getIdentifiersByParticipantId(participantId: string): Promise<Identifier[]> {
|
||||
return this.identifiers.filter((i) => i.participantId === participantId);
|
||||
}
|
||||
|
||||
async getEndpointsByParticipantId(
|
||||
participantId: string,
|
||||
options?: { protocol?: string; status?: string }
|
||||
): Promise<Endpoint[]> {
|
||||
let list = this.endpoints.filter((e) => e.participantId === participantId);
|
||||
if (options?.protocol) list = list.filter((e) => e.protocol === options.protocol);
|
||||
if (options?.status) list = list.filter((e) => e.status === options.status);
|
||||
return list;
|
||||
}
|
||||
|
||||
async getCapabilitiesByParticipantId(participantId: string): Promise<Capability[]> {
|
||||
return this.capabilities.filter((c) => c.participantId === participantId);
|
||||
}
|
||||
|
||||
async getCredentialsByParticipantId(participantId: string): Promise<CredentialRef[]> {
|
||||
return this.credentials.filter((c) => c.participantId === participantId);
|
||||
}
|
||||
|
||||
async getPoliciesByTenantId(tenantId: string): Promise<Policy[]> {
|
||||
return this.policies.filter((p) => p.tenantId === tenantId);
|
||||
}
|
||||
}
|
||||
38
packages/storage/src/port.ts
Normal file
38
packages/storage/src/port.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
Participant,
|
||||
Identifier,
|
||||
Endpoint,
|
||||
Capability,
|
||||
CredentialRef,
|
||||
Policy,
|
||||
} from "@as4-411/core";
|
||||
|
||||
/**
|
||||
* Directory store port: enough for the resolver to find participants and endpoints.
|
||||
* Implementations: in-memory, Postgres, SQLite.
|
||||
*/
|
||||
export interface DirectoryStore {
|
||||
/** Find participants that have any of the given identifier (type, value) pairs, optionally scoped by tenant */
|
||||
findParticipantsByIdentifiers(
|
||||
identifiers: Array<{ type: string; value: string }>,
|
||||
options?: { tenantId?: string }
|
||||
): Promise<Participant[]>;
|
||||
|
||||
/** Get all identifiers for a participant */
|
||||
getIdentifiersByParticipantId(participantId: string): Promise<Identifier[]>;
|
||||
|
||||
/** Get all endpoints for a participant, optionally filter by protocol */
|
||||
getEndpointsByParticipantId(
|
||||
participantId: string,
|
||||
options?: { protocol?: string; status?: string }
|
||||
): Promise<Endpoint[]>;
|
||||
|
||||
/** Get capabilities for a participant */
|
||||
getCapabilitiesByParticipantId(participantId: string): Promise<Capability[]>;
|
||||
|
||||
/** Get credential refs for a participant */
|
||||
getCredentialsByParticipantId(participantId: string): Promise<CredentialRef[]>;
|
||||
|
||||
/** Get policies for a tenant (for policy filter step) */
|
||||
getPoliciesByTenantId(tenantId: string): Promise<Policy[]>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user