feat: partner hierarchy core - models, gateways, contract invariants
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Foundation for Block 5 (F41-F45): the frontend surface the partner
provisioning API needs and has nothing for today. Core only - no UI yet, so
this is additive and changes no existing behavior.

- models/provisioning-node.model.ts    four fixed levels, node status rules,
                                       PaymentPointConfig, tree helpers
                                       (childLevelOf/ancestorsOf/childrenOf)
- models/partner-credential.model.ts   public-key-only credential shape
- services/*-gateway.interface.ts      hierarchy read/create/status/disable
                                       plus credential register/rotate/revoke
- services/*-api.gateway.ts            /api/partner/v1/... per contract §4, §6
- services/*-local.gateway.ts          in-memory, seeded four-level tree
- services/*-gateway.token.ts          environment.useMockData ? local : api

Two deliberate choices worth stating:

1. PartnerCredential has no private-key field at all, and cannot grow one by
   accident - the partner generates the keypair, we hold only the public
   half (§6.1). looksLikePrivateKey() exists purely so a UI can refuse a
   paste of the wrong half before it reaches a log.

2. The local gateway ENFORCES the §2 invariants rather than being a
   permissive stub: externalReference uniqueness per (companyId,
   environment, level), server-computed path, inherited immutable
   environment/companyId, cascading disable, and disabled-is-terminal. A
   mock that accepts what the real backend rejects would let the UI ship a
   flow the backend then refuses - the invariants are the point, not the
   data.

24 new tests covering exactly those invariants (cascade stops outside the
subtree, disabled cannot be reactivated, same reference allowed at a
different level, lookup scoped by environment, revoked credential cannot
rotate).

Also checked and deliberately NOT done: F40 (delete mock-data.interceptor +
src/assets/mock). Both are still load-bearing. mock-data.interceptor mocks
the LEGACY endpoints (/category, /items, /cart, /qr) still served by
ApiService and untouched by the gateway swap. src/assets/mock feeds
mock-backoffice-data.provider, mock-bootstrap.provider (gated by
useMockBootstrapOnLocal, currently true - it is what makes local dev render
at all), and widget-manifest.service's fallbackManifestUrl, which is not
mock-gated and runs in production. F40 belongs after the legacy-endpoint
retirement (Track N), not here.

Verified: 139/139 unit tests (24 new), arch:check clean, production build
succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-18 17:10:30 +04:00
parent ffd57f2d18
commit 7e7a015ff6
8 changed files with 787 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { Environment } from './provisioning-node.model';
/**
* Partner API credentials.
* Per docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §6, TRACK-S §4.1.
*
* The partner generates the keypair. We hold only the PUBLIC key - the
* private half is never transmitted to us, never accepted by any endpoint,
* never logged. There is deliberately no field for it in this model, so no
* UI or gateway can accidentally carry one.
*/
export type CredentialAlgorithm = 'ed25519' | 'rsa-pss-sha256';
/** `rotating` means a successor key is registered and both currently verify. */
export type CredentialStatus = 'active' | 'rotating' | 'revoked';
export interface PartnerCredential {
partnerId: string;
keyId: string;
/** Authority is this node's subtree - nothing above it, nothing beside it. */
scopeNodeId: string;
environment: Environment;
algorithm: CredentialAlgorithm;
/** PEM or base64 raw, per algorithm. Public half only. */
publicKey: string;
status: CredentialStatus;
createdAt: string;
expiresAt?: string;
}
export interface RegisterCredentialRequest {
scopeNodeId: string;
environment: Environment;
algorithm: CredentialAlgorithm;
publicKey: string;
}
/**
* Revocation is immediate and irreversible (§6.2); a revoked credential can
* never be rotated back into service. Rotation only applies to a live key.
*/
export function canRotate(credential: PartnerCredential): boolean {
return credential.status === 'active';
}
export function canRevoke(credential: PartnerCredential): boolean {
return credential.status !== 'revoked';
}
/**
* Guards against a private key being pasted into a public-key field. Not a
* validity check on the key itself - that is the backend's job - purely a
* "this is the wrong half" check, because the cost of getting it wrong is a
* partner's private key landing in our logs.
*/
export function looksLikePrivateKey(value: string): boolean {
return /PRIVATE KEY|BEGIN OPENSSH PRIVATE/i.test(value);
}

View File

@@ -0,0 +1,105 @@
import {
ProvisioningNode,
acceptsPayments,
ancestorsOf,
canTransitionTo,
childLevelOf,
childrenOf,
} from './provisioning-node.model';
function node(id: string, parentId: string | null, path: string[], displayName = id): ProvisioningNode {
return {
id,
level: 'store',
parentId,
companyId: 'cmp_1',
path,
environment: 'TEST',
status: 'active',
externalReference: `ext-${id}`,
displayName,
createdAt: '2026-08-18T00:00:00.000Z',
updatedAt: '2026-08-18T00:00:00.000Z',
};
}
describe('provisioning node model', () => {
describe('childLevelOf', () => {
it('walks the four fixed levels in order', () => {
expect(childLevelOf('company')).toBe('project');
expect(childLevelOf('project')).toBe('store');
expect(childLevelOf('store')).toBe('payment_point');
});
it('returns null at the leaf, so nothing can be nested below a payment point', () => {
expect(childLevelOf('payment_point')).toBeNull();
});
});
describe('acceptsPayments', () => {
it('is true only for active nodes', () => {
expect(acceptsPayments({ ...node('a', null, ['a']), status: 'active' })).toBeTrue();
expect(acceptsPayments({ ...node('a', null, ['a']), status: 'suspended' })).toBeFalse();
expect(acceptsPayments({ ...node('a', null, ['a']), status: 'disabled' })).toBeFalse();
});
});
describe('canTransitionTo', () => {
it('treats disabled as terminal', () => {
expect(canTransitionTo('disabled', 'active')).toBeFalse();
expect(canTransitionTo('disabled', 'suspended')).toBeFalse();
});
it('allows suspend and reactivate', () => {
expect(canTransitionTo('active', 'suspended')).toBeTrue();
expect(canTransitionTo('suspended', 'active')).toBeTrue();
});
it('rejects a no-op transition', () => {
expect(canTransitionTo('active', 'active')).toBeFalse();
});
});
describe('ancestorsOf', () => {
it('resolves the root-to-leaf chain in path order', () => {
const all = [
node('cmp', null, ['cmp']),
node('prj', 'cmp', ['cmp', 'prj']),
node('str', 'prj', ['cmp', 'prj', 'str']),
];
const chain = ancestorsOf(all[2], all);
expect(chain.map(n => n.id)).toEqual(['cmp', 'prj', 'str']);
});
it('skips ids the caller did not supply rather than returning holes', () => {
const leaf = node('str', 'prj', ['cmp', 'prj', 'str']);
const chain = ancestorsOf(leaf, [leaf]);
expect(chain.map(n => n.id)).toEqual(['str']);
});
});
describe('childrenOf', () => {
it('returns direct children only, not deeper descendants', () => {
const all = [
node('cmp', null, ['cmp']),
node('prj', 'cmp', ['cmp', 'prj']),
node('str', 'prj', ['cmp', 'prj', 'str']),
];
expect(childrenOf('cmp', all).map(n => n.id)).toEqual(['prj']);
});
it('orders by displayName so the tree renders stably', () => {
const all = [
node('a', 'root', ['root', 'a'], 'Zebra'),
node('b', 'root', ['root', 'b'], 'Alpha'),
];
expect(childrenOf('root', all).map(n => n.displayName)).toEqual(['Alpha', 'Zebra']);
});
});
});

View File

@@ -0,0 +1,104 @@
/**
* Partner-provisioned merchant hierarchy.
* Per docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §2, §10.
*
* Deliberately generic: no partner name appears in any type, field or status
* value here. Everything partner-varying (level naming, which levels a given
* partner uses) lives in PartnerProfile, not in this model.
*/
/** Four fixed levels. Middle levels are optional per partner, depth is not partner-defined. */
export type NodeLevel = 'company' | 'project' | 'store' | 'payment_point';
export type Environment = 'TEST' | 'LIVE';
/** `disabled` is terminal - it never returns to any other status (§2). */
export type NodeStatus = 'active' | 'suspended' | 'disabled';
export interface ProvisioningNode {
id: string;
level: NodeLevel;
/** null only for level 'company'. */
parentId: string | null;
/** Denormalized root, present on every node. */
companyId: string;
/** Ordered ancestor ids, root first, inclusive of self. Server-computed, never client-sent. */
path: string[];
environment: Environment;
status: NodeStatus;
/** The partner's own id for this node. Unique per (companyId, environment, level). */
externalReference: string;
displayName: string;
createdAt: string;
updatedAt: string;
}
/**
* A payment_point is an acceptance channel - one payment method at one store.
* Not a physical till, not a settlement account (§10.1).
*/
export interface PaymentPointConfig {
method: PaymentMethod;
/** ISO 4217 subset this channel accepts. */
currencies: string[];
/**
* Opaque provider-side binding, set only by financial enablement.
* Its absence is what makes a registered payment point not-yet-live:
* creating a node never enables real money (§2 invariant 6).
*/
providerAccountRef?: string;
}
/** Both ship today (src/app/pages/cart/cart.component.ts). Extensible. */
export type PaymentMethod = 'qr' | 'card';
export interface CreateNodeRequest {
externalReference: string;
displayName: string;
/** Opaque to us, echoed back, never interpreted. */
metadata?: Record<string, string>;
}
/** The child level that may be created under a given level, or null at the leaf. */
export function childLevelOf(level: NodeLevel): NodeLevel | null {
switch (level) {
case 'company': return 'project';
case 'project': return 'store';
case 'store': return 'payment_point';
case 'payment_point': return null;
}
}
/** True when a node can currently accept payments (§2 status table). */
export function acceptsPayments(node: ProvisioningNode): boolean {
return node.status === 'active';
}
/**
* `disabled` is terminal and cascades; `suspended` is reversible.
* Encoded here so UI and any future client-side guard agree on one rule.
*/
export function canTransitionTo(from: NodeStatus, to: NodeStatus): boolean {
if (from === 'disabled') {
return false;
}
if (from === to) {
return false;
}
return to === 'active' || to === 'suspended' || to === 'disabled';
}
/** Builds the ordered root-to-leaf chain for a node from a flat set. */
export function ancestorsOf(node: ProvisioningNode, all: readonly ProvisioningNode[]): ProvisioningNode[] {
const byId = new Map(all.map(n => [n.id, n]));
return node.path
.map(id => byId.get(id))
.filter((n): n is ProvisioningNode => n !== undefined);
}
/** Direct children of a node, in stable displayName order. */
export function childrenOf(nodeId: string, all: readonly ProvisioningNode[]): ProvisioningNode[] {
return all
.filter(n => n.parentId === nodeId)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
}

View File

@@ -0,0 +1,108 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { of } from 'rxjs';
import { PartnerCredential, RegisterCredentialRequest } from '../models/partner-credential.model';
import { CreateNodeRequest, Environment, NodeStatus, ProvisioningNode } from '../models/provisioning-node.model';
import { PartnerHierarchyGateway } from './partner-hierarchy-gateway.interface';
/**
* Contract: docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §4, §6.
*
* Note this is the INBOUND partner API viewed from our own admin side - the
* same endpoints partners call, used here so our backoffice sees exactly the
* hierarchy a partner sees, rather than a parallel internal view that could
* drift from it.
*/
@Injectable({ providedIn: 'root' })
export class PartnerHierarchyApiGateway implements PartnerHierarchyGateway {
private readonly http = inject(HttpClient);
loadHierarchy(companyId: string, environment: Environment): Observable<ProvisioningNode[]> {
const params = new HttpParams().set('environment', environment);
return this.http.get<ProvisioningNode[]>(
`/api/partner/v1/companies/${encodeURIComponent(companyId)}/hierarchy`,
{ params },
);
}
loadNode(nodeId: string): Observable<ProvisioningNode | null> {
return this.http.get<ProvisioningNode | null>(`/api/partner/v1/nodes/${encodeURIComponent(nodeId)}`);
}
lookupByExternalReference(
externalReference: string,
environment: Environment,
): Observable<ProvisioningNode | null> {
const params = new HttpParams()
.set('externalReference', externalReference)
.set('environment', environment);
// §4.2: an unmatched reference is 404, never a partial match. Translated
// to null so callers branch on a value rather than catching an error.
return this.http
.get<ProvisioningNode>('/api/partner/v1/nodes/lookup', { params })
.pipe(catchError(() => of(null)));
}
createProject(companyId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
return this.http.post<ProvisioningNode>(
`/api/partner/v1/companies/${encodeURIComponent(companyId)}/projects`,
request,
);
}
createStore(projectId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
return this.http.post<ProvisioningNode>(
`/api/partner/v1/projects/${encodeURIComponent(projectId)}/stores`,
request,
);
}
createPaymentPoint(storeId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
return this.http.post<ProvisioningNode>(
`/api/partner/v1/stores/${encodeURIComponent(storeId)}/payment-points`,
request,
);
}
setStatus(
nodeId: string,
status: Extract<NodeStatus, 'active' | 'suspended'>,
reason?: string,
): Observable<ProvisioningNode> {
return this.http.patch<ProvisioningNode>(
`/api/partner/v1/nodes/${encodeURIComponent(nodeId)}/status`,
reason ? { status, reason } : { status },
);
}
disable(nodeId: string): Observable<ProvisioningNode> {
return this.http.post<ProvisioningNode>(
`/api/partner/v1/nodes/${encodeURIComponent(nodeId)}/disable`,
{},
);
}
loadCredentials(companyId: string): Observable<PartnerCredential[]> {
const params = new HttpParams().set('companyId', companyId);
return this.http.get<PartnerCredential[]>('/api/partner/v1/credentials', { params });
}
registerCredential(request: RegisterCredentialRequest): Observable<PartnerCredential> {
return this.http.post<PartnerCredential>('/api/partner/v1/credentials', request);
}
rotateCredential(keyId: string, newPublicKey: string): Observable<PartnerCredential> {
return this.http.post<PartnerCredential>(
`/api/partner/v1/credentials/${encodeURIComponent(keyId)}/rotate`,
{ publicKey: newPublicKey },
);
}
revokeCredential(keyId: string): Observable<void> {
return this.http
.delete(`/api/partner/v1/credentials/${encodeURIComponent(keyId)}`)
.pipe(map(() => undefined));
}
}

View File

@@ -0,0 +1,32 @@
import { Observable } from 'rxjs';
import { PartnerCredential, RegisterCredentialRequest } from '../models/partner-credential.model';
import { CreateNodeRequest, Environment, NodeStatus, ProvisioningNode } from '../models/provisioning-node.model';
/** Per docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §4, §6. */
export interface PartnerHierarchyGateway {
/** Full tree for a company, with current statuses (§4.2). */
loadHierarchy(companyId: string, environment: Environment): Observable<ProvisioningNode[]>;
loadNode(nodeId: string): Observable<ProvisioningNode | null>;
/** Resolve by the partner's own reference (§4.2). Null when unmatched - never a fuzzy match. */
lookupByExternalReference(
externalReference: string,
environment: Environment,
): Observable<ProvisioningNode | null>;
createProject(companyId: string, request: CreateNodeRequest): Observable<ProvisioningNode>;
createStore(projectId: string, request: CreateNodeRequest): Observable<ProvisioningNode>;
createPaymentPoint(storeId: string, request: CreateNodeRequest): Observable<ProvisioningNode>;
/** 'active' | 'suspended' only - disabling is a separate, terminal call. */
setStatus(nodeId: string, status: Extract<NodeStatus, 'active' | 'suspended'>, reason?: string): Observable<ProvisioningNode>;
/** Terminal and cascading (§2). */
disable(nodeId: string): Observable<ProvisioningNode>;
loadCredentials(companyId: string): Observable<PartnerCredential[]>;
registerCredential(request: RegisterCredentialRequest): Observable<PartnerCredential>;
rotateCredential(keyId: string, newPublicKey: string): Observable<PartnerCredential>;
revokeCredential(keyId: string): Observable<void>;
}

View File

@@ -0,0 +1,11 @@
import { InjectionToken, inject } from '@angular/core';
import { environment } from '../../../../environments/environment';
import { PartnerHierarchyGateway } from './partner-hierarchy-gateway.interface';
import { PartnerHierarchyLocalGateway } from './partner-hierarchy-local.gateway';
import { PartnerHierarchyApiGateway } from './partner-hierarchy-api.gateway';
/** Swap point for docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §4, §6. */
export const PARTNER_HIERARCHY_GATEWAY = new InjectionToken<PartnerHierarchyGateway>('PARTNER_HIERARCHY_GATEWAY', {
providedIn: 'root',
factory: () => (environment.useMockData ? inject(PartnerHierarchyLocalGateway) : inject(PartnerHierarchyApiGateway)),
});

View File

@@ -0,0 +1,159 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { PartnerHierarchyLocalGateway } from './partner-hierarchy-local.gateway';
import { ProvisioningNode } from '../models/provisioning-node.model';
/**
* These assert the §2 invariants from
* docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md, not just that the mock
* returns something. A mock that accepts what the real backend rejects would
* let the UI ship a flow the backend then refuses.
*/
describe('PartnerHierarchyLocalGateway (contract invariants)', () => {
let gateway: PartnerHierarchyLocalGateway;
beforeEach(() => {
TestBed.configureTestingModule({});
gateway = TestBed.inject(PartnerHierarchyLocalGateway);
});
it('seeds a full four-level hierarchy', async () => {
const nodes = await firstValueFrom(gateway.loadHierarchy('cmp_1', 'TEST'));
expect([...nodes].map(n => String(n.level)).sort()).toEqual(
['company', 'payment_point', 'payment_point', 'project', 'store'],
);
});
it('computes path server-side, never trusting a client-supplied one', async () => {
const created = await firstValueFrom(
gateway.createStore('prj_1', { externalReference: 'new-store', displayName: 'New Store' }),
);
expect(created.path).toEqual(['cmp_1', 'prj_1', created.id]);
expect(created.path[created.path.length - 1]).toBe(created.id);
});
it('inherits companyId and environment from the parent', async () => {
const created = await firstValueFrom(
gateway.createPaymentPoint('str_1', { externalReference: 'new-pp', displayName: 'New PP' }),
);
expect(created.companyId).toBe('cmp_1');
expect(created.environment).toBe('TEST');
});
it('rejects a duplicate externalReference at the same level', async () => {
await firstValueFrom(gateway.createStore('prj_1', { externalReference: 'dup', displayName: 'A' }));
await expectAsync(
firstValueFrom(gateway.createStore('prj_1', { externalReference: 'dup', displayName: 'B' })),
).toBeRejectedWithError(/already in use/);
});
it('allows the same externalReference at a different level', async () => {
await firstValueFrom(gateway.createStore('prj_1', { externalReference: 'shared', displayName: 'Store' }));
// Uniqueness is scoped per (companyId, environment, level) - a payment
// point may reuse a store's reference without collision.
const created = await firstValueFrom(
gateway.createPaymentPoint('str_1', { externalReference: 'shared', displayName: 'PP' }),
);
expect(created.externalReference).toBe('shared');
});
it('rejects creating under a parent that does not exist', async () => {
await expectAsync(
firstValueFrom(gateway.createStore('nope', { externalReference: 'x', displayName: 'X' })),
).toBeRejectedWithError(/Parent not found/);
});
it('cascades disable to every descendant', async () => {
await firstValueFrom(gateway.disable('str_1'));
const nodes = await firstValueFrom(gateway.loadHierarchy('cmp_1', 'TEST'));
const store = find(nodes, 'str_1');
const paymentPoints = nodes.filter(n => n.level === 'payment_point');
expect(store.status).toBe('disabled');
expect(paymentPoints.every(n => n.status === 'disabled')).toBeTrue();
});
it('leaves nodes outside the disabled subtree untouched', async () => {
await firstValueFrom(gateway.disable('str_1'));
const nodes = await firstValueFrom(gateway.loadHierarchy('cmp_1', 'TEST'));
expect(find(nodes, 'cmp_1').status).toBe('active');
expect(find(nodes, 'prj_1').status).toBe('active');
});
it('treats disabled as terminal - no status change afterwards', async () => {
await firstValueFrom(gateway.disable('str_1'));
await expectAsync(firstValueFrom(gateway.setStatus('str_1', 'active'))).toBeRejectedWithError(
/disabled node cannot change status/,
);
});
it('allows suspend then reactivate', async () => {
await firstValueFrom(gateway.setStatus('str_1', 'suspended'));
const reactivated = await firstValueFrom(gateway.setStatus('str_1', 'active'));
expect(reactivated.status).toBe('active');
});
it('returns null rather than a fuzzy match for an unknown externalReference', async () => {
const found = await firstValueFrom(gateway.lookupByExternalReference('does-not-exist', 'TEST'));
expect(found).toBeNull();
});
it('scopes lookup by environment', async () => {
// Seed data is TEST-only; the same reference must not resolve under LIVE.
const found = await firstValueFrom(gateway.lookupByExternalReference('demo-store-1', 'LIVE'));
expect(found).toBeNull();
});
it('never returns a revoked credential to rotation', async () => {
const credential = await firstValueFrom(
gateway.registerCredential({
scopeNodeId: 'prj_1',
environment: 'TEST',
algorithm: 'ed25519',
publicKey: 'ssh-ed25519 AAAA...',
}),
);
await firstValueFrom(gateway.revokeCredential(credential.keyId));
await expectAsync(
firstValueFrom(gateway.rotateCredential(credential.keyId, 'ssh-ed25519 BBBB...')),
).toBeRejectedWithError(/revoked credential cannot be rotated/);
});
it('scopes credentials to the requested company', async () => {
await firstValueFrom(
gateway.registerCredential({
scopeNodeId: 'prj_1',
environment: 'TEST',
algorithm: 'ed25519',
publicKey: 'ssh-ed25519 AAAA...',
}),
);
const forCompany = await firstValueFrom(gateway.loadCredentials('cmp_1'));
const forOther = await firstValueFrom(gateway.loadCredentials('cmp_other'));
expect(forCompany.length).toBe(1);
expect(forOther.length).toBe(0);
});
});
function find(nodes: ProvisioningNode[], id: string): ProvisioningNode {
const found = nodes.find(n => n.id === id);
if (!found) {
throw new Error(`expected node ${id} in hierarchy`);
}
return found;
}

View File

@@ -0,0 +1,209 @@
import { Injectable, signal } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { PartnerCredential, RegisterCredentialRequest } from '../models/partner-credential.model';
import {
CreateNodeRequest,
Environment,
NodeLevel,
NodeStatus,
ProvisioningNode,
} from '../models/provisioning-node.model';
import { PartnerHierarchyGateway } from './partner-hierarchy-gateway.interface';
/**
* In-memory hierarchy for local dev, until the partner API ships.
*
* Enforces the invariants the real backend enforces (§2), rather than being a
* permissive stub: externalReference uniqueness, terminal `disabled`,
* cascading disable, no re-parenting. A mock that accepts what the real
* backend rejects teaches the UI the wrong lesson.
*/
const NOW = '2026-08-18T00:00:00.000Z';
function node(
id: string,
level: NodeLevel,
parentId: string | null,
companyId: string,
path: string[],
displayName: string,
externalReference: string,
status: NodeStatus = 'active',
): ProvisioningNode {
return {
id, level, parentId, companyId, path, displayName, externalReference,
environment: 'TEST', status, createdAt: NOW, updatedAt: NOW,
};
}
const SEED: ProvisioningNode[] = [
node('cmp_1', 'company', null, 'cmp_1', ['cmp_1'], 'Demo Company', 'demo-co'),
node('prj_1', 'project', 'cmp_1', 'cmp_1', ['cmp_1', 'prj_1'], 'marketplaces', 'demo-marketplaces'),
node('str_1', 'store', 'prj_1', 'cmp_1', ['cmp_1', 'prj_1', 'str_1'], 'Demo Storefront', 'demo-store-1'),
node('pp_1', 'payment_point', 'str_1', 'cmp_1', ['cmp_1', 'prj_1', 'str_1', 'pp_1'], 'QR acceptance', 'demo-pp-qr'),
node('pp_2', 'payment_point', 'str_1', 'cmp_1', ['cmp_1', 'prj_1', 'str_1', 'pp_2'], 'Card acceptance', 'demo-pp-card'),
];
@Injectable({ providedIn: 'root' })
export class PartnerHierarchyLocalGateway implements PartnerHierarchyGateway {
private readonly nodes = signal<ProvisioningNode[]>([...SEED]);
private readonly credentials = signal<PartnerCredential[]>([]);
private sequence = 1;
loadHierarchy(companyId: string, environment: Environment): Observable<ProvisioningNode[]> {
return of(this.nodes().filter(n => n.companyId === companyId && n.environment === environment));
}
loadNode(nodeId: string): Observable<ProvisioningNode | null> {
return of(this.nodes().find(n => n.id === nodeId) ?? null);
}
lookupByExternalReference(
externalReference: string,
environment: Environment,
): Observable<ProvisioningNode | null> {
return of(
this.nodes().find(
n => n.externalReference === externalReference && n.environment === environment,
) ?? null,
);
}
createProject(companyId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
return this.create(companyId, 'project', request);
}
createStore(projectId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
return this.create(projectId, 'store', request);
}
createPaymentPoint(storeId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
return this.create(storeId, 'payment_point', request);
}
setStatus(
nodeId: string,
status: Extract<NodeStatus, 'active' | 'suspended'>,
): Observable<ProvisioningNode> {
const existing = this.nodes().find(n => n.id === nodeId);
if (!existing) {
return throwError(() => new Error(`Node not found: ${nodeId}`));
}
if (existing.status === 'disabled') {
// §2: disabled is terminal. The mock must refuse this too, or the UI
// learns a transition the real backend will reject.
return throwError(() => new Error('A disabled node cannot change status.'));
}
const updated = { ...existing, status, updatedAt: new Date().toISOString() };
this.nodes.update(all => all.map(n => (n.id === nodeId ? updated : n)));
return of(updated);
}
disable(nodeId: string): Observable<ProvisioningNode> {
const existing = this.nodes().find(n => n.id === nodeId);
if (!existing) {
return throwError(() => new Error(`Node not found: ${nodeId}`));
}
// §2: disabling cascades to every descendant, atomically.
const updatedAt = new Date().toISOString();
this.nodes.update(all =>
all.map(n =>
n.id === nodeId || n.path.includes(nodeId)
? { ...n, status: 'disabled' as const, updatedAt }
: n,
),
);
return of({ ...existing, status: 'disabled' as const, updatedAt });
}
loadCredentials(companyId: string): Observable<PartnerCredential[]> {
const companyNodeIds = new Set(
this.nodes().filter(n => n.companyId === companyId).map(n => n.id),
);
return of(this.credentials().filter(c => companyNodeIds.has(c.scopeNodeId)));
}
registerCredential(request: RegisterCredentialRequest): Observable<PartnerCredential> {
const credential: PartnerCredential = {
partnerId: 'ptr_local',
keyId: `key_${this.sequence++}`,
scopeNodeId: request.scopeNodeId,
environment: request.environment,
algorithm: request.algorithm,
publicKey: request.publicKey,
status: 'active',
createdAt: new Date().toISOString(),
};
this.credentials.update(all => [...all, credential]);
return of(credential);
}
rotateCredential(keyId: string, newPublicKey: string): Observable<PartnerCredential> {
const existing = this.credentials().find(c => c.keyId === keyId);
if (!existing) {
return throwError(() => new Error(`Credential not found: ${keyId}`));
}
if (existing.status === 'revoked') {
return throwError(() => new Error('A revoked credential cannot be rotated.'));
}
const updated: PartnerCredential = { ...existing, publicKey: newPublicKey, status: 'rotating' };
this.credentials.update(all => all.map(c => (c.keyId === keyId ? updated : c)));
return of(updated);
}
revokeCredential(keyId: string): Observable<void> {
this.credentials.update(all =>
all.map(c => (c.keyId === keyId ? { ...c, status: 'revoked' as const } : c)),
);
return of(undefined);
}
private create(
parentId: string,
level: NodeLevel,
request: CreateNodeRequest,
): Observable<ProvisioningNode> {
const parent = this.nodes().find(n => n.id === parentId);
if (!parent) {
return throwError(() => new Error(`Parent not found: ${parentId}`));
}
// §2 invariant 3: externalReference is unique per (companyId, environment, level).
const collision = this.nodes().some(
n =>
n.companyId === parent.companyId &&
n.environment === parent.environment &&
n.level === level &&
n.externalReference === request.externalReference,
);
if (collision) {
return throwError(
() => new Error(`externalReference already in use at this level: ${request.externalReference}`),
);
}
const id = `${level}_${this.sequence++}`;
const created: ProvisioningNode = {
id,
level,
parentId,
companyId: parent.companyId,
// §2 invariant 4: path is server-computed, never client-supplied.
path: [...parent.path, id],
// §2 invariant 2: environment is inherited and immutable.
environment: parent.environment,
status: 'active',
externalReference: request.externalReference,
displayName: request.displayName,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this.nodes.update(all => [...all, created]);
return of(created);
}
}