Files
clean-architecture-frontend…/src/contracts/read-only-registry.ts
T
DongHyeonkaandClaude Opus 5 f4bfdf0365 fix: close the live V3 authority findings from the adapter re-review
LIVE-01. A credential collaborator that returns UNAVAILABLE, throws, rejects
or answers off-contract is an outage of the auth integration, not evidence
about the user's session. Each of those now closes as AUTH_INTEGRATION_FAILURE
with zero fetches, so the composition root's logout path stays reserved for a
genuinely absent session. The synchronous and asynchronous failure sites share
one classifier.

LIVE-02 / LIVE-03. Object.freeze(new Map(...)) freezes the wrapper, not the
backing store, so an exported registry could still be cleared or replaced after
composition. Both the installed REST auth profile registry and the composed
HTTP/event lookups are now read facades over private stores, and every composed
row is an exact own-data snapshot that rejects accessors, inherited and
symbol-keyed fields.

LIVE-04. The total deadline now bounds the physical waits rather than being
checked between them: dispatch and response admission race the attempt signal,
the bounded reader takes that signal, and an abandoned operation is still
observed once so a late native rejection cannot surface unhandled. A body that
completes after the deadline or the caller owns the execution is no longer
admitted; a stale generation keeps its more specific SCOPE_FENCED verdict.

LIVE-05. DEADLINE is no longer treated as a caller-owned cancellation, so a
timeout reaches api.request.failed exactly once while caller, route, scope and
shutdown aborts stay excluded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:43:59 +09:00

81 lines
3.1 KiB
TypeScript

/**
* LIVE-02 / LIVE-03. A composed registry is authority, not data.
*
* `Object.freeze(new Map(...))` only freezes the wrapper object: `set`,
* `delete` and `clear` still reach the backing store, so anything holding the
* exported singleton can empty a validated registry after composition and
* silently change what every later request resolves. The fix is structural —
* the store stays private in a closure and only read operations are exported.
*
* The facade is deliberately *not* a `Map` instance, so borrowing a mutator
* (`Map.prototype.clear.call(facade)`) fails on the missing internal slot
* rather than succeeding.
*/
export type ReadOnlyRegistry<Key, Value> = Readonly<{
get(key: Key): Value | undefined;
has(key: Key): boolean;
keys(): IterableIterator<Key>;
values(): IterableIterator<Value>;
entries(): IterableIterator<readonly [Key, Value]>;
forEach(visit: (value: Value, key: Key) => void): void;
readonly size: number;
[Symbol.iterator](): IterableIterator<readonly [Key, Value]>;
}>;
export function createReadOnlyRegistry<Key, Value>(
entries: Iterable<readonly [Key, Value]>,
): ReadOnlyRegistry<Key, Value> {
const store = new Map<Key, Value>(entries as Iterable<[Key, Value]>);
const facade = {
get: (key: Key) => store.get(key),
has: (key: Key) => store.has(key),
keys: () => store.keys(),
values: () => store.values(),
entries: () => store.entries(),
forEach: (visit: (value: Value, key: Key) => void) => {
for (const [key, value] of store) visit(value, key);
},
get size() {
return store.size;
},
[Symbol.iterator]: () => store.entries(),
};
return Object.freeze(facade) as ReadOnlyRegistry<Key, Value>;
}
/**
* Rejects anything that is not an exact own-data record over `allowedKeys`.
*
* A validated row must survive the validation: an accessor re-runs on every
* later read, an inherited field can be replaced through the prototype, and a
* symbol-keyed field escapes a name-based sweep entirely. Only own data
* descriptors are copied, and the result is frozen.
*/
export function exactOwnDataSnapshot<Shape extends object>(
source: unknown,
allowedKeys: readonly (keyof Shape & string)[],
requiredKeys: readonly (keyof Shape & string)[],
onViolation: (detail: string) => never,
): Readonly<Shape> {
if (source === null || typeof source !== "object") {
onViolation("object required");
}
if (Object.getOwnPropertySymbols(source).length > 0) {
onViolation("symbol-keyed field");
}
const allowed = new Set<string>(allowedKeys);
const snapshot: Record<string, unknown> = {};
for (const key of Object.getOwnPropertyNames(source)) {
if (!allowed.has(key)) onViolation(`unexpected field ${key}`);
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor || !("value" in descriptor)) {
onViolation(`accessor field ${key}`);
}
snapshot[key] = descriptor.value;
}
for (const key of requiredKeys) {
if (!Object.hasOwn(snapshot, key)) onViolation(`missing field ${key}`);
}
return Object.freeze(snapshot) as Readonly<Shape>;
}