167 lines
4.9 KiB
TypeScript
167 lines
4.9 KiB
TypeScript
import { z } from 'zod';
|
|
import type { CredentialEstateRegistry } from './credential-provider.dto.js';
|
|
import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js';
|
|
|
|
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
|
|
|
const hostSchema = z
|
|
.object({
|
|
host: z.string().regex(HOST),
|
|
provider: z.literal('gitea'),
|
|
apiBaseUrl: z.string(),
|
|
tokenPrefix: z.string().regex(NAME),
|
|
})
|
|
.strict();
|
|
|
|
const estateSchema = z
|
|
.object({
|
|
name: z.string().regex(NAME),
|
|
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
|
|
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
|
|
hosts: z.array(hostSchema).min(1),
|
|
})
|
|
.strict();
|
|
|
|
const registrySchema = z
|
|
.object({
|
|
version: z.literal(1),
|
|
estates: z.array(estateSchema).min(1),
|
|
})
|
|
.strict();
|
|
|
|
export class CredentialEstateRegistryError extends Error {
|
|
constructor(
|
|
public readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(`Credential estate registry rejected: code=${code} ${message}`);
|
|
this.name = 'CredentialEstateRegistryError';
|
|
}
|
|
}
|
|
|
|
function validateApiUrl(host: CredentialHostConfigDto): void {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(host.apiBaseUrl);
|
|
} catch (error: unknown) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new CredentialEstateRegistryError('invalid-api-url', detail);
|
|
}
|
|
if (
|
|
url.protocol !== 'https:' ||
|
|
url.username !== '' ||
|
|
url.password !== '' ||
|
|
url.pathname !== '/' ||
|
|
url.search !== '' ||
|
|
url.hash !== ''
|
|
) {
|
|
throw new CredentialEstateRegistryError(
|
|
'invalid-api-url',
|
|
'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment',
|
|
);
|
|
}
|
|
if (url.hostname !== host.host) {
|
|
throw new CredentialEstateRegistryError(
|
|
'api-host-mismatch',
|
|
'provider API URL hostname does not equal the declared host',
|
|
);
|
|
}
|
|
}
|
|
|
|
export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry {
|
|
private readonly estates: ReadonlyMap<string, CredentialEstateConfigDto>;
|
|
|
|
constructor(estates: readonly CredentialEstateConfigDto[]) {
|
|
this.estates = new Map(
|
|
estates.map(
|
|
(estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [
|
|
estate.name,
|
|
estate,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
matches(estate: string, host: string): boolean {
|
|
return this.resolve(estate, host) !== undefined;
|
|
}
|
|
|
|
resolve(estate: string, host: string): CredentialHostConfigDto | undefined {
|
|
return this.estates
|
|
.get(estate)
|
|
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
|
|
}
|
|
|
|
resolveByHost(
|
|
host: string,
|
|
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
|
|
for (const [estate, config] of this.estates) {
|
|
const match = config.hosts.find(
|
|
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
|
|
);
|
|
if (match !== undefined) return { estate, host: match };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
inventoryAuthority(estate: string): string {
|
|
const identity = this.estates.get(estate)?.inventoryAuthorityIdentity;
|
|
if (identity === undefined) {
|
|
throw new CredentialEstateRegistryError(
|
|
'inventory-authority-missing',
|
|
`estate ${estate} has no delegated inventory authority identity`,
|
|
);
|
|
}
|
|
return identity;
|
|
}
|
|
|
|
readOnlyControl(estate: string): string {
|
|
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
|
if (identity === undefined) {
|
|
throw new CredentialEstateRegistryError(
|
|
'read-only-control-missing',
|
|
`estate ${estate} has no provider-confirmed read-only control identity`,
|
|
);
|
|
}
|
|
return identity;
|
|
}
|
|
}
|
|
|
|
export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry {
|
|
let raw: unknown;
|
|
try {
|
|
raw = JSON.parse(source);
|
|
} catch (error: unknown) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new CredentialEstateRegistryError('invalid-json', detail);
|
|
}
|
|
|
|
const parsed = registrySchema.safeParse(raw);
|
|
if (!parsed.success) {
|
|
throw new CredentialEstateRegistryError(
|
|
'invalid-schema',
|
|
parsed.error.issues[0]?.message ?? 'invalid',
|
|
);
|
|
}
|
|
|
|
const estateNames = new Set<string>();
|
|
const hostNames = new Set<string>();
|
|
for (const estate of parsed.data.estates) {
|
|
if (estateNames.has(estate.name)) {
|
|
throw new CredentialEstateRegistryError('duplicate-estate', estate.name);
|
|
}
|
|
estateNames.add(estate.name);
|
|
for (const host of estate.hosts) {
|
|
validateApiUrl(host);
|
|
if (hostNames.has(host.host)) {
|
|
throw new CredentialEstateRegistryError('duplicate-host', host.host);
|
|
}
|
|
hostNames.add(host.host);
|
|
}
|
|
}
|
|
|
|
return new ParsedCredentialEstateRegistry(parsed.data.estates);
|
|
}
|