From 340d9c04bf1b4609a63082b10f4595a7f4d2e256 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 11 Jul 2026 02:09:15 +0200 Subject: [PATCH] refactor: remove unused capability kernel --- src/main.activateView.test.ts | 47 - src/main.ts | 9 - src/network/BufferedNetworkBody.ts | 92 -- src/network/CapabilityScopedTransport.test.ts | 794 ---------------- src/network/CapabilityScopedTransport.ts | 539 ----------- .../DesktopPinnedNetworkAdapter.test.ts | 428 --------- src/network/DesktopPinnedNetworkAdapter.ts | 265 ------ src/network/NetworkCapability.test.ts | 383 -------- src/network/NetworkCapability.ts | 313 ------- src/network/NetworkErrors.test.ts | 141 --- src/network/NetworkErrors.ts | 180 ---- src/network/NetworkScheduler.test.ts | 855 ------------------ src/network/NetworkScheduler.ts | 631 ------------- src/network/PinnedNetworkHop.ts | 251 ----- src/parser/feedDocumentParser.ts | 6 +- src/parser/feedDocumentSource.ts | 7 +- src/parser/feedParser.ts | 4 +- src/persistence/v3/__tests__/fixtures.ts | 111 --- src/persistence/v3/canonicalEncoding.ts | 25 - src/persistence/v3/collections.ts | 217 ----- src/persistence/v3/entities.ts | 215 ----- .../v3/libraryModel.hostile.test.ts | 276 ------ src/persistence/v3/libraryModel.test.ts | 129 --- src/persistence/v3/libraryModel.ts | 44 - src/persistence/v3/model.ts | 129 --- src/persistence/v3/referentialIntegrity.ts | 82 -- src/persistence/v3/scalars.ts | 236 ----- src/persistence/v3/validateLibrary.ts | 103 --- src/security/base64Url.test.ts | 37 - src/security/base64Url.ts | 50 - src/security/capabilityVaultKey.test.ts | 157 ---- src/security/capabilityVaultKey.ts | 347 ------- src/security/capabilityVaultMetadata.test.ts | 64 -- src/security/capabilityVaultMetadata.ts | 90 -- src/security/feedCapabilityReferences.test.ts | 63 -- src/security/feedCapabilityReferences.ts | 81 -- src/security/feedCapabilityStorage.ts | 511 ----------- src/security/resourceHandles.test.ts | 89 -- src/security/resourceHandles.ts | 92 -- src/security/sealedCapabilityRecord.test.ts | 304 ------- src/security/sealedCapabilityRecord.ts | 578 ------------ src/security/strictData.test.ts | 107 --- src/security/strictData.ts | 117 --- src/security/targetEnvelopes.test.ts | 327 ------- src/security/targetEnvelopes.ts | 316 ------- src/services/FeedCapabilityBroker.test.ts | 664 -------------- src/services/FeedCapabilityBroker.ts | 439 --------- src/services/FeedCapabilityRepository.test.ts | 605 ------------- src/services/FeedCapabilityRepository.ts | 574 ------------ src/services/PodNotesNetworkServices.test.ts | 187 ---- src/services/PodNotesNetworkServices.ts | 98 -- 51 files changed, 7 insertions(+), 12402 deletions(-) delete mode 100644 src/network/BufferedNetworkBody.ts delete mode 100644 src/network/CapabilityScopedTransport.test.ts delete mode 100644 src/network/CapabilityScopedTransport.ts delete mode 100644 src/network/DesktopPinnedNetworkAdapter.test.ts delete mode 100644 src/network/DesktopPinnedNetworkAdapter.ts delete mode 100644 src/network/NetworkCapability.test.ts delete mode 100644 src/network/NetworkCapability.ts delete mode 100644 src/network/NetworkErrors.test.ts delete mode 100644 src/network/NetworkErrors.ts delete mode 100644 src/network/NetworkScheduler.test.ts delete mode 100644 src/network/NetworkScheduler.ts delete mode 100644 src/network/PinnedNetworkHop.ts delete mode 100644 src/persistence/v3/__tests__/fixtures.ts delete mode 100644 src/persistence/v3/canonicalEncoding.ts delete mode 100644 src/persistence/v3/collections.ts delete mode 100644 src/persistence/v3/entities.ts delete mode 100644 src/persistence/v3/libraryModel.hostile.test.ts delete mode 100644 src/persistence/v3/libraryModel.test.ts delete mode 100644 src/persistence/v3/libraryModel.ts delete mode 100644 src/persistence/v3/model.ts delete mode 100644 src/persistence/v3/referentialIntegrity.ts delete mode 100644 src/persistence/v3/scalars.ts delete mode 100644 src/persistence/v3/validateLibrary.ts delete mode 100644 src/security/base64Url.test.ts delete mode 100644 src/security/base64Url.ts delete mode 100644 src/security/capabilityVaultKey.test.ts delete mode 100644 src/security/capabilityVaultKey.ts delete mode 100644 src/security/capabilityVaultMetadata.test.ts delete mode 100644 src/security/capabilityVaultMetadata.ts delete mode 100644 src/security/feedCapabilityReferences.test.ts delete mode 100644 src/security/feedCapabilityReferences.ts delete mode 100644 src/security/feedCapabilityStorage.ts delete mode 100644 src/security/resourceHandles.test.ts delete mode 100644 src/security/resourceHandles.ts delete mode 100644 src/security/sealedCapabilityRecord.test.ts delete mode 100644 src/security/sealedCapabilityRecord.ts delete mode 100644 src/security/strictData.test.ts delete mode 100644 src/security/strictData.ts delete mode 100644 src/security/targetEnvelopes.test.ts delete mode 100644 src/security/targetEnvelopes.ts delete mode 100644 src/services/FeedCapabilityBroker.test.ts delete mode 100644 src/services/FeedCapabilityBroker.ts delete mode 100644 src/services/FeedCapabilityRepository.test.ts delete mode 100644 src/services/FeedCapabilityRepository.ts delete mode 100644 src/services/PodNotesNetworkServices.test.ts delete mode 100644 src/services/PodNotesNetworkServices.ts diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index 3369846..863e311 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -377,53 +377,6 @@ describe("PodNotes onload wiring (#55)", () => { } }); - it("owns and disposes one capability-network service aggregate per plugin load", async () => { - const { plugin } = await loadPlugin(); - const services = ( - plugin as unknown as { - networkServices?: { status: string; dispose(): void; isDisposed(): boolean }; - } - ).networkServices; - - expect(services?.status).toBe("available"); - expect(services?.isDisposed()).toBe(false); - - plugin.onunload(); - loaded.splice(loaded.indexOf(plugin), 1); - - expect(services?.isDisposed()).toBe(true); - expect( - ( - plugin as unknown as { - networkServices?: unknown; - } - ).networkServices, - ).toBeUndefined(); - }); - - it("keeps capability networking explicitly unavailable on mobile", async () => { - Object.assign(Platform, { - isDesktop: false, - isDesktopApp: false, - isIosApp: true, - isMobile: true, - isMobileApp: true, - isPhone: true, - }); - - const { plugin } = await loadPlugin(); - const services = ( - plugin as unknown as { - networkServices?: { status: string; reason?: string }; - } - ).networkServices; - - expect(services).toMatchObject({ - status: "unavailable", - reason: "unsupported-platform", - }); - }); - it("Show PodNotes command and ribbon icon both route to activateView", async () => { const { commands, ribbonCalls, activateSpy } = await loadPlugin(); diff --git a/src/main.ts b/src/main.ts index 4744986..12bc8b0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,10 +41,6 @@ import { PodNotesDataError, } from "./persistence/podNotesData"; import { CredentialRepository } from "./services/CredentialRepository"; -import { - createPodNotesNetworkServices, - type PodNotesNetworkServices, -} from "./services/PodNotesNetworkServices"; type MediaSessionActionName = | "previoustrack" @@ -83,7 +79,6 @@ export default class PodNotes extends Plugin implements IPodNotes { // Store subscriptions tied to the plugin lifetime: settings persistence and // queue automation. Disposed together in onunload. private storeUnsubscribers: Unsubscriber[] = []; - private networkServices?: PodNotesNetworkServices; private transcriptionService?: TranscriptionService; private volumeUnsubscribe?: Unsubscriber; private localFilesMirrorUnsubscribe?: Unsubscriber; @@ -195,8 +190,6 @@ export default class PodNotes extends Plugin implements IPodNotes { this.registerEvent(getContextMenuHandler(this.app)); this.registerMediaSessionHandlers(); - this.networkServices = createPodNotesNetworkServices(this.app.secretStorage); - this.isReady = true; } @@ -419,8 +412,6 @@ export default class PodNotes extends Plugin implements IPodNotes { override onunload() { this.isUnloaded = true; - this.networkServices?.dispose(); - this.networkServices = undefined; this.clearLayoutReadyRetry(); this.clearMediaSessionHandlers(); for (const unsubscribe of this.storeUnsubscribers) unsubscribe(); diff --git a/src/network/BufferedNetworkBody.ts b/src/network/BufferedNetworkBody.ts deleted file mode 100644 index e0f4f45..0000000 --- a/src/network/BufferedNetworkBody.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - assertNetworkOperationActive, - CAPABILITY_TRANSPORT_ERROR_CODES, - createCapabilityTransportError, - sanitizeCapabilityTransportError, - type NetworkResourceLabel, -} from "./NetworkErrors"; - -export const MAX_BUFFERED_NETWORK_RESPONSE_BYTES = 12 * 1024 * 1024; -export const MAX_BUFFERED_NETWORK_CHUNKS = 16_384; - -const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(Uint8Array.prototype) as object; -const TYPED_ARRAY_LENGTH_GETTER = Object.getOwnPropertyDescriptor( - TYPED_ARRAY_PROTOTYPE, - "length", -)?.get; -const TYPED_ARRAY_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( - TYPED_ARRAY_PROTOTYPE, - "byteLength", -)?.get; - -function getByteChunkLength(value: unknown): number | undefined { - if ( - !ArrayBuffer.isView(value) || - !TYPED_ARRAY_LENGTH_GETTER || - !TYPED_ARRAY_BYTE_LENGTH_GETTER - ) { - return undefined; - } - try { - const length = Reflect.apply(TYPED_ARRAY_LENGTH_GETTER, value, []) as unknown; - const byteLength = Reflect.apply(TYPED_ARRAY_BYTE_LENGTH_GETTER, value, []) as unknown; - return Number.isSafeInteger(length) && length === byteLength - ? (byteLength as number) - : undefined; - } catch { - return undefined; - } -} - -function growBuffer( - buffer: Uint8Array, - required: number, - limit: number, -): Uint8Array { - if (buffer.byteLength >= required) return buffer; - let capacity = Math.max(1024, buffer.byteLength); - while (capacity < required) capacity = Math.min(limit, Math.max(required, capacity * 2)); - const grown = new Uint8Array(capacity); - grown.set(buffer); - return grown; -} - -export async function consumeBufferedNetworkBody( - body: AsyncIterable, - maxResponseBytes: number, - resourceLabel: NetworkResourceLabel, - signal: AbortSignal, -): Promise { - let buffer = new Uint8Array(0); - let totalBytes = 0; - let chunkCount = 0; - try { - for await (const chunk of body) { - assertNetworkOperationActive(signal, resourceLabel); - chunkCount += 1; - const chunkLength = getByteChunkLength(chunk); - if (chunkLength === undefined) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.adapterResponseInvalid, - resourceLabel, - ); - } - if ( - chunkCount > MAX_BUFFERED_NETWORK_CHUNKS || - chunkLength > maxResponseBytes - totalBytes - ) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.responseTooLarge, - resourceLabel, - ); - } - if (chunkLength === 0) continue; - buffer = growBuffer(buffer, totalBytes + chunkLength, maxResponseBytes); - buffer.set(chunk as unknown as ArrayLike, totalBytes); - totalBytes += chunkLength; - } - return buffer.subarray(0, totalBytes); - } catch (error) { - throw sanitizeCapabilityTransportError(error, resourceLabel); - } -} diff --git a/src/network/CapabilityScopedTransport.test.ts b/src/network/CapabilityScopedTransport.test.ts deleted file mode 100644 index 008df3b..0000000 --- a/src/network/CapabilityScopedTransport.test.ts +++ /dev/null @@ -1,794 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - CAPABILITY_TRANSPORT_ERROR_CODES, - CapabilityScopedTransport, - CapabilityTransportError, - createCapabilityScopedNetworkRuntime, - MAX_BUFFERED_NETWORK_CHUNKS, - MAX_NETWORK_REDIRECTS, - MAX_NETWORK_RESOLVED_ADDRESSES, - NETWORK_BUFFERED_RESOURCE_POLICIES, - type NetworkNameResolver, - type PinnedNetworkHopAdapter, - type PinnedNetworkHopRequest, - type PinnedNetworkHopResponse, -} from "./CapabilityScopedTransport"; -import { - NetworkCapabilityError, - createNetworkCapabilityAuthority, - type NetworkCapability, - type NetworkCapabilityScope, -} from "./NetworkCapability"; -import { NetworkDisposedError } from "./NetworkErrors"; -import { NetworkScheduler } from "./NetworkScheduler"; -import type { EpisodeHandle, FeedHandle } from "src/security/resourceHandles"; - -const feedId = `podnotes-feed-${"1a".repeat(32)}` as FeedHandle; -const episodeId = `podnotes-episode-${"2b".repeat(32)}` as EpisodeHandle; -const subscriptionScope = Object.freeze({ resourceKind: "subscription", feedId } as const); -const feedArtworkScope = Object.freeze({ resourceKind: "feed-artwork", feedId } as const); -const streamScope = Object.freeze({ - resourceKind: "episode-stream", - feedId, - episodeId, -} as const); -const encoder = new TextEncoder(); - -interface Deferred { - readonly promise: Promise; - resolve(value: T): void; - reject(error: unknown): void; -} - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -async function* bytesBody(...chunks: Array): AsyncIterable { - for (const chunk of chunks) yield typeof chunk === "string" ? encoder.encode(chunk) : chunk; -} - -const publicNameResolver: NetworkNameResolver = () => ["93.184.216.34"]; - -function hopResponse( - request: PinnedNetworkHopRequest, - options: { - status?: number; - location?: string; - connectedAddress?: string; - body?: AsyncIterable; - close?: () => void | PromiseLike; - } = {}, -): PinnedNetworkHopResponse { - return { - status: options.status ?? 200, - body: options.body ?? bytesBody("ok"), - connectedAddress: options.connectedAddress ?? request.connectAddress, - close: options.close ?? (() => undefined), - ...(options.location === undefined ? {} : { location: options.location }), - }; -} - -function setup( - scope: Scope, - adapter: PinnedNetworkHopAdapter, - options: { - target?: string; - privateOrigins?: readonly string[]; - nameResolver?: NetworkNameResolver; - scheduler?: NetworkScheduler; - } = {}, -) { - const authority = createNetworkCapabilityAuthority(); - const capability = authority.issuer.issue( - scope, - options.target ?? "https://feeds.example.com/feed.xml", - options.privateOrigins, - ); - const scheduler = options.scheduler ?? new NetworkScheduler(); - const nameResolver = options.nameResolver ?? publicNameResolver; - const transport = new CapabilityScopedTransport( - authority.resolver, - nameResolver, - adapter, - scheduler, - ); - return { authority, capability, nameResolver, scheduler, transport }; -} - -function expectSafeTransportError( - error: unknown, - code: (typeof CAPABILITY_TRANSPORT_ERROR_CODES)[keyof typeof CAPABILITY_TRANSPORT_ERROR_CODES], - sentinel?: string, -): void { - expect(error).toBeInstanceOf(CapabilityTransportError); - expect((error as CapabilityTransportError).code).toBe(code); - expect((error as CapabilityTransportError).resourceLabel).toBe("podcast subscription"); - expect(Object.isFrozen(error)).toBe(true); - expect(error).not.toHaveProperty("cause"); - if (sentinel) { - expect(String(error)).not.toContain(sentinel); - expect(JSON.stringify(error)).not.toContain(sentinel); - } -} - -async function flushOperations(): Promise { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); -} - -describe("CapabilityScopedTransport pinned request boundary", () => { - it("pins one resolved address and exposes only structured request data to the adapter", async () => { - const target = "HTTPS://Feeds.Example.COM:443/audio%2fpart?sig=TOP-SECRET%2BVALUE"; - const source = encoder.encode("payload"); - let observed: PinnedNetworkHopRequest | undefined; - const nameResolver = vi.fn(() => ["93.184.216.34"]); - const adapter = vi.fn((request) => { - observed = request; - return hopResponse(request, { body: bytesBody(source) }); - }); - const { transport, capability } = setup(subscriptionScope, adapter, { - target, - nameResolver, - }); - - const response = await transport.getBytes(capability, subscriptionScope); - - expect(nameResolver).toHaveBeenCalledWith("feeds.example.com", expect.any(AbortSignal)); - expect(observed).toEqual({ - protocol: "https:", - connectAddress: "93.184.216.34", - port: 443, - serverName: "feeds.example.com", - hostHeader: "feeds.example.com", - requestTarget: "/audio%2fpart?sig=TOP-SECRET%2BVALUE", - method: "GET", - credentials: "omit", - redirect: "manual", - signal: expect.any(AbortSignal), - }); - expect(observed).not.toHaveProperty("target"); - expect(Object.isFrozen(observed)).toBe(true); - expect(Object.keys(transport)).toEqual([]); - expect(Object.isFrozen(transport)).toBe(true); - expect(new TextDecoder().decode(response.bytes)).toBe("payload"); - expect(Object.isFrozen(response)).toBe(true); - expect(JSON.stringify(response)).not.toContain(target); - source.fill(0); - expect(new TextDecoder().decode(response.bytes)).toBe("payload"); - }); - - it("uses an IP literal directly, requires its exact private grant, and skips DNS", async () => { - const nameResolver = vi.fn(); - const adapter = vi.fn((request) => hopResponse(request)); - const target = "http://192.168.1.10:8080/feed"; - const blocked = setup(subscriptionScope, adapter, { target, nameResolver }); - const blockedError = await blocked.transport - .getBytes(blocked.capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError(blockedError, CAPABILITY_TRANSPORT_ERROR_CODES.targetRejected); - expect(adapter).not.toHaveBeenCalled(); - - const allowed = setup(subscriptionScope, adapter, { - target, - nameResolver, - privateOrigins: ["http://192.168.1.10:8080"], - }); - const response = await allowed.transport.getBytes(allowed.capability, subscriptionScope); - expect(response.status).toBe(200); - expect(new TextDecoder().decode(response.bytes)).toBe("ok"); - expect(nameResolver).not.toHaveBeenCalled(); - expect(adapter.mock.calls[0][0].connectAddress).toBe("192.168.1.10"); - expect(adapter.mock.calls[0][0]).not.toHaveProperty("serverName"); - }); - - it("selects only an authorized DNS answer and permits private DNS only by exact origin", async () => { - const addresses = ["192.168.1.20", "93.184.216.34"]; - const publicAdapter = vi.fn((request) => hopResponse(request)); - const publicSetup = setup(subscriptionScope, publicAdapter, { - nameResolver: () => addresses, - }); - await publicSetup.transport.getBytes(publicSetup.capability, subscriptionScope); - expect(publicAdapter.mock.calls[0][0].connectAddress).toBe("93.184.216.34"); - - const privateAdapter = vi.fn((request) => hopResponse(request)); - const privateSetup = setup(subscriptionScope, privateAdapter, { - target: "https://private.example/feed", - privateOrigins: ["https://private.example"], - nameResolver: () => addresses, - }); - await privateSetup.transport.getBytes(privateSetup.capability, subscriptionScope); - expect(privateAdapter.mock.calls[0][0].connectAddress).toBe("192.168.1.20"); - }); - - it("falls back to the next pinned DNS address under the same operation deadline", async () => { - const adapter = vi.fn((request) => { - if (request.connectAddress === "2001:4860::1") { - throw new Error("unreachable first address"); - } - return hopResponse(request); - }); - const { transport, capability } = setup(subscriptionScope, adapter, { - nameResolver: () => ["2001:4860::1", "93.184.216.34"], - }); - - await expect(transport.getBytes(capability, subscriptionScope)).resolves.toMatchObject({ - status: 200, - }); - expect(adapter.mock.calls.map(([request]) => request.connectAddress)).toEqual([ - "2001:4860::1", - "93.184.216.34", - ]); - }); - - it("verifies the socket's actual remote address, including mapped IPv4", async () => { - const mappedAdapter = vi.fn((request) => - hopResponse(request, { connectedAddress: "::ffff:93.184.216.34" }), - ); - const mapped = setup(subscriptionScope, mappedAdapter); - await expect( - mapped.transport.getBytes(mapped.capability, subscriptionScope), - ).resolves.toMatchObject({ - status: 200, - }); - - const close = vi.fn(); - const mismatchedAdapter = vi.fn((request) => - hopResponse(request, { connectedAddress: "169.254.169.254", close }), - ); - const mismatched = setup(subscriptionScope, mismatchedAdapter); - const error = await mismatched.transport - .getBytes(mismatched.capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError(error, CAPABILITY_TRANSPORT_ERROR_CODES.adapterResponseInvalid); - expect(close).toHaveBeenCalledOnce(); - }); - - it.each([ - ["empty", []], - ["hostname", ["private.example"]], - ["duplicate", ["8.8.8.8", "::ffff:8.8.8.8"]], - [ - "over-limit", - Array.from( - { length: MAX_NETWORK_RESOLVED_ADDRESSES + 1 }, - (_, index) => `8.8.8.${index + 1}`, - ), - ], - ] as const)("rejects a malformed %s DNS answer", async (_name, addresses) => { - const adapter = vi.fn(); - const { transport, capability } = setup(subscriptionScope, adapter, { - nameResolver: () => addresses, - }); - const error = await transport - .getBytes(capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError(error, CAPABILITY_TRANSPORT_ERROR_CODES.addressRejected); - expect(adapter).not.toHaveBeenCalled(); - }); - - it("sanitizes resolver failures and hostile array traps", async () => { - const sentinel = "https://private.example/feed?token=DNS-SECRET"; - const adapters = [ - () => { - throw new Error(sentinel); - }, - () => - new Proxy(["93.184.216.34"], { - ownKeys: () => { - throw new Error(sentinel); - }, - }), - ] satisfies NetworkNameResolver[]; - - for (const nameResolver of adapters) { - const adapter = vi.fn(); - const { transport, capability } = setup(subscriptionScope, adapter, { nameResolver }); - const error = await transport - .getBytes(capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError( - error, - CAPABILITY_TRANSPORT_ERROR_CODES.addressRejected, - sentinel, - ); - } - }); -}); - -describe("CapabilityScopedTransport redirects and bounded responses", () => { - it("resolves every redirect through a newly pinned hop", async () => { - const absolute = "HTTPS://CDN.Example.COM:443/audio%2fpart?sig=A%2BB"; - const requests: PinnedNetworkHopRequest[] = []; - const closes = [vi.fn(), vi.fn(), vi.fn()]; - const adapter = vi.fn((request) => { - requests.push(request); - if (requests.length === 1) { - return hopResponse(request, { status: 302, location: absolute, close: closes[0] }); - } - if (requests.length === 2) { - return hopResponse(request, { - status: 307, - location: "../final?token=still-private", - close: closes[1], - }); - } - return hopResponse(request, { body: bytesBody("done"), close: closes[2] }); - }); - const { transport, capability } = setup(subscriptionScope, adapter); - - const response = await transport.getBytes(capability, subscriptionScope); - - expect(requests.map(({ serverName }) => serverName)).toEqual([ - "feeds.example.com", - "cdn.example.com", - "cdn.example.com", - ]); - expect(requests.map(({ requestTarget }) => requestTarget)).toEqual([ - "/feed.xml", - "/audio%2fpart?sig=A%2BB", - "/final?token=still-private", - ]); - expect(new TextDecoder().decode(response.bytes)).toBe("done"); - expect(closes.every((close) => close.mock.calls.length === 1)).toBe(true); - }); - - it.each([ - ["https downgrade", "http://public.example/final"], - ["private redirect", "https://127.0.0.1/private"], - ["credential redirect", "https://user:pass@public.example/final"], - ["unsupported redirect", "file:///etc/passwd"], - ["fragment redirect", "#private-fragment"], - ["backslash redirect", "\\private.example\\feed"], - ] as const)("rejects a %s before opening the redirected hop", async (_name, location) => { - const close = vi.fn(); - const adapter = vi.fn((request) => - hopResponse(request, { status: 302, location, close }), - ); - const { transport, capability } = setup(subscriptionScope, adapter); - const error = await transport - .getBytes(capability, subscriptionScope) - .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(CapabilityTransportError); - expect(adapter).toHaveBeenCalledOnce(); - expect(close).toHaveBeenCalledOnce(); - }); - - it("allows a private redirect only through an exact pre-issued origin grant", async () => { - const requests: PinnedNetworkHopRequest[] = []; - const adapter = vi.fn((request) => { - requests.push(request); - return requests.length === 1 - ? hopResponse(request, { status: 302, location: "http://127.0.0.1:8080/feed" }) - : hopResponse(request); - }); - const { transport, capability } = setup(subscriptionScope, adapter, { - target: "http://public.example/feed", - privateOrigins: ["http://127.0.0.1:8080"], - }); - - await expect(transport.getBytes(capability, subscriptionScope)).resolves.toMatchObject({ - status: 200, - }); - expect(requests[1].connectAddress).toBe("127.0.0.1"); - }); - - it("rejects redirect loops and chains beyond the fixed limit", async () => { - const loopAdapter = vi.fn((request) => - hopResponse(request, { status: 302, location: "https://feeds.example.com/feed.xml" }), - ); - const loop = setup(subscriptionScope, loopAdapter); - const loopError = await loop.transport - .getBytes(loop.capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError(loopError, CAPABILITY_TRANSPORT_ERROR_CODES.redirectRejected); - expect(loopAdapter).toHaveBeenCalledOnce(); - - let hop = 0; - const chainAdapter = vi.fn((request) => { - hop += 1; - return hopResponse(request, { - status: 302, - location: `https://redirect-${hop}.example/feed`, - }); - }); - const chain = setup(subscriptionScope, chainAdapter); - const chainError = await chain.transport - .getBytes(chain.capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError(chainError, CAPABILITY_TRANSPORT_ERROR_CODES.redirectLimit); - expect(chainAdapter).toHaveBeenCalledTimes(MAX_NETWORK_REDIRECTS + 1); - }); - - it("requires the capability's fixed status policy and closes the hop", async () => { - const close = vi.fn(); - const adapter = vi.fn((request) => - hopResponse(request, { status: 404, body: bytesBody("secret"), close }), - ); - const { transport, capability } = setup(subscriptionScope, adapter); - const error = await transport - .getBytes(capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError(error, CAPABILITY_TRANSPORT_ERROR_CODES.statusRejected); - expect(close).toHaveBeenCalledOnce(); - }); - - it("enforces the capability's byte ceiling incrementally", async () => { - const limit = NETWORK_BUFFERED_RESOURCE_POLICIES.subscription.maxResponseBytes; - let iteratorClosed = false; - async function* oversizedBody(): AsyncIterable { - try { - yield new Uint8Array(limit); - yield new Uint8Array(1); - } finally { - iteratorClosed = true; - } - } - const close = vi.fn(); - const adapter = vi.fn((request) => - hopResponse(request, { body: oversizedBody(), close }), - ); - const { transport, capability } = setup(subscriptionScope, adapter); - const error = await transport - .getBytes(capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError(error, CAPABILITY_TRANSPORT_ERROR_CODES.responseTooLarge); - expect(iteratorClosed).toBe(true); - expect(close).toHaveBeenCalledOnce(); - }); - - it("bounds zero-length chunk work independently of byte count", async () => { - async function* excessiveChunks(): AsyncIterable { - for (let index = 0; index <= MAX_BUFFERED_NETWORK_CHUNKS; index += 1) { - yield new Uint8Array(0); - } - } - const adapter = vi.fn((request) => - hopResponse(request, { body: excessiveChunks() }), - ); - const { transport, capability } = setup(subscriptionScope, adapter); - const error = await transport - .getBytes(capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError(error, CAPABILITY_TRANSPORT_ERROR_CODES.responseTooLarge); - }); - - it("rejects non-byte views and sanitizes adapter, body, and close failures", async () => { - const sentinel = "https://user:pass@private.example/audio?token=TOP-SECRET"; - const cases: PinnedNetworkHopAdapter[] = [ - (request) => - hopResponse(request, { - body: bytesBody(new Uint16Array([1]) as unknown as Uint8Array), - }), - () => { - throw new Error(sentinel); - }, - (request) => - hopResponse(request, { - body: { - [Symbol.asyncIterator]: () => ({ - next: () => Promise.reject(new Error(sentinel)), - }), - }, - }), - (request) => - hopResponse(request, { - close: () => { - throw new Error(sentinel); - }, - }), - ]; - - for (const adapter of cases) { - const { transport, capability } = setup(subscriptionScope, adapter); - const error = await transport - .getBytes(capability, subscriptionScope) - .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(CapabilityTransportError); - expect(String(error)).not.toContain(sentinel); - expect(JSON.stringify(error)).not.toContain(sentinel); - } - }); - - it("closes malformed adapter responses without invoking accessor fields", async () => { - let statusRead = false; - const close = vi.fn(); - const adapter: PinnedNetworkHopAdapter = (request) => { - const response = { - body: bytesBody("ok"), - connectedAddress: request.connectAddress, - close, - } as Record; - Object.defineProperty(response, "status", { - enumerable: true, - get: () => { - statusRead = true; - return 200; - }, - }); - return response as unknown as PinnedNetworkHopResponse; - }; - const { transport, capability } = setup(subscriptionScope, adapter); - const error = await transport - .getBytes(capability, subscriptionScope) - .catch((caught: unknown) => caught); - expectSafeTransportError(error, CAPABILITY_TRANSPORT_ERROR_CODES.adapterResponseInvalid); - expect(statusRead).toBe(false); - expect(close).toHaveBeenCalledOnce(); - }); - - it("preserves the adapter response receiver when invoking close", async () => { - let responseObject: PinnedNetworkHopResponse | undefined; - let usedResponseReceiver = false; - const adapter: PinnedNetworkHopAdapter = (request) => { - responseObject = { - status: 200, - body: bytesBody("ok"), - connectedAddress: request.connectAddress, - close(): void { - usedResponseReceiver = this === responseObject; - }, - }; - return responseObject; - }; - const { transport, capability } = setup(subscriptionScope, adapter); - - await transport.getBytes(capability, subscriptionScope); - expect(usedResponseReceiver).toBe(true); - }); -}); - -describe("CapabilityScopedTransport policy, revocation, and lifecycle", () => { - it("derives immutable buffering and lane policy from the resource kind", async () => { - expect(Object.isFrozen(NETWORK_BUFFERED_RESOURCE_POLICIES)).toBe(true); - expect(Object.isFrozen(NETWORK_BUFFERED_RESOURCE_POLICIES.subscription)).toBe(true); - expect( - Object.isFrozen(NETWORK_BUFFERED_RESOURCE_POLICIES.subscription.acceptedStatuses), - ).toBe(true); - - const gate = deferred(); - const adapter = vi.fn(() => gate.promise); - const artwork = setup(feedArtworkScope, adapter); - const pending = artwork.transport.getBytes(artwork.capability, feedArtworkScope); - expect(artwork.transport.getLaneSnapshot("media")).toMatchObject({ active: 1 }); - expect(artwork.transport.getLaneSnapshot("metadata")).toMatchObject({ active: 0 }); - await flushOperations(); - const request = adapter.mock.calls[0][0]; - gate.resolve(hopResponse(request)); - await pending; - }); - - it("rejects buffered episode-stream use before DNS or adapter work", async () => { - const nameResolver = vi.fn(); - const adapter = vi.fn(); - const { transport, capability } = setup(streamScope, adapter, { nameResolver }); - const error = await transport - .getBytes(capability, streamScope) - .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(CapabilityTransportError); - expect((error as CapabilityTransportError).code).toBe( - CAPABILITY_TRANSPORT_ERROR_CODES.resourceRejected, - ); - expect(nameResolver).not.toHaveBeenCalled(); - expect(adapter).not.toHaveBeenCalled(); - }); - - it("revalidates a queued capability before network work starts", async () => { - const firstGate = deferred(); - let calls = 0; - const adapter = vi.fn((request) => { - calls += 1; - return calls === 1 ? firstGate.promise : hopResponse(request); - }); - const scheduler = new NetworkScheduler({ - laneLimits: { metadata: { maxActive: 1, maxQueued: 1 } }, - }); - const authority = createNetworkCapabilityAuthority(); - const firstCapability = authority.issuer.issue( - subscriptionScope, - "https://first.example/feed", - ); - const secondCapability = authority.issuer.issue( - subscriptionScope, - "https://second.example/feed", - ); - const transport = new CapabilityScopedTransport( - authority.resolver, - publicNameResolver, - adapter, - scheduler, - ); - const first = transport.getBytes(firstCapability, subscriptionScope); - const second = transport.getBytes(secondCapability, subscriptionScope); - expect(transport.getLaneSnapshot("metadata")).toMatchObject({ active: 1, queued: 1 }); - expect(authority.issuer.revoke(secondCapability)).toBe(true); - - await flushOperations(); - firstGate.resolve(hopResponse(adapter.mock.calls[0][0])); - await first; - await expect(second).rejects.toBeInstanceOf(NetworkCapabilityError); - expect(adapter).toHaveBeenCalledOnce(); - }); - - it("revocation aborts and closes already-active capability work", async () => { - const nextGate = deferred>(); - const close = vi.fn(() => nextGate.reject(new Error("closed"))); - const body: AsyncIterable = { - [Symbol.asyncIterator]: () => ({ next: () => nextGate.promise }), - }; - const adapter = vi.fn((request) => - hopResponse(request, { body, close }), - ); - const active = setup(subscriptionScope, adapter); - const pending = active.transport.getBytes(active.capability, subscriptionScope); - await flushOperations(); - - expect(active.authority.issuer.revoke(active.capability)).toBe(true); - expect(close).toHaveBeenCalledOnce(); - await expect(pending).rejects.toBeInstanceOf(CapabilityTransportError); - await flushOperations(); - expect(active.transport.getLaneSnapshot("metadata").active).toBe(0); - }); - - it("does not return buffered bytes when revocation occurs during asynchronous close", async () => { - const closeGate = deferred(); - const close = vi.fn(() => closeGate.promise); - const adapter = vi.fn((request) => - hopResponse(request, { body: bytesBody("complete"), close }), - ); - const active = setup(subscriptionScope, adapter); - const pending = active.transport.getBytes(active.capability, subscriptionScope); - await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); - - expect(active.authority.issuer.revoke(active.capability)).toBe(true); - closeGate.resolve(); - await expect(pending).rejects.toBeInstanceOf(CapabilityTransportError); - }); - - it("rejects forged tokens, cross-scope use, and forged resolver facets", async () => { - const adapter = vi.fn(); - const { authority, transport, capability, scheduler } = setup(subscriptionScope, adapter); - const forged = Object.freeze(Object.create(null)) as NetworkCapability; - await expect(transport.getBytes(forged, subscriptionScope)).rejects.toBeInstanceOf( - NetworkCapabilityError, - ); - await expect( - transport.getBytes( - capability as unknown as NetworkCapability, - streamScope, - ), - ).rejects.toBeInstanceOf(NetworkCapabilityError); - expect( - () => - new CapabilityScopedTransport( - { resolve: authority.resolver.resolve }, - publicNameResolver, - adapter, - scheduler, - ), - ).toThrow("authentic network capability resolver"); - expect(adapter).not.toHaveBeenCalled(); - }); - - it("requires every trusted composition dependency explicitly", () => { - const authority = createNetworkCapabilityAuthority(); - const scheduler = new NetworkScheduler(); - const adapter = vi.fn(); - expect( - () => - new CapabilityScopedTransport( - authority.resolver, - null as unknown as NetworkNameResolver, - adapter, - scheduler, - ), - ).toThrow(TypeError); - expect( - () => - new CapabilityScopedTransport( - authority.resolver, - publicNameResolver, - adapter, - undefined as unknown as NetworkScheduler, - ), - ).toThrow("shared network scheduler"); - }); - - it("does not export an executable raw pinned-hop client", async () => { - const hopBoundary = await import("./PinnedNetworkHop"); - const { transport } = setup(subscriptionScope, vi.fn()); - - expect(hopBoundary).not.toHaveProperty("PinnedNetworkHopClient"); - expect(Object.getOwnPropertyNames(Object.getPrototypeOf(transport)).sort()).toEqual([ - "constructor", - "dispose", - "getBytes", - "getLaneSnapshot", - "getSnapshot", - "isDisposed", - ]); - }); - - it("composition runtime never distributes the raw-target resolver", async () => { - const adapter = vi.fn((request) => hopResponse(request)); - const runtime = createCapabilityScopedNetworkRuntime( - publicNameResolver, - adapter, - new NetworkScheduler(), - ); - const capability = runtime.issuer.issue( - subscriptionScope, - "https://feeds.example.com/feed.xml?token=hidden", - ); - - expect(Object.keys(runtime).sort()).toEqual(["dispose", "issuer", "transport"]); - expect(runtime).not.toHaveProperty("resolver"); - await expect( - runtime.transport.getBytes(capability, subscriptionScope), - ).resolves.toMatchObject({ - status: 200, - }); - runtime.dispose(); - expect(runtime.transport.isDisposed()).toBe(true); - }); - - it("aborts and closes active response work exactly once on disposal", async () => { - const nextGate = deferred>(); - const close = vi.fn(() => { - nextGate.reject(new Error("closed")); - }); - const body: AsyncIterable = { - [Symbol.asyncIterator]: () => ({ next: () => nextGate.promise }), - }; - let signal: AbortSignal | undefined; - const adapter = vi.fn((request) => { - signal = request.signal; - return hopResponse(request, { body, close }); - }); - const { transport, capability } = setup(subscriptionScope, adapter); - const pending = transport.getBytes(capability, subscriptionScope); - await flushOperations(); - - transport.dispose(); - expect(signal?.aborted).toBe(true); - expect(close).toHaveBeenCalledOnce(); - await expect(pending).rejects.toBeInstanceOf(NetworkDisposedError); - await flushOperations(); - expect(close).toHaveBeenCalledOnce(); - }); - - it("closes a late adapter response without consuming it after disposal", async () => { - const adapterGate = deferred(); - const close = vi.fn(); - let request: PinnedNetworkHopRequest | undefined; - let bodyStarted = false; - const body: AsyncIterable = { - [Symbol.asyncIterator]: () => { - bodyStarted = true; - return { next: () => Promise.resolve({ done: true, value: undefined }) }; - }, - }; - const adapter = vi.fn((nextRequest) => { - request = nextRequest; - return adapterGate.promise; - }); - const { transport, capability } = setup(subscriptionScope, adapter); - const pending = transport.getBytes(capability, subscriptionScope); - await flushOperations(); - transport.dispose(); - await expect(pending).rejects.toBeInstanceOf(NetworkDisposedError); - - if (!request) throw new Error("adapter request was not captured"); - adapterGate.resolve(hopResponse(request, { body, close })); - await flushOperations(); - expect(close).toHaveBeenCalledOnce(); - expect(bodyStarted).toBe(false); - }); -}); diff --git a/src/network/CapabilityScopedTransport.ts b/src/network/CapabilityScopedTransport.ts deleted file mode 100644 index eccbbe1..0000000 --- a/src/network/CapabilityScopedTransport.ts +++ /dev/null @@ -1,539 +0,0 @@ -import { - MAX_BUFFERED_NETWORK_RESPONSE_BYTES, - consumeBufferedNetworkBody, -} from "./BufferedNetworkBody"; -import { - NetworkCapabilityError, - createNetworkCapabilityAuthority, - isNetworkCapabilityResolver, - type NetworkCapability, - type NetworkCapabilityIssuer, - type NetworkCapabilityResolution, - type NetworkCapabilityResolver, - type NetworkCapabilityScope, - type NetworkResourceKind, -} from "./NetworkCapability"; -import { - classifyResolvedAddress, - isResolvedAddressLiteral, - MAX_NETWORK_TARGET_BYTES, - parseTargetPolicyView, - type NetworkProtocol, - type TargetPolicyView, -} from "./LiteralTargetClassifier"; -import { - assertNetworkOperationActive, - CAPABILITY_TRANSPORT_ERROR_CODES, - createCapabilityTransportError, - createNetworkResourceLabel, - sanitizeCapabilityTransportError, - type CapabilityTransportError, - type NetworkResourceLabel, -} from "./NetworkErrors"; -import { - closeInvalidHopResponse, - manageHopResponse, - snapshotHopResponse, - snapshotResolvedAddresses, - targetRequestParts, - type NetworkNameResolver, - type PinnedNetworkHop, - type PinnedNetworkHopAdapter, -} from "./PinnedNetworkHop"; -import type { - NetworkLane, - NetworkLaneSnapshot, - NetworkScheduler, - NetworkSchedulerSession, - NetworkSchedulerSnapshot, -} from "./NetworkScheduler"; - -export { - MAX_BUFFERED_NETWORK_CHUNKS, - MAX_BUFFERED_NETWORK_RESPONSE_BYTES, -} from "./BufferedNetworkBody"; -export { - CAPABILITY_TRANSPORT_ERROR_CODES, - CapabilityTransportError, - type CapabilityTransportErrorCode, -} from "./NetworkErrors"; -export { - MAX_NETWORK_RESOLVED_ADDRESSES, - type NetworkNameResolver, - type PinnedNetworkHopAdapter, - type PinnedNetworkHopRequest, - type PinnedNetworkHopResponse, -} from "./PinnedNetworkHop"; - -export const MAX_NETWORK_REDIRECTS = 5; - -export interface BufferedNetworkResourcePolicy { - readonly lane: NetworkLane; - readonly acceptedStatuses: readonly number[]; - /** Total enqueue-to-completion deadline. */ - readonly timeoutMs: number; - readonly maxResponseBytes: number; -} - -function bufferedPolicy( - lane: NetworkLane, - timeoutMs: number, - maxResponseBytes: number, -): BufferedNetworkResourcePolicy { - return Object.freeze({ - lane, - acceptedStatuses: Object.freeze([200]), - timeoutMs, - maxResponseBytes, - }); -} - -/** - * Buffering, status, deadline, and lane policy is fixed by capability purpose. - * Episode streams deliberately have no buffered operation. - */ -export const NETWORK_BUFFERED_RESOURCE_POLICIES = Object.freeze({ - subscription: bufferedPolicy("metadata", 30_000, 8 * 1024 * 1024), - "feed-artwork": bufferedPolicy("media", 30_000, MAX_BUFFERED_NETWORK_RESPONSE_BYTES), - site: bufferedPolicy("metadata", 30_000, 4 * 1024 * 1024), - "episode-stream": null, - "episode-chapters": bufferedPolicy("metadata", 20_000, 4 * 1024 * 1024), - "episode-artwork": bufferedPolicy("media", 30_000, MAX_BUFFERED_NETWORK_RESPONSE_BYTES), - "episode-item-link": bufferedPolicy("metadata", 30_000, 4 * 1024 * 1024), -} as const satisfies Readonly>); - -export interface CapabilityBytesResponse { - readonly status: number; - readonly bytes: Uint8Array; -} - -type TransportOutcome = - | { readonly ok: true; readonly value: T } - | { - readonly ok: false; - readonly error: CapabilityTransportError | NetworkCapabilityError; - }; - -const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); - -function createCapabilityResolutionError(): NetworkCapabilityError { - return Object.freeze(new NetworkCapabilityError("resolve")); -} - -function evaluateTarget( - target: string, - privateOrigins: ReadonlySet, - resourceLabel: NetworkResourceLabel, - previousProtocol?: NetworkProtocol, -): TargetPolicyView { - const policy = parseTargetPolicyView(target); - if ( - !policy.ok || - policy.value.hasCredentials || - (policy.value.hostClassification === "non-public-or-special-literal" && - !privateOrigins.has(policy.value.normalizedOrigin)) - ) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.targetRejected, - resourceLabel, - ); - } - if (previousProtocol === "https:" && policy.value.protocol === "http:") { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.redirectRejected, - resourceLabel, - ); - } - return policy.value; -} - -function hasUnsafeRelativeLocationCodePoint(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if (code <= 0x20 || (code >= 0x7f && code <= 0x9f)) return true; - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (next < 0xdc00 || next > 0xdfff) return true; - index += 1; - continue; - } - if (code >= 0xdc00 && code <= 0xdfff) return true; - if ( - code === 0x061c || - (code >= 0x200b && code <= 0x200f) || - (code >= 0x2028 && code <= 0x202e) || - (code >= 0x2066 && code <= 0x2069) || - code === 0xfeff - ) { - return true; - } - } - return false; -} - -function resolveRedirectTarget( - location: string | undefined, - currentTarget: string, - resourceLabel: NetworkResourceLabel, -): string { - if ( - location === undefined || - location.length === 0 || - location.length > MAX_NETWORK_TARGET_BYTES || - location !== location.trim() || - location.includes("\\") || - hasUnsafeRelativeLocationCodePoint(location) || - new TextEncoder().encode(location).byteLength > MAX_NETWORK_TARGET_BYTES - ) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.redirectRejected, - resourceLabel, - ); - } - if (/^[a-z][a-z0-9+.-]*:/i.test(location)) return location; - try { - return new URL(location, currentTarget).href; - } catch { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.redirectRejected, - resourceLabel, - ); - } -} - -function combineAbortSignals(...signals: readonly AbortSignal[]): { - readonly signal: AbortSignal; - dispose(): void; -} { - const controller = new AbortController(); - const abort = (): void => controller.abort(); - for (const signal of signals) signal.addEventListener("abort", abort, { once: true }); - if (signals.some((signal) => signal.aborted)) abort(); - return Object.freeze({ - signal: controller.signal, - dispose(): void { - for (const signal of signals) signal.removeEventListener("abort", abort); - }, - }); -} - -export class CapabilityScopedTransport { - readonly #capabilityResolver: NetworkCapabilityResolver; - readonly #nameResolver: NetworkNameResolver; - readonly #adapter: PinnedNetworkHopAdapter; - readonly #session: NetworkSchedulerSession; - - constructor( - capabilityResolver: NetworkCapabilityResolver, - nameResolver: NetworkNameResolver, - adapter: PinnedNetworkHopAdapter, - scheduler: NetworkScheduler, - ) { - if (!isNetworkCapabilityResolver(capabilityResolver)) { - throw new TypeError("An authentic network capability resolver is required."); - } - if (!scheduler || typeof scheduler.createSession !== "function") { - throw new TypeError("A shared network scheduler is required."); - } - if (typeof nameResolver !== "function") { - throw new TypeError("A network name resolver is required."); - } - if (typeof adapter !== "function") { - throw new TypeError("A pinned network hop adapter is required."); - } - this.#capabilityResolver = capabilityResolver; - this.#nameResolver = nameResolver; - this.#adapter = adapter; - this.#session = scheduler.createSession(); - Object.freeze(this); - } - - async getBytes( - capability: NetworkCapability, - expectedScope: Scope, - ): Promise { - let initialResolution: NetworkCapabilityResolution; - try { - initialResolution = this.#capabilityResolver.resolve(capability, expectedScope); - } catch { - throw createCapabilityResolutionError(); - } - const validatedScope = initialResolution.scope as Scope; - const resourceLabel = createNetworkResourceLabel(initialResolution.resourceLabel); - const policy = NETWORK_BUFFERED_RESOURCE_POLICIES[validatedScope.resourceKind]; - if (!policy) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.resourceRejected, - resourceLabel, - ); - } - - const outcome = await this.#session.schedule>({ - lane: policy.lane, - resourceLabel, - timeoutMs: policy.timeoutMs, - operation: async (signal) => { - let currentResolution: NetworkCapabilityResolution; - try { - currentResolution = this.#capabilityResolver.resolve( - capability, - validatedScope, - ); - } catch { - return { ok: false, error: createCapabilityResolutionError() }; - } - const combinedSignal = combineAbortSignals( - signal, - currentResolution.revocationSignal, - currentResolution.authoritySignal, - ); - try { - const value = await this.#execute( - currentResolution.target, - new Set(currentResolution.privateOrigins), - policy, - resourceLabel, - combinedSignal.signal, - ); - return { ok: true, value }; - } catch (error) { - return { - ok: false, - error: sanitizeCapabilityTransportError(error, resourceLabel), - }; - } finally { - combinedSignal.dispose(); - } - }, - }); - - if (!outcome.ok) throw outcome.error; - return outcome.value; - } - - dispose(): void { - this.#session.dispose(); - } - - isDisposed(): boolean { - return this.#session.isDisposed(); - } - - getLaneSnapshot(lane: NetworkLane): NetworkLaneSnapshot { - return this.#session.getLaneSnapshot(lane); - } - - getSnapshot(): NetworkSchedulerSnapshot { - return this.#session.getSnapshot(); - } - - async #selectConnectAddresses( - policy: TargetPolicyView, - privateOrigins: ReadonlySet, - resourceLabel: NetworkResourceLabel, - signal: AbortSignal, - ): Promise { - if (isResolvedAddressLiteral(policy.normalizedHostname)) { - return Object.freeze([policy.normalizedHostname]); - } - - let rawAddresses: unknown; - try { - rawAddresses = await this.#nameResolver(policy.normalizedHostname, signal); - } catch { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.addressRejected, - resourceLabel, - ); - } - assertNetworkOperationActive(signal, resourceLabel); - const addresses = snapshotResolvedAddresses(rawAddresses, resourceLabel); - const grantsPrivateAddress = privateOrigins.has(policy.normalizedOrigin); - const selected = addresses.filter( - (address) => - classifyResolvedAddress(address) === "ordinary-public-literal" || - grantsPrivateAddress, - ); - if (selected.length === 0) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.addressRejected, - resourceLabel, - ); - } - return Object.freeze(selected); - } - - async #openHop( - target: string, - policy: TargetPolicyView, - connectAddresses: readonly string[], - resourceLabel: NetworkResourceLabel, - signal: AbortSignal, - ): Promise { - const parts = targetRequestParts(target, policy); - for (const connectAddress of connectAddresses) { - assertNetworkOperationActive(signal, resourceLabel); - const request = Object.freeze({ - protocol: policy.protocol, - connectAddress, - port: policy.port, - ...(isResolvedAddressLiteral(policy.normalizedHostname) - ? {} - : { serverName: policy.normalizedHostname }), - hostHeader: parts.hostHeader, - requestTarget: parts.requestTarget, - method: "GET" as const, - credentials: "omit" as const, - redirect: "manual" as const, - signal, - }); - - let response: unknown; - try { - response = await this.#adapter(request); - } catch { - assertNetworkOperationActive(signal, resourceLabel); - continue; - } - let prepared: ReturnType; - try { - prepared = snapshotHopResponse(response, connectAddress, resourceLabel); - } catch (error) { - await closeInvalidHopResponse(response); - throw error; - } - const managed = manageHopResponse(prepared, signal); - if (signal.aborted) { - await managed.close(); - assertNetworkOperationActive(signal, resourceLabel); - } - return managed; - } - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.operationFailed, - resourceLabel, - ); - } - - async #execute( - initialTarget: string, - privateOrigins: ReadonlySet, - resourcePolicy: BufferedNetworkResourcePolicy, - resourceLabel: NetworkResourceLabel, - signal: AbortSignal, - ): Promise { - let target = initialTarget; - let previousProtocol: NetworkProtocol | undefined; - const visitedTargets = new Set(); - - for (let redirectCount = 0; ; redirectCount += 1) { - assertNetworkOperationActive(signal, resourceLabel); - const targetPolicy = evaluateTarget( - target, - privateOrigins, - resourceLabel, - previousProtocol, - ); - if (visitedTargets.has(target)) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.redirectRejected, - resourceLabel, - ); - } - visitedTargets.add(target); - const connectAddresses = await this.#selectConnectAddresses( - targetPolicy, - privateOrigins, - resourceLabel, - signal, - ); - assertNetworkOperationActive(signal, resourceLabel); - - const response = await this.#openHop( - target, - targetPolicy, - connectAddresses, - resourceLabel, - signal, - ); - let bodyResult: Uint8Array | undefined; - let hopError: unknown; - try { - assertNetworkOperationActive(signal, resourceLabel); - if (REDIRECT_STATUSES.has(response.status)) { - if (redirectCount >= MAX_NETWORK_REDIRECTS) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.redirectLimit, - resourceLabel, - ); - } - target = resolveRedirectTarget(response.location, target, resourceLabel); - previousProtocol = targetPolicy.protocol; - } else if (!resourcePolicy.acceptedStatuses.includes(response.status)) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.statusRejected, - resourceLabel, - ); - } else { - bodyResult = await consumeBufferedNetworkBody( - response.body, - resourcePolicy.maxResponseBytes, - resourceLabel, - signal, - ); - } - } catch (error) { - hopError = error; - } - - try { - await response.close(); - } catch { - if (!hopError) { - hopError = createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.operationFailed, - resourceLabel, - ); - } - } - if (hopError) throw hopError; - assertNetworkOperationActive(signal, resourceLabel); - if (bodyResult) return Object.freeze({ status: response.status, bytes: bodyResult }); - } - } -} - -export interface CapabilityScopedNetworkRuntime { - readonly issuer: NetworkCapabilityIssuer; - readonly transport: CapabilityScopedTransport; - dispose(): void; -} - -/** - * Application composition entry point. It traps the raw-target resolver inside - * the transport and distributes only issuance and purpose-scoped operations. - */ -export function createCapabilityScopedNetworkRuntime( - nameResolver: NetworkNameResolver, - adapter: PinnedNetworkHopAdapter, - scheduler: NetworkScheduler, -): CapabilityScopedNetworkRuntime { - const authority = createNetworkCapabilityAuthority(); - const transport = new CapabilityScopedTransport( - authority.resolver, - nameResolver, - adapter, - scheduler, - ); - let disposed = false; - return Object.freeze({ - issuer: authority.issuer, - transport, - dispose(): void { - if (disposed) return; - disposed = true; - transport.dispose(); - authority.dispose(); - }, - }); -} diff --git a/src/network/DesktopPinnedNetworkAdapter.test.ts b/src/network/DesktopPinnedNetworkAdapter.test.ts deleted file mode 100644 index bf3c11a..0000000 --- a/src/network/DesktopPinnedNetworkAdapter.test.ts +++ /dev/null @@ -1,428 +0,0 @@ -import * as dns from "node:dns"; -import * as http from "node:http"; -import * as https from "node:https"; -import { createPrivateKey } from "node:crypto"; -import type { AddressInfo } from "node:net"; -import * as tls from "node:tls"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createDesktopNetworkPrimitives } from "./DesktopPinnedNetworkAdapter"; -import type { PinnedNetworkHopRequest, PinnedNetworkHopResponse } from "./PinnedNetworkHop"; - -const TEST_CA = `-----BEGIN CERTIFICATE----- -MIIDKTCCAhGgAwIBAgIUKKe57BMvUfGX6MPCr/K2P0a+b+MwDQYJKoZIhvcNAQEL -BQAwGzEZMBcGA1UEAwwQUG9kTm90ZXMgVGVzdCBDQTAgFw0yNjA3MTAxNzM0Mzda -GA8yMTI2MDYxNjE3MzQzN1owGzEZMBcGA1UEAwwQUG9kTm90ZXMgVGVzdCBDQTCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALMd/tbDLftm7RXSGd7Yu1PJ -DxqLmIljG5LqGrNzLIQh3S6n5uKFS7gr4Zu5FQ32bhnDV3WnpmSh4Z3rZuQIpzqL -pG0XAs5z8b0JlCS3obDJHxs/oCz5AD4bOMI9lnRhHhJFBXt3fetNBAFAlvJOA1HQ -tAHG6qSTowwDkJgjmziEMTW9iBeVAaWKWv0IQVTY+eZTn19YnTgot5hC1xWqqYKQ -19bYsi0jMygSGjPb985X2+IAh1xTX61cz3/XkwjynPi31EyqzSez/URj5/7MEaD9 -oXnqbcCoBymfuk6rwtz+nCUMrwg/JTqSCdwi6D9s4RA9sZrA7PhoEbaaAlbtSL8C -AwEAAaNjMGEwHQYDVR0OBBYEFIqSoqO3BzSWe5SqwEoMwJi8C36IMB8GA1UdIwQY -MBaAFIqSoqO3BzSWe5SqwEoMwJi8C36IMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P -AQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IBAQA8pqx5pIoxsnsdxZysYHueiRay -rgEdqiQWGw6Z0eE1V/n3WfjbyqIn6X1EmJJDBq+uDIR6PkxIRQIIKgv5lRm6gJag -xW7SfyvhXubkI3XNoV4vZiNeD/WIxcS5nhDfoKKBLhPFfbK8XPg+4Uja6YBPSc6/ -E9I1x+p5agi+stww0by3LAnNU1zp+5SnsBKH3SbZHiEGUQBJ3o1m66GSXSdgJoKD -pQpHYts3+B/97ngTw6/XJiKyGrb0TLgOU932XhfYwEFCh0vufH50ZwSiqnwboLLv -VeArqAy5qsTPLfXNdiv0LqVv9xKh9c3/oSYEJy1IMYvwbDohADKzPGUO8HxM ------END CERTIFICATE-----`; - -const TEST_SERVER_CERTIFICATE = `-----BEGIN CERTIFICATE----- -MIIDMjCCAhqgAwIBAgIUek61i/3a9on1lgYcLwM0q6fAFUowDQYJKoZIhvcNAQEL -BQAwGzEZMBcGA1UEAwwQUG9kTm90ZXMgVGVzdCBDQTAgFw0yNjA3MTAxNzM0Mzda -GA8yMTI2MDYxNjE3MzQzN1owFDESMBAGA1UEAwwJZmVlZC50ZXN0MIIBIjANBgkq -hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3XbKBj4EdfNcU+7DeUwVEQwfH+w75WCX -IFS1tPwSYQ/EAJJ2/v6e9nU3LUkt+QOY6njME2mb32iOY4f7dx9QX5KDXy75YI+X -MvZBStjxhb/4IqrEmSEEnZgHrVGeZMefMjBQti0KE43iYgKHWijLjQY/gYVIE4/y -rY/CJ/tuZqZF2N8beaAX2v96B6T38ncYKIuZjbW36TNaNrngKyLY0WOMrAOCiWg8 -GKS4NMTZxQa85I/PhJ/X8xWHlEglYU+LpyP3MMzu5NtezcXyqHGhVCAEvjPzAEk0 -RGcZAmlRYPvYEaQUmiNk1hHBepB4ngbV8/PQ7aMmQj3ju/GxAmssFQIDAQABo3Mw -cTAaBgNVHREEEzARgglmZWVkLnRlc3SHBH8AAAEwEwYDVR0lBAwwCgYIKwYBBQUH -AwEwHQYDVR0OBBYEFD6IZ2QOGItQtzIiddYCDP8Rqrp4MB8GA1UdIwQYMBaAFIqS -oqO3BzSWe5SqwEoMwJi8C36IMA0GCSqGSIb3DQEBCwUAA4IBAQBmExcnER58mIdv -auqR6l8wigDDkzX9chDS2MzHNcGMVu1V4q8dln1BauA8lCq7wzxdExxmxYU9xRxb -nEeho8EvDTY95TpFODpb5077PLt22lxBioEGiZ6k2yyeBAva7EPlXNJ5NUa/dntb -Kh2q5/7Ql9gD2W1EQpUeEZ2rci6+Fw+wxrD5ImKTkIeB01QwlirSjFmMzeH4hhjI -sdyC56GC6wifgion1wEnIBnTKxkglzQRX8CIO6ki6UDEk9v8US6rTb+iWjxnrpfd -oP2MlZPfrzkK3rX0XZKK9tddF+PoZzr2zKmmSpLnH2k7bmENgRJ2NivJiIJJeaDN -e/pCawaX ------END CERTIFICATE-----`; - -const TEST_SERVER_PRIVATE_KEY_DER = `MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDddsoGPgR181xT -7sN5TBURDB8f7DvlYJcgVLW0/BJhD8QAknb+/p72dTctSS35A5jqeMwTaZvfaI5j -h/t3H1BfkoNfLvlgj5cy9kFK2PGFv/giqsSZIQSdmAetUZ5kx58yMFC2LQoTjeJi -AodaKMuNBj+BhUgTj/Ktj8In+25mpkXY3xt5oBfa/3oHpPfydxgoi5mNtbfpM1o2 -ueArItjRY4ysA4KJaDwYpLg0xNnFBrzkj8+En9fzFYeUSCVhT4unI/cwzO7k217N -xfKocaFUIAS+M/MASTREZxkCaVFg+9gRpBSaI2TWEcF6kHieBtXz89DtoyZCPeO7 -8bECaywVAgMBAAECggEAKl27a6LJFSxjVP4n4Gg83nhAawm08p4+AvYZlcAkHvPW -ZdqfD4xzqgfF8LCfbhPxWKeXuL37DuvQU3uT0FfJ9yKO5bJVjBKkFOB1oKUsXQ8a -n70l43pkyOYoz8/9ml9y1bXW9ubaVt/NdzzvGkEUIYwYsYUnAvEGdov6E05+8f1R -L0KobPlF0admCQv0oCUtrllvmbAJ7DnW36iHhEQVxmafr74ksbpqc8zsdkFeavpJ -Kecw40RgHZ8DfBLhwiEK7hRgRrTN0D8x//BhzcQLQN58Dl61nF+YQM5yyz2mVM65 -HZfaLr8GD5wl41ASNjMoBrrKDgk5DE8PEsuRfRvUiQKBgQD+0kOY7PVxBtH8MdYX -DX9fQNspTd6f6AEyiQhuqrEEsemFGRYKH44GAjNPk7dLFefHWrWGPkUQgqfyMv1z -H6OAmf+356G500wRdhJups/mbYeCIt4r/1U+ZB3/BQn+Bvj6PJK+JTuDeYyS4CFm -8oySVlhk1aHtOVjqTzwgbp0MnQKBgQDefQbEmP7nw2I0bz993/GGBRsQLgrFuwKL -s6W1gG6u53v88DNfG0hIOcVKUszOAHtjfePYvui0azajdl34ZrTL8tSTGtDPslmm -n2FFxqd2XZvoCWoVqBb4u1FSwuXuqRk7b/u+cwseFINQUOphOPBmTMbNUWmjtsWC -xE9CTqD32QKBgQCKzhhaTgFonxF1MeRvzD9ssJRxTGSPcD5uu6xnM/2XFB6jDMYv -n36qkTpIB6vZb8ZAlo244WSXmMRJLzNWKY7Tt49PKxKyeYG/JTTnJ2CqIVGmNr4U -81II2VppluIZiMzI6oP4palkdap4OzSWslqWQAiIbMJ2yT1A7QZQmFBeFQKBgQDG -AhOB6IAEF/lQnSmuhx3WnrilP7TKoVL0rh9uVy0qcE4CoGu32voLfY6+RM6NEBTA -SQv8nHtSBpEMDY44Qn/peuYBPb5U+m+bQQE7Lj9fspPMgIRvXbeECoGn3FjyfIA+ -S72Kk6xzSuJLGphdimU5Rl8v36La1xEU/k1jv8K2kQKBgGXU8s3Rdpxubboi+clL -XCFKWxYggoOEyQv2/z6RApinWr4UM5mfQoyKUtqILp5MPr1OdlQLNVop+HkRQmZ4 -56MDDIqNQXCcYJfCglHgN8WoaH8F2Y27t1H9nDdstP4pz/JabTM/kcOybSK2hIO2 -KFI5c+f3rpDcC3yZ9ugfxkcf`; - -interface Deferred { - readonly promise: Promise; - resolve(value: T): void; - reject(error: unknown): void; -} - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -async function listen(server: http.Server | https.Server): Promise { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.removeListener("error", reject); - resolve(); - }); - }); - return (server.address() as AddressInfo).port; -} - -async function closeServer(server: http.Server | https.Server): Promise { - server.closeAllConnections(); - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); -} - -async function within(promise: PromiseLike, milliseconds = 2_000): Promise { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - Promise.resolve(promise), - new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new Error("Operation did not settle promptly")), - milliseconds, - ); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function requestFor( - port: number, - signal: AbortSignal, - overrides: Partial = {}, -): PinnedNetworkHopRequest { - return Object.freeze({ - protocol: "http:", - connectAddress: "127.0.0.1", - port, - hostHeader: `feed.test:${port}`, - requestTarget: "/feed.xml", - method: "GET", - credentials: "omit", - redirect: "manual", - signal, - ...overrides, - }); -} - -async function bodyText(response: PinnedNetworkHopResponse): Promise { - const chunks: Uint8Array[] = []; - for await (const chunk of response.body) chunks.push(chunk); - return Buffer.concat(chunks).toString("utf8"); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("createDesktopNetworkPrimitives name resolver", () => { - it("returns a frozen aggregate and snapshots IPv4 and IPv6 answers", async () => { - const prototype = dns.promises.Resolver.prototype; - vi.spyOn(prototype, "resolve4").mockResolvedValue(["93.184.216.34"]); - vi.spyOn(prototype, "resolve6").mockResolvedValue(["2001:4860:4860::8888"]); - const primitives = createDesktopNetworkPrimitives(); - - const addresses = await primitives.nameResolver("feed.test", new AbortController().signal); - - expect(addresses).toEqual(["93.184.216.34", "2001:4860:4860::8888"]); - expect(Object.isFrozen(addresses)).toBe(true); - expect(Object.isFrozen(primitives)).toBe(true); - }); - - it("keeps a successful address family when the other family has no answer", async () => { - const prototype = dns.promises.Resolver.prototype; - vi.spyOn(prototype, "resolve4").mockResolvedValue(["93.184.216.34"]); - vi.spyOn(prototype, "resolve6").mockRejectedValue( - Object.assign(new Error("no IPv6 answer"), { code: "ENODATA" }), - ); - const { nameResolver } = createDesktopNetworkPrimitives(); - - await expect(nameResolver("feed.test", new AbortController().signal)).resolves.toEqual([ - "93.184.216.34", - ]); - }); - - it("synchronously cancels both in-flight address-family queries on abort", async () => { - const ipv4 = deferred(); - const ipv6 = deferred(); - const prototype = dns.promises.Resolver.prototype; - vi.spyOn(prototype, "resolve4").mockImplementation(() => ipv4.promise); - vi.spyOn(prototype, "resolve6").mockImplementation(() => ipv6.promise); - const cancel = vi.spyOn(prototype, "cancel").mockImplementation(() => { - const error = Object.assign(new Error("cancelled"), { code: "ECANCELLED" }); - ipv4.reject(error); - ipv6.reject(error); - }); - const { nameResolver } = createDesktopNetworkPrimitives(); - const controller = new AbortController(); - const reason = new Error("caller cancelled"); - const pending = Promise.resolve(nameResolver("feed.test", controller.signal)); - - controller.abort(reason); - - expect(cancel).toHaveBeenCalledTimes(1); - await expect(within(pending)).rejects.toBe(reason); - }); - - it("does not start DNS work for an already-aborted operation", async () => { - const prototype = dns.promises.Resolver.prototype; - const resolve4 = vi.spyOn(prototype, "resolve4"); - const resolve6 = vi.spyOn(prototype, "resolve6"); - const { nameResolver } = createDesktopNetworkPrimitives(); - const controller = new AbortController(); - const reason = new Error("already cancelled"); - controller.abort(reason); - - await expect(nameResolver("feed.test", controller.signal)).rejects.toBe(reason); - expect(resolve4).not.toHaveBeenCalled(); - expect(resolve6).not.toHaveBeenCalled(); - }); -}); - -describe("createDesktopNetworkPrimitives HTTP adapter", () => { - it("connects to the selected address and preserves Host, path, redirect, and credentials policy", async () => { - const observed = deferred<{ - method?: string; - url?: string; - host?: string; - acceptEncoding?: string; - authorization?: string; - cookie?: string; - }>(); - let requestCount = 0; - const server = http.createServer((request, response) => { - requestCount += 1; - observed.resolve({ - method: request.method, - url: request.url, - host: request.headers.host, - acceptEncoding: request.headers["accept-encoding"], - authorization: request.headers.authorization, - cookie: request.headers.cookie, - }); - response.writeHead(302, { Location: "/redirected" }); - response.end("redirect body"); - }); - const port = await listen(server); - - try { - const { adapter } = createDesktopNetworkPrimitives(); - const response = await adapter( - requestFor(port, new AbortController().signal, { - hostHeader: "feeds.example.test:8443", - requestTarget: "/audio%2fpart?sig=TOP-SECRET%2BVALUE", - }), - ); - - expect(await observed.promise).toEqual({ - method: "GET", - url: "/audio%2fpart?sig=TOP-SECRET%2BVALUE", - host: "feeds.example.test:8443", - acceptEncoding: "identity", - authorization: undefined, - cookie: undefined, - }); - expect(response.status).toBe(302); - expect(response.location).toBe("/redirected"); - expect(response.connectedAddress).toBe("127.0.0.1"); - expect(await bodyText(response)).toBe("redirect body"); - expect(requestCount).toBe(1); - expect(Object.isFrozen(response)).toBe(true); - await response.close(); - } finally { - await closeServer(server); - } - }); - - it("rejects promptly and closes the socket when aborted before response headers", async () => { - const requestSeen = deferred(); - const socketClosed = deferred(); - const server = http.createServer((request) => { - requestSeen.resolve(); - request.socket.once("close", () => socketClosed.resolve()); - }); - const port = await listen(server); - - try { - const { adapter } = createDesktopNetworkPrimitives(); - const controller = new AbortController(); - const reason = new Error("stop before headers"); - const pending = Promise.resolve(adapter(requestFor(port, controller.signal))); - await within(requestSeen.promise); - - controller.abort(reason); - - await expect(within(pending)).rejects.toBe(reason); - await within(socketClosed.promise); - } finally { - await closeServer(server); - } - }); - - it("settles pending body iteration and closes once when aborted mid-response", async () => { - const socketClosed = deferred(); - let closeEvents = 0; - const server = http.createServer((request, response) => { - request.socket.once("close", () => { - closeEvents += 1; - socketClosed.resolve(); - }); - response.on("error", () => undefined); - response.writeHead(200); - response.write("first chunk"); - }); - const port = await listen(server); - - try { - const { adapter } = createDesktopNetworkPrimitives(); - const controller = new AbortController(); - const response = await adapter(requestFor(port, controller.signal)); - const iterator = response.body[Symbol.asyncIterator](); - const first = await within(iterator.next()); - expect(Buffer.from(first.value ?? []).toString("utf8")).toBe("first chunk"); - const pendingBody = iterator.next(); - const reason = new Error("stop body"); - - controller.abort(reason); - - await expect(within(pendingBody)).rejects.toBe(reason); - const firstClose = response.close(); - const secondClose = response.close(); - expect(firstClose).toBe(secondClose); - await within(Promise.resolve(firstClose)); - await within(socketClosed.promise); - expect(closeEvents).toBe(1); - } finally { - await closeServer(server); - } - }); - - it("rejects an abrupt peer close before headers", async () => { - const server = http.createServer(); - server.on("connection", (socket) => socket.destroy()); - const port = await listen(server); - - try { - const { adapter } = createDesktopNetworkPrimitives(); - await expect( - within(Promise.resolve(adapter(requestFor(port, new AbortController().signal)))), - ).rejects.toBeInstanceOf(Error); - } finally { - await closeServer(server); - } - }); -}); - -describe.skipIf(typeof tls.setDefaultCACertificates !== "function")( - "createDesktopNetworkPrimitives HTTPS adapter", - () => { - it("uses normal CA validation and verifies DNS SNI or the literal IP SAN", async () => { - const hosts: Array = []; - const server = https.createServer( - { - key: createPrivateKey({ - key: Buffer.from( - TEST_SERVER_PRIVATE_KEY_DER.replaceAll("\n", ""), - "base64", - ), - format: "der", - type: "pkcs8", - }).export({ format: "pem", type: "pkcs8" }), - cert: TEST_SERVER_CERTIFICATE, - }, - (request, response) => { - hosts.push(request.headers.host); - response.end("secure"); - }, - ); - server.on("tlsClientError", () => undefined); - const port = await listen(server); - const originalCertificates = tls.getCACertificates("default"); - - try { - const { adapter } = createDesktopNetworkPrimitives(); - await expect( - adapter( - requestFor(port, new AbortController().signal, { - protocol: "https:", - serverName: "feed.test", - }), - ), - ).rejects.toBeInstanceOf(Error); - - tls.setDefaultCACertificates([...originalCertificates, TEST_CA]); - const namedResponse = await adapter( - requestFor(port, new AbortController().signal, { - protocol: "https:", - serverName: "feed.test", - }), - ); - expect(await bodyText(namedResponse)).toBe("secure"); - await namedResponse.close(); - - const literalResponse = await adapter( - requestFor(port, new AbortController().signal, { - protocol: "https:", - hostHeader: `127.0.0.1:${port}`, - }), - ); - expect(await bodyText(literalResponse)).toBe("secure"); - await literalResponse.close(); - - await expect( - adapter( - requestFor(port, new AbortController().signal, { - protocol: "https:", - serverName: "wrong.test", - }), - ), - ).rejects.toMatchObject({ code: "ERR_TLS_CERT_ALTNAME_INVALID" }); - expect(hosts).toEqual([`feed.test:${port}`, `127.0.0.1:${port}`]); - } finally { - tls.setDefaultCACertificates(originalCertificates); - await closeServer(server); - } - }); - }, -); diff --git a/src/network/DesktopPinnedNetworkAdapter.ts b/src/network/DesktopPinnedNetworkAdapter.ts deleted file mode 100644 index 304242b..0000000 --- a/src/network/DesktopPinnedNetworkAdapter.ts +++ /dev/null @@ -1,265 +0,0 @@ -/// - -import type { - NetworkNameResolver, - PinnedNetworkHopAdapter, - PinnedNetworkHopResponse, -} from "./PinnedNetworkHop"; - -interface DesktopNodeRequire { - (moduleName: "node:dns"): typeof import("node:dns"); - (moduleName: "node:http"): typeof import("node:http"); - (moduleName: "node:https"): typeof import("node:https"); - (moduleName: "node:tls"): typeof import("node:tls"); -} - -declare const require: DesktopNodeRequire; - -type ClientRequest = import("node:http").ClientRequest; -type IncomingMessage = import("node:http").IncomingMessage; -type Socket = import("node:net").Socket; - -function abortError(signal: AbortSignal): Error { - if (signal.reason instanceof Error) return signal.reason; - const error = new Error("Network operation aborted"); - error.name = "AbortError"; - return error; -} - -function rejected(error: unknown): Promise { - return Promise.reject(error); -} - -function resolveFamily(operation: () => Promise): Promise { - try { - return operation(); - } catch (error) { - return rejected(error); - } -} - -function createNameResolver(dns: typeof import("node:dns")): NetworkNameResolver { - return async (hostname, signal): Promise => { - if (signal.aborted) throw abortError(signal); - - const resolver = new dns.promises.Resolver(); - const cancel = (): void => { - try { - resolver.cancel(); - } catch { - // Cancellation remains best effort if the host resolver is already settled. - } - }; - signal.addEventListener("abort", cancel, { once: true }); - - try { - const ipv4 = resolveFamily(() => resolver.resolve4(hostname)); - const ipv6 = signal.aborted - ? rejected(abortError(signal)) - : resolveFamily(() => resolver.resolve6(hostname)); - const results = await Promise.allSettled([ipv4, ipv6]); - if (signal.aborted) throw abortError(signal); - - const addresses = results.flatMap((result) => - result.status === "fulfilled" ? result.value : [], - ); - if (addresses.length === 0) { - const failure = results.find( - (result): result is PromiseRejectedResult => result.status === "rejected", - ); - throw failure?.reason ?? new Error("Network name resolution failed"); - } - return Object.freeze(addresses); - } finally { - signal.removeEventListener("abort", cancel); - } - }; -} - -async function* responseBody(response: IncomingMessage): AsyncIterable { - for await (const chunk of response) { - if (typeof chunk === "string" || !ArrayBuffer.isView(chunk)) { - throw new TypeError("Network response body was not binary"); - } - yield new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); - } -} - -function createAdapter( - http: typeof import("node:http"), - https: typeof import("node:https"), - tls: typeof import("node:tls"), -): PinnedNetworkHopAdapter { - return (request): Promise => { - if (request.signal.aborted) return rejected(abortError(request.signal)); - - return new Promise((resolve, reject) => { - let clientRequest: ClientRequest | undefined; - let response: IncomingMessage | undefined; - let socket: Socket | undefined; - let adapterSettled = false; - let closeStarted = false; - let requestClosed = false; - let responseClosed = false; - let socketClosed = false; - let closePromise: Promise | undefined; - let resolveClose: (() => void) | undefined; - - const resolveCloseIfComplete = (): void => { - if ( - closeStarted && - (clientRequest === undefined || requestClosed) && - (response === undefined || responseClosed) && - (socket === undefined || socketClosed) - ) { - resolveClose?.(); - } - }; - - const trackSocket = (nextSocket: Socket): void => { - if (socket === nextSocket) return; - socket = nextSocket; - socketClosed = nextSocket.readyState === "closed"; - if (!socketClosed) { - nextSocket.once("close", () => { - socketClosed = true; - resolveCloseIfComplete(); - }); - } - resolveCloseIfComplete(); - }; - - const beginClose = (error?: Error): Promise => { - if (closePromise) return closePromise; - closeStarted = true; - closePromise = new Promise((resolvePromise) => { - resolveClose = resolvePromise; - }); - request.signal.removeEventListener("abort", onAbort); - if (response && !response.destroyed) response.destroy(error); - if (clientRequest && !clientRequest.destroyed) clientRequest.destroy(error); - if (socket && !socket.destroyed) socket.destroy(error); - resolveCloseIfComplete(); - return closePromise; - }; - - const rejectOnceAfterClose = (error: unknown, close: Promise): void => { - if (adapterSettled) return; - adapterSettled = true; - void close.then(() => reject(error)); - }; - - function onAbort(): void { - const error = abortError(request.signal); - rejectOnceAfterClose(error, beginClose(error)); - } - - const onRequestError = (error: Error): void => { - const close = beginClose(error); - rejectOnceAfterClose(error, close); - }; - - const onResponse = (nextResponse: IncomingMessage): void => { - response = nextResponse; - trackSocket(response.socket); - response.once("close", () => { - responseClosed = true; - resolveCloseIfComplete(); - }); - // The async iterator still observes the stored stream error. This listener - // only prevents a socket reset racing ahead of iterator attachment. - response.on("error", () => undefined); - - if (request.signal.aborted) { - onAbort(); - return; - } - const status = response.statusCode; - const connectedAddress = response.socket.remoteAddress; - if (status === undefined || connectedAddress === undefined) { - const error = new Error("Network response did not expose its connected peer"); - rejectOnceAfterClose(error, beginClose(error)); - return; - } - - adapterSettled = true; - resolve( - Object.freeze({ - status, - body: responseBody(response), - connectedAddress, - close: (): Promise => beginClose(), - ...(response.headers.location === undefined - ? {} - : { location: response.headers.location }), - }), - ); - }; - - request.signal.addEventListener("abort", onAbort, { once: true }); - try { - const baseOptions: import("node:http").RequestOptions = { - hostname: request.connectAddress, - port: request.port, - path: request.requestTarget, - method: request.method, - headers: { - Host: request.hostHeader, - "Accept-Encoding": "identity", - }, - setHost: false, - agent: false, - signal: request.signal, - }; - clientRequest = - request.protocol === "https:" - ? https.request({ - ...baseOptions, - rejectUnauthorized: true, - ...(request.serverName === undefined - ? {} - : { servername: request.serverName }), - checkServerIdentity: (_hostname, certificate) => - tls.checkServerIdentity( - request.serverName ?? request.connectAddress, - certificate, - ), - }) - : http.request(baseOptions); - clientRequest.once("response", onResponse); - clientRequest.once("error", onRequestError); - clientRequest.once("close", () => { - requestClosed = true; - resolveCloseIfComplete(); - }); - clientRequest.once("socket", trackSocket); - if (clientRequest.socket) trackSocket(clientRequest.socket); - if (request.signal.aborted) { - onAbort(); - return; - } - clientRequest.end(); - } catch (error) { - rejectOnceAfterClose(error, beginClose(error instanceof Error ? error : undefined)); - } - }); - }; -} - -/** - * Creates the Node-backed primitives for one desktop plugin generation. - * Callers must gate this factory behind Obsidian's desktop platform check. - */ -export function createDesktopNetworkPrimitives(): Readonly<{ - nameResolver: NetworkNameResolver; - adapter: PinnedNetworkHopAdapter; -}> { - const dns = require("node:dns"); - const http = require("node:http"); - const https = require("node:https"); - const tls = require("node:tls"); - return Object.freeze({ - nameResolver: createNameResolver(dns), - adapter: createAdapter(http, https, tls), - }); -} diff --git a/src/network/NetworkCapability.test.ts b/src/network/NetworkCapability.test.ts deleted file mode 100644 index c8bf05d..0000000 --- a/src/network/NetworkCapability.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - EPISODE_NETWORK_RESOURCE_KINDS, - FEED_NETWORK_RESOURCE_KINDS, - MAX_NETWORK_PRIVATE_ORIGINS, - MAX_NETWORK_TARGET_BYTES, - NETWORK_RESOURCE_LABELS, - NetworkCapabilityError, - createNetworkCapabilityAuthority, - isNetworkCapabilityResolver, - type NetworkCapability, - type NetworkCapabilityScope, -} from "./NetworkCapability"; -import type { EpisodeHandle, FeedHandle } from "src/security/resourceHandles"; - -const feedId = `podnotes-feed-${"1a".repeat(32)}` as FeedHandle; -const otherFeedId = `podnotes-feed-${"2b".repeat(32)}` as FeedHandle; -const episodeId = `podnotes-episode-${"3c".repeat(32)}` as EpisodeHandle; -const otherEpisodeId = `podnotes-episode-${"4d".repeat(32)}` as EpisodeHandle; - -const subscriptionScope = Object.freeze({ - resourceKind: "subscription", - feedId, -} as const); - -const streamScope = Object.freeze({ - resourceKind: "episode-stream", - feedId, - episodeId, -} as const); - -function expectResolutionFailure( - resolver: ReturnType["resolver"], - token: NetworkCapability, - expectedScope: NetworkCapabilityScope, -): void { - let thrown: unknown; - try { - resolver.resolve(token, expectedScope); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(NetworkCapabilityError); - expect((thrown as Error).message).toBe("Network capability is invalid or unavailable."); -} - -describe("network capability authority", () => { - it.each(FEED_NETWORK_RESOURCE_KINDS)( - "binds the %s resource to its exact feed", - (resourceKind) => { - const authority = createNetworkCapabilityAuthority(); - const scope = { resourceKind, feedId } as const; - const token = authority.issuer.issue(scope, "https://media.example/resource"); - - const resolution = authority.resolver.resolve(token, scope); - expect(resolution).toMatchObject({ - scope, - target: "https://media.example/resource", - privateOrigins: [], - resourceLabel: NETWORK_RESOURCE_LABELS[resourceKind], - }); - expect(resolution.revocationSignal.aborted).toBe(false); - }, - ); - - it.each(EPISODE_NETWORK_RESOURCE_KINDS)( - "binds the %s resource to its exact feed and episode", - (resourceKind) => { - const authority = createNetworkCapabilityAuthority(); - const scope = { resourceKind, feedId, episodeId } as const; - const token = authority.issuer.issue(scope, "https://media.example/resource"); - - const resolution = authority.resolver.resolve(token, scope); - expect(resolution).toMatchObject({ - scope, - target: "https://media.example/resource", - privateOrigins: [], - resourceLabel: NETWORK_RESOURCE_LABELS[resourceKind], - }); - expect(resolution.revocationSignal.aborted).toBe(false); - }, - ); - - it("preserves exact target bytes without URL normalization", () => { - const authority = createNetworkCapabilityAuthority(); - const target = "HTTPS://Example.COM:443/audio%2fpart.mp3?Signature=A%2BB"; - const token = authority.issuer.issue(streamScope, target); - - expect(authority.resolver.resolve(token, streamScope).target).toBe(target); - }); - - it("copies and freezes every piece of hidden resolution authority", () => { - const authority = createNetworkCapabilityAuthority(); - const mutableScope = { - resourceKind: "episode-stream" as const, - feedId, - episodeId, - }; - const privateOrigins = ["https://private.example:8443"]; - const token = authority.issuer.issue( - mutableScope, - "https://private.example:8443/audio.mp3", - privateOrigins, - ); - - mutableScope.feedId = otherFeedId; - mutableScope.episodeId = otherEpisodeId; - privateOrigins[0] = "https://changed.example"; - privateOrigins.push("https://extra.example"); - - const resolution = authority.resolver.resolve(token, streamScope); - expect(resolution.scope).toEqual(streamScope); - expect(resolution.privateOrigins).toEqual(["https://private.example:8443"]); - expect(Object.isFrozen(resolution)).toBe(true); - expect(Object.isFrozen(resolution.scope)).toBe(true); - expect(Object.isFrozen(resolution.privateOrigins)).toBe(true); - expect(() => { - (resolution.privateOrigins as string[]).push("https://mutated.example"); - }).toThrow(); - expect(() => { - Object.assign(resolution.scope, { feedId: otherFeedId }); - }).toThrow(); - }); - - it("uses frozen property-free tokens that reveal nothing when serialized", () => { - const authority = createNetworkCapabilityAuthority(); - const target = "https://secret.example/feed.xml?token=do-not-log"; - const privateOrigin = "https://private.example"; - const token = authority.issuer.issue(subscriptionScope, target, [privateOrigin]); - - expect(Object.isFrozen(token)).toBe(true); - expect(Object.getPrototypeOf(token)).toBeNull(); - expect(Object.keys(token)).toEqual([]); - expect(Object.getOwnPropertyNames(token)).toEqual([]); - expect(Object.getOwnPropertySymbols(token)).toEqual([]); - expect(Reflect.ownKeys(token)).toEqual([]); - expect(JSON.stringify(token)).toBe("{}"); - expect(JSON.stringify(token)).not.toContain(target); - expect(JSON.stringify(token)).not.toContain(privateOrigin); - expect(() => Object.defineProperty(token, "target", { value: target })).toThrow(); - }); - - it("keeps issuer and resolver authority on separate frozen facets", () => { - const authority = createNetworkCapabilityAuthority(); - - expect(Object.isFrozen(authority)).toBe(true); - expect(Object.isFrozen(authority.issuer)).toBe(true); - expect(Object.isFrozen(authority.resolver)).toBe(true); - expect(Object.keys(authority.issuer).sort()).toEqual(["issue", "revoke"]); - expect(Object.keys(authority.resolver)).toEqual(["resolve"]); - expect("resolve" in authority.issuer).toBe(false); - expect("issue" in authority.resolver).toBe(false); - expect(isNetworkCapabilityResolver(authority.resolver)).toBe(true); - expect(isNetworkCapabilityResolver({ resolve: authority.resolver.resolve })).toBe(false); - }); - - it("rejects forged, cloned, deserialized, primitive, and cross-authority tokens", () => { - const first = createNetworkCapabilityAuthority(); - const second = createNetworkCapabilityAuthority(); - const token = first.issuer.issue(subscriptionScope, "https://secret.example/feed.xml"); - const propertyFreeForgery = Object.freeze(Object.create(null) as object); - const spreadClone = { ...token }; - const deserialized = JSON.parse(JSON.stringify(token)) as unknown; - - for (const candidate of [propertyFreeForgery, spreadClone, deserialized, null, "token"]) { - expectResolutionFailure( - first.resolver, - candidate as NetworkCapability, - subscriptionScope, - ); - } - expectResolutionFailure(second.resolver, token, subscriptionScope); - }); - - it("rejects every cross-scope use before exposing resolution data", () => { - const authority = createNetworkCapabilityAuthority(); - const target = "https://secret.example/audio.mp3?credential=hidden"; - const token = authority.issuer.issue(streamScope, target); - const wrongScopes: NetworkCapabilityScope[] = [ - { resourceKind: "episode-stream", feedId: otherFeedId, episodeId }, - { resourceKind: "episode-stream", feedId, episodeId: otherEpisodeId }, - { resourceKind: "episode-chapters", feedId, episodeId }, - { resourceKind: "subscription", feedId }, - ]; - - for (const wrongScope of wrongScopes) { - let thrown: unknown; - try { - authority.resolver.resolve(token, wrongScope); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(NetworkCapabilityError); - expect((thrown as Error).message).not.toContain(target); - } - }); - - it("rejects malformed issue and expected scopes without invoking accessors", () => { - const authority = createNetworkCapabilityAuthority(); - const target = "https://secret.example/feed.xml?credential=hidden"; - let accessorRead = false; - const accessorScope = { - feedId, - } as Record; - Object.defineProperty(accessorScope, "resourceKind", { - enumerable: true, - get: () => { - accessorRead = true; - return "subscription"; - }, - }); - const symbolScope = { ...subscriptionScope, [Symbol("hidden")]: true }; - const prototypeKeyScope = { ...subscriptionScope } as Record; - Object.defineProperty(prototypeKeyScope, "__proto__", { - enumerable: true, - value: { resourceKind: "subscription", feedId }, - }); - const malformedScopes: unknown[] = [ - {}, - [], - { resourceKind: "subscription", feedId: "https://example.com/feed.xml" }, - { resourceKind: "unknown", feedId }, - { resourceKind: "subscription", feedId, episodeId }, - { resourceKind: "episode-stream", feedId }, - { resourceKind: "episode-stream", feedId, episodeId: "episode-title" }, - { ...subscriptionScope, extra: true }, - Object.create({ resourceKind: "subscription", feedId }), - accessorScope, - symbolScope, - prototypeKeyScope, - ]; - - for (const malformedScope of malformedScopes) { - expect(() => - authority.issuer.issue(malformedScope as NetworkCapabilityScope, target), - ).toThrow(NetworkCapabilityError); - } - expect(accessorRead).toBe(false); - - const token = authority.issuer.issue(subscriptionScope, target); - for (const malformedScope of malformedScopes) { - let thrown: unknown; - try { - authority.resolver.resolve(token, malformedScope as NetworkCapabilityScope); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(NetworkCapabilityError); - expect((thrown as Error).message).not.toContain(target); - } - expect(accessorRead).toBe(false); - }); - - it.each([ - "", - " https://example.com/feed.xml", - "https://example.com/feed.xml ", - "ftp://example.com/feed.xml", - "file:///tmp/audio.mp3", - "not a URL", - "http:example.com/feed.xml", - "http:///example.com/feed.xml", - "https://user:password@example.com/feed.xml", - "https://@example.com/feed.xml", - "https://example.com/feed.xml#fragment", - "https:\\example.com\\feed.xml", - "https://example.com/a\u0000b", - "https://example.com/a\u202eb", - `https://example.com/${"a".repeat(MAX_NETWORK_TARGET_BYTES)}`, - ])("rejects an invalid or unbounded target without echoing it: %s", (target) => { - const authority = createNetworkCapabilityAuthority(); - let thrown: unknown; - try { - authority.issuer.issue(subscriptionScope, target); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(NetworkCapabilityError); - if (target.length > 0) expect((thrown as Error).message).not.toContain(target); - }); - - it("counts target limits in UTF-8 bytes", () => { - const authority = createNetworkCapabilityAuthority(); - const oversized = `https://example.com/${"\u{1f680}".repeat(MAX_NETWORK_TARGET_BYTES / 2)}`; - - expect(() => authority.issuer.issue(subscriptionScope, oversized)).toThrow( - NetworkCapabilityError, - ); - }); - - it.each([ - ["trailing slash", ["https://private.example/"]], - ["path", ["https://private.example/path"]], - ["query", ["https://private.example?key=value"]], - ["fragment", ["https://private.example#section"]], - ["credentials", ["https://user:pass@private.example"]], - ["non-http", ["ftp://private.example"]], - ["non-canonical host", ["https://PRIVATE.example"]], - ["duplicate", ["https://private.example", "https://private.example"]], - ])("rejects invalid private origins: %s", (_name, privateOrigins) => { - const authority = createNetworkCapabilityAuthority(); - expect(() => - authority.issuer.issue( - subscriptionScope, - "https://example.com/feed.xml", - privateOrigins, - ), - ).toThrow(NetworkCapabilityError); - }); - - it("rejects sparse, accessor-backed, decorated, and over-limit private-origin arrays", () => { - const authority = createNetworkCapabilityAuthority(); - const sparse = Array.from({ length: 1 }); - delete sparse[0]; - const accessorBacked: string[] = []; - Object.defineProperty(accessorBacked, "0", { - enumerable: true, - get: () => "https://private.example", - }); - Object.defineProperty(accessorBacked, "length", { value: 1 }); - const decorated = ["https://private.example"]; - Object.defineProperty(decorated, "extra", { value: true, enumerable: true }); - const tooMany = Array.from( - { length: MAX_NETWORK_PRIVATE_ORIGINS + 1 }, - (_, index) => `https://private-${index}.example`, - ); - const revoked = Proxy.revocable([], {}); - revoked.revoke(); - - for (const privateOrigins of [sparse, accessorBacked, decorated, tooMany, revoked.proxy]) { - expect(() => - authority.issuer.issue( - subscriptionScope, - "https://example.com/feed.xml", - privateOrigins, - ), - ).toThrow(NetworkCapabilityError); - } - }); - - it("revokes individual tokens and disposes the complete authority", () => { - const authority = createNetworkCapabilityAuthority(); - const first = authority.issuer.issue(subscriptionScope, "https://example.com/feed.xml"); - const second = authority.issuer.issue(streamScope, "https://example.com/audio.mp3"); - const firstSignal = authority.resolver.resolve(first, subscriptionScope).revocationSignal; - const secondResolution = authority.resolver.resolve(second, streamScope); - const secondSignal = secondResolution.revocationSignal; - const authoritySignal = secondResolution.authoritySignal; - - expect(authority.issuer.revoke(first)).toBe(true); - expect(firstSignal.aborted).toBe(true); - expect(secondSignal.aborted).toBe(false); - expect(authoritySignal.aborted).toBe(false); - expect(authority.issuer.revoke(first)).toBe(false); - expect(authority.issuer.revoke(Object.freeze({}))).toBe(false); - expectResolutionFailure(authority.resolver, first, subscriptionScope); - expect(authority.resolver.resolve(second, streamScope).target).toBe( - "https://example.com/audio.mp3", - ); - - authority.dispose(); - expect(secondSignal.aborted).toBe(false); - expect(authoritySignal.aborted).toBe(true); - expectResolutionFailure(authority.resolver, second, streamScope); - expect(authority.issuer.revoke(second)).toBe(false); - expect(() => - authority.issuer.issue(subscriptionScope, "https://example.com/new.xml"), - ).toThrow(NetworkCapabilityError); - }); - - it("never derives resource labels from targets, origins, titles, or handles", () => { - const authority = createNetworkCapabilityAuthority(); - const target = "https://sensitive.example/private-feed-name.xml"; - const origin = "https://sensitive.example"; - const token = authority.issuer.issue(subscriptionScope, target, [origin]); - const { resourceLabel } = authority.resolver.resolve(token, subscriptionScope); - - expect(resourceLabel).toBe("podcast subscription"); - expect(resourceLabel).not.toContain(target); - expect(resourceLabel).not.toContain(origin); - expect(resourceLabel).not.toContain(feedId); - }); -}); diff --git a/src/network/NetworkCapability.ts b/src/network/NetworkCapability.ts deleted file mode 100644 index dc1ee6f..0000000 --- a/src/network/NetworkCapability.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - isEpisodeHandle, - isFeedHandle, - type EpisodeHandle, - type FeedHandle, -} from "src/security/resourceHandles"; - -import { parseTargetPolicyView } from "./LiteralTargetClassifier"; - -export { MAX_NETWORK_TARGET_BYTES } from "./LiteralTargetClassifier"; -export const MAX_NETWORK_PRIVATE_ORIGINS = 16; - -export const FEED_NETWORK_RESOURCE_KINDS = ["subscription", "feed-artwork", "site"] as const; -export const EPISODE_NETWORK_RESOURCE_KINDS = [ - "episode-stream", - "episode-chapters", - "episode-artwork", - "episode-item-link", -] as const; - -export type FeedNetworkResourceKind = (typeof FEED_NETWORK_RESOURCE_KINDS)[number]; -export type EpisodeNetworkResourceKind = (typeof EPISODE_NETWORK_RESOURCE_KINDS)[number]; -export type NetworkResourceKind = FeedNetworkResourceKind | EpisodeNetworkResourceKind; - -export interface FeedNetworkCapabilityScope { - readonly resourceKind: FeedNetworkResourceKind; - readonly feedId: FeedHandle; -} - -export interface EpisodeNetworkCapabilityScope { - readonly resourceKind: EpisodeNetworkResourceKind; - readonly feedId: FeedHandle; - readonly episodeId: EpisodeHandle; -} - -export type NetworkCapabilityScope = FeedNetworkCapabilityScope | EpisodeNetworkCapabilityScope; - -export const NETWORK_RESOURCE_LABELS = Object.freeze({ - subscription: "podcast subscription", - "feed-artwork": "podcast artwork", - site: "podcast site", - "episode-stream": "episode audio", - "episode-chapters": "episode chapters", - "episode-artwork": "episode artwork", - "episode-item-link": "episode page", -} as const satisfies Record); - -export type NetworkResourceLabel = (typeof NETWORK_RESOURCE_LABELS)[NetworkResourceKind]; - -declare const networkCapabilityBrand: unique symbol; - -/** - * An opaque authority token. The brand exists only to help TypeScript. Runtime - * tokens are frozen, null-prototype objects with no own properties or symbols. - */ -export interface NetworkCapability { - readonly [networkCapabilityBrand]: Scope; -} - -export interface NetworkCapabilityResolution< - Scope extends NetworkCapabilityScope = NetworkCapabilityScope, -> { - readonly scope: Readonly; - readonly target: string; - readonly privateOrigins: readonly string[]; - readonly resourceLabel: NetworkResourceLabel; - readonly revocationSignal: AbortSignal; - readonly authoritySignal: AbortSignal; -} - -export interface NetworkCapabilityIssuer { - issue( - scope: Scope, - target: string, - privateOrigins?: readonly string[], - ): NetworkCapability; - revoke(capability: unknown): boolean; -} - -export interface NetworkCapabilityResolver { - resolve( - capability: NetworkCapability, - expectedScope: Scope, - ): NetworkCapabilityResolution; -} - -/** - * Construction-only object. Application composition should distribute the - * issuer and resolver facets independently to keep target issuance separate - * from transport resolution. - */ -export interface NetworkCapabilityAuthority { - readonly issuer: NetworkCapabilityIssuer; - readonly resolver: NetworkCapabilityResolver; - dispose(): void; -} - -export class NetworkCapabilityError extends Error { - constructor(operation: "issue" | "resolve") { - super( - operation === "issue" - ? "Invalid network capability request." - : "Network capability is invalid or unavailable.", - ); - this.name = "NetworkCapabilityError"; - } -} - -type UnknownRecord = Record; - -const feedResourceKinds = new Set(FEED_NETWORK_RESOURCE_KINDS); -const episodeResourceKinds = new Set(EPISODE_NETWORK_RESOURCE_KINDS); -const authenticResolvers = new WeakSet(); - -function isBoundedHttpTarget(value: unknown): value is string { - const policy = parseTargetPolicyView(value); - return policy.ok && !policy.value.hasCredentials; -} - -function isExactPrivateOrigin(value: unknown): value is string { - if (!isBoundedHttpTarget(value)) return false; - try { - const parsed = new URL(value); - return ( - !parsed.username && - !parsed.password && - parsed.pathname === "/" && - !parsed.search && - !parsed.hash && - parsed.origin === value - ); - } catch { - return false; - } -} - -function copyPrivateOrigins(value: unknown): readonly string[] | null { - if (value === undefined) return Object.freeze([] as string[]); - try { - if (!Array.isArray(value)) return null; - const length = value.length; - if (length > MAX_NETWORK_PRIVATE_ORIGINS) return null; - const ownKeys = Reflect.ownKeys(value); - if (ownKeys.length !== length + 1 || !ownKeys.includes("length")) return null; - - const origins: string[] = []; - for (let index = 0; index < length; index += 1) { - const key = String(index); - if (!ownKeys.includes(key)) return null; - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor?.enumerable || !("value" in descriptor)) return null; - if (!isExactPrivateOrigin(descriptor.value)) return null; - origins.push(descriptor.value); - } - if (new Set(origins).size !== origins.length) return null; - return Object.freeze(origins); - } catch { - return null; - } -} - -function strictDataRecord(value: unknown): UnknownRecord | null { - try { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) return null; - - const record = Object.create(null) as UnknownRecord; - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== "string") return null; - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor?.enumerable || !("value" in descriptor)) return null; - record[key] = descriptor.value; - } - return record; - } catch { - return null; - } -} - -function hasExactKeys(record: UnknownRecord, expected: readonly string[]): boolean { - const keys = Object.keys(record); - return keys.length === expected.length && expected.every((key) => keys.includes(key)); -} - -function copyScope(value: unknown): NetworkCapabilityScope | null { - const record = strictDataRecord(value); - if (!record || typeof record.resourceKind !== "string" || !isFeedHandle(record.feedId)) { - return null; - } - - if (feedResourceKinds.has(record.resourceKind)) { - if (!hasExactKeys(record, ["resourceKind", "feedId"])) return null; - return Object.freeze({ - resourceKind: record.resourceKind as FeedNetworkResourceKind, - feedId: record.feedId, - }); - } - - if (episodeResourceKinds.has(record.resourceKind)) { - if ( - !hasExactKeys(record, ["resourceKind", "feedId", "episodeId"]) || - !isEpisodeHandle(record.episodeId) - ) { - return null; - } - return Object.freeze({ - resourceKind: record.resourceKind as EpisodeNetworkResourceKind, - feedId: record.feedId, - episodeId: record.episodeId, - }); - } - - return null; -} - -function scopesMatch(left: NetworkCapabilityScope, right: NetworkCapabilityScope): boolean { - if (left.resourceKind !== right.resourceKind || left.feedId !== right.feedId) return false; - if (episodeResourceKinds.has(left.resourceKind)) { - return "episodeId" in left && "episodeId" in right && left.episodeId === right.episodeId; - } - return !("episodeId" in left) && !("episodeId" in right); -} - -function isObject(value: unknown): value is object { - return (typeof value === "object" && value !== null) || typeof value === "function"; -} - -/** Runtime authentication for the resolver facet issued by this module. */ -export function isNetworkCapabilityResolver(value: unknown): value is NetworkCapabilityResolver { - return isObject(value) && authenticResolvers.has(value); -} - -/** - * Low-level composition primitive. Never distribute the returned aggregate; - * application code must trap its resolver inside the network runtime and expose - * only the issuer plus purpose-scoped operations. - */ -export function createNetworkCapabilityAuthority(): NetworkCapabilityAuthority { - const resolutions = new WeakMap< - object, - { - readonly resolution: NetworkCapabilityResolution; - readonly revocation: AbortController; - } - >(); - const authorityRevocation = new AbortController(); - let disposed = false; - - const issuer: NetworkCapabilityIssuer = Object.freeze({ - issue( - scope: Scope, - target: string, - privateOrigins?: readonly string[], - ): NetworkCapability { - const copiedScope = copyScope(scope); - if (disposed || !copiedScope || !isBoundedHttpTarget(target)) { - throw new NetworkCapabilityError("issue"); - } - const copiedPrivateOrigins = copyPrivateOrigins(privateOrigins); - if (!copiedPrivateOrigins) throw new NetworkCapabilityError("issue"); - - const token = Object.freeze(Object.create(null) as object); - const revocation = new AbortController(); - const resolution = Object.freeze({ - scope: copiedScope, - target, - privateOrigins: copiedPrivateOrigins, - resourceLabel: NETWORK_RESOURCE_LABELS[copiedScope.resourceKind], - revocationSignal: revocation.signal, - authoritySignal: authorityRevocation.signal, - }); - resolutions.set(token, { resolution, revocation }); - return token as NetworkCapability; - }, - revoke(capability: unknown): boolean { - if (disposed || !isObject(capability)) return false; - const entry = resolutions.get(capability); - if (!entry || !resolutions.delete(capability)) return false; - entry.revocation.abort(); - return true; - }, - }); - - const resolver: NetworkCapabilityResolver = Object.freeze({ - resolve( - capability: NetworkCapability, - expectedScope: Scope, - ): NetworkCapabilityResolution { - const copiedExpectedScope = copyScope(expectedScope); - if (!copiedExpectedScope || disposed || !isObject(capability)) { - throw new NetworkCapabilityError("resolve"); - } - - const entry = resolutions.get(capability); - if (!entry || !scopesMatch(entry.resolution.scope, copiedExpectedScope)) { - throw new NetworkCapabilityError("resolve"); - } - return entry.resolution as NetworkCapabilityResolution; - }, - }); - authenticResolvers.add(resolver); - - return Object.freeze({ - issuer, - resolver, - dispose(): void { - if (disposed) return; - disposed = true; - authorityRevocation.abort(); - }, - }); -} diff --git a/src/network/NetworkErrors.test.ts b/src/network/NetworkErrors.test.ts deleted file mode 100644 index dcbb080..0000000 --- a/src/network/NetworkErrors.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - CAPABILITY_TRANSPORT_ERROR_CODES, - CapabilityTransportError, - createCapabilityTransportError, - createNetworkResourceLabel, - isNetworkResourceLabel, - MAX_NETWORK_RESOURCE_LABEL_LENGTH, - NETWORK_ERROR_CODES, - NETWORK_STATIC_RESOURCE_LABELS, - NetworkDeadlineError, - NetworkDisposedError, - NetworkInvariantError, - NetworkOperationError, - NetworkQueueFullError, - NetworkSchedulerError, - sanitizeCapabilityTransportError, - type CapabilityTransportErrorCode, - type NetworkResourceLabel, -} from "./NetworkErrors"; - -describe("network resource labels", () => { - it.each([ - "feed metadata", - "episode media", - "provider operation", - `podnotes-feed-${"a".repeat(64)}`, - `podnotes-episode-${"0".repeat(64)}`, - ])("accepts the explicit target-free label %s", (value) => { - expect(isNetworkResourceLabel(value)).toBe(true); - expect(createNetworkResourceLabel(value)).toBe(value); - }); - - it.each([ - "", - "Feed metadata", - "sk-secret", - "private token", - "episode-media", - "provider operation 2", - "https://secret.example/feed?token=raw", - "secret.example", - "localhost:8080", - "feed/path", - "feed\\path", - "feed?token", - "feed#fragment", - "user@example", - "token=value", - "space doubled", - " leading", - "trailing ", - "one two three four five six seven", - "x".repeat(MAX_NETWORK_RESOURCE_LABEL_LENGTH + 1), - `podnotes-feed-${"g".repeat(64)}`, - `podnotes-episode-${"a".repeat(63)}`, - ])("rejects non-label input without echoing it: %s", (value) => { - expect(isNetworkResourceLabel(value)).toBe(false); - let caught: unknown; - try { - createNetworkResourceLabel(value); - } catch (error) { - caught = error; - } - expect(caught).toBeInstanceOf(TypeError); - if (value.length > 0) expect(String(caught)).not.toContain(value); - }); - - it("rejects non-string values", () => { - expect(isNetworkResourceLabel(undefined)).toBe(false); - expect(isNetworkResourceLabel({ toString: () => "feed metadata" })).toBe(false); - }); - - it("keeps the static application-label registry immutable", () => { - expect(Object.isFrozen(NETWORK_STATIC_RESOURCE_LABELS)).toBe(true); - expect(NETWORK_STATIC_RESOURCE_LABELS).toContain("podcast subscription"); - expect(() => - (NETWORK_STATIC_RESOURCE_LABELS as unknown as string[]).push("secret"), - ).toThrow(); - }); -}); - -describe("capability transport error authority", () => { - const label = createNetworkResourceLabel("feed metadata"); - - it("rejects unregistered runtime codes and labels before trusting an error", () => { - expect(() => - createCapabilityTransportError( - "NETWORK_INVENTED" as CapabilityTransportErrorCode, - label, - ), - ).toThrow(TypeError); - expect(() => - createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.operationFailed, - "https://secret.example/feed?token=raw" as NetworkResourceLabel, - ), - ).toThrow(TypeError); - }); - - it("sanitizes a directly constructed error that never passed the trusted mint", () => { - const forged = new CapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.responseTooLarge, - "https://secret.example/feed?token=raw" as NetworkResourceLabel, - ); - - const sanitized = sanitizeCapabilityTransportError(forged, label); - - expect(sanitized).not.toBe(forged); - expect(sanitized.code).toBe(CAPABILITY_TRANSPORT_ERROR_CODES.operationFailed); - expect(sanitized.resourceLabel).toBe(label); - expect(String(sanitized)).not.toContain("secret.example"); - }); -}); - -describe("network scheduler errors", () => { - const label = createNetworkResourceLabel("feed metadata"); - - it.each([ - [NetworkDeadlineError, "NetworkDeadlineError", NETWORK_ERROR_CODES.deadlineExceeded], - [NetworkDisposedError, "NetworkDisposedError", NETWORK_ERROR_CODES.sessionDisposed], - [NetworkQueueFullError, "NetworkQueueFullError", NETWORK_ERROR_CODES.queueFull], - [NetworkOperationError, "NetworkOperationError", NETWORK_ERROR_CODES.operationFailed], - [NetworkInvariantError, "NetworkInvariantError", NETWORK_ERROR_CODES.internalInvariant], - ] as const)("exposes only the stable %s contract", (ErrorType, name, code) => { - const error = new ErrorType(label); - - expect(error).toBeInstanceOf(NetworkSchedulerError); - expect(error.name).toBe(name); - expect(error.code).toBe(code); - expect(error.resourceLabel).toBe(label); - expect(error.message).toBe(`${code}: ${label}`); - expect(error).not.toHaveProperty("cause"); - }); - - it("keeps the stable code table immutable", () => { - expect(Object.isFrozen(NETWORK_ERROR_CODES)).toBe(true); - expect(() => Object.assign(NETWORK_ERROR_CODES, { queueFull: "CHANGED" })).toThrow(); - expect(NETWORK_ERROR_CODES.queueFull).toBe("NETWORK_QUEUE_FULL"); - }); -}); diff --git a/src/network/NetworkErrors.ts b/src/network/NetworkErrors.ts deleted file mode 100644 index 4bc695d..0000000 --- a/src/network/NetworkErrors.ts +++ /dev/null @@ -1,180 +0,0 @@ -const HANDLE_RESOURCE_LABEL_PATTERN = /^podnotes-(?:feed|episode)-[0-9a-f]{64}$/; - -export const NETWORK_STATIC_RESOURCE_LABELS = Object.freeze([ - "podcast subscription", - "podcast artwork", - "podcast site", - "episode audio", - "episode chapters", - "episode artwork", - "episode page", - "feed metadata", - "episode media", - "provider operation", -] as const); - -const staticResourceLabels = new Set(NETWORK_STATIC_RESOURCE_LABELS); - -export const MAX_NETWORK_RESOURCE_LABEL_LENGTH = 96; - -declare const networkResourceLabelBrand: unique symbol; - -/** - * An application-owned description or opaque handle that is safe to expose in - * an error. Raw URLs, origins, paths, response data, and provider messages are - * deliberately outside this type's runtime grammar. - */ -export type NetworkResourceLabel = string & { - readonly [networkResourceLabelBrand]: true; -}; - -export const NETWORK_ERROR_CODES = Object.freeze({ - deadlineExceeded: "NETWORK_DEADLINE_EXCEEDED", - sessionDisposed: "NETWORK_SESSION_DISPOSED", - queueFull: "NETWORK_QUEUE_FULL", - operationFailed: "NETWORK_OPERATION_FAILED", - internalInvariant: "NETWORK_INTERNAL_INVARIANT", -} as const); - -export type NetworkErrorCode = (typeof NETWORK_ERROR_CODES)[keyof typeof NETWORK_ERROR_CODES]; - -export const CAPABILITY_TRANSPORT_ERROR_CODES = Object.freeze({ - resourceRejected: "NETWORK_RESOURCE_REJECTED", - targetRejected: "NETWORK_TARGET_REJECTED", - addressRejected: "NETWORK_ADDRESS_REJECTED", - adapterResponseInvalid: "NETWORK_ADAPTER_RESPONSE_INVALID", - redirectRejected: "NETWORK_REDIRECT_REJECTED", - redirectLimit: "NETWORK_REDIRECT_LIMIT", - statusRejected: "NETWORK_STATUS_REJECTED", - responseTooLarge: "NETWORK_RESPONSE_TOO_LARGE", - operationFailed: "NETWORK_OPERATION_FAILED", -} as const); - -export type CapabilityTransportErrorCode = - (typeof CAPABILITY_TRANSPORT_ERROR_CODES)[keyof typeof CAPABILITY_TRANSPORT_ERROR_CODES]; - -const capabilityTransportErrorCodes = new Set( - Object.values(CAPABILITY_TRANSPORT_ERROR_CODES), -); -const internallyIssuedCapabilityErrors = new WeakSet(); - -export function isNetworkResourceLabel(value: unknown): value is NetworkResourceLabel { - if (typeof value !== "string" || value.length > MAX_NETWORK_RESOURCE_LABEL_LENGTH) { - return false; - } - - return staticResourceLabels.has(value) || HANDLE_RESOURCE_LABEL_PATTERN.test(value); -} - -export function createNetworkResourceLabel(value: string): NetworkResourceLabel { - if (!isNetworkResourceLabel(value)) { - throw new TypeError( - "Network resource labels must be registered application labels or PodNotes handles.", - ); - } - return value; -} - -/** Stable target-free error from the capability-scoped transport boundary. */ -export class CapabilityTransportError extends Error { - constructor( - public readonly code: CapabilityTransportErrorCode, - public readonly resourceLabel: NetworkResourceLabel, - ) { - super(`${code}: ${resourceLabel}`); - this.name = "CapabilityTransportError"; - } -} - -/** @internal Creates errors that may safely cross an adapter boundary. */ -export function createCapabilityTransportError( - code: CapabilityTransportErrorCode, - resourceLabel: NetworkResourceLabel, -): CapabilityTransportError { - if (!capabilityTransportErrorCodes.has(code) || !isNetworkResourceLabel(resourceLabel)) { - throw new TypeError( - "Capability transport errors require a registered code and safe label.", - ); - } - const error = new CapabilityTransportError(code, resourceLabel); - internallyIssuedCapabilityErrors.add(error); - return Object.freeze(error); -} - -/** @internal Drops arbitrary resolver, adapter, and response failure details. */ -export function sanitizeCapabilityTransportError( - error: unknown, - resourceLabel: NetworkResourceLabel, -): CapabilityTransportError { - return typeof error === "object" && - error !== null && - internallyIssuedCapabilityErrors.has(error) - ? (error as CapabilityTransportError) - : createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.operationFailed, - resourceLabel, - ); -} - -/** @internal Shared cancellation check for bounded network operations. */ -export function assertNetworkOperationActive( - signal: AbortSignal, - resourceLabel: NetworkResourceLabel, -): void { - if (signal.aborted) { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.operationFailed, - resourceLabel, - ); - } -} - -/** - * Scheduler errors intentionally expose only a stable code and the explicit - * target-free label supplied by application code. Raw adapter failures are - * never attached as `cause` or interpolated into the message. - */ -export class NetworkSchedulerError extends Error { - constructor( - public readonly code: NetworkErrorCode, - public readonly resourceLabel: NetworkResourceLabel, - ) { - super(`${code}: ${resourceLabel}`); - this.name = "NetworkSchedulerError"; - } -} - -export class NetworkDeadlineError extends NetworkSchedulerError { - constructor(resourceLabel: NetworkResourceLabel) { - super(NETWORK_ERROR_CODES.deadlineExceeded, resourceLabel); - this.name = "NetworkDeadlineError"; - } -} - -export class NetworkDisposedError extends NetworkSchedulerError { - constructor(resourceLabel: NetworkResourceLabel) { - super(NETWORK_ERROR_CODES.sessionDisposed, resourceLabel); - this.name = "NetworkDisposedError"; - } -} - -export class NetworkQueueFullError extends NetworkSchedulerError { - constructor(resourceLabel: NetworkResourceLabel) { - super(NETWORK_ERROR_CODES.queueFull, resourceLabel); - this.name = "NetworkQueueFullError"; - } -} - -export class NetworkOperationError extends NetworkSchedulerError { - constructor(resourceLabel: NetworkResourceLabel) { - super(NETWORK_ERROR_CODES.operationFailed, resourceLabel); - this.name = "NetworkOperationError"; - } -} - -export class NetworkInvariantError extends NetworkSchedulerError { - constructor(resourceLabel: NetworkResourceLabel) { - super(NETWORK_ERROR_CODES.internalInvariant, resourceLabel); - this.name = "NetworkInvariantError"; - } -} diff --git a/src/network/NetworkScheduler.test.ts b/src/network/NetworkScheduler.test.ts deleted file mode 100644 index 4500b8b..0000000 --- a/src/network/NetworkScheduler.test.ts +++ /dev/null @@ -1,855 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - createNetworkResourceLabel, - NETWORK_ERROR_CODES, - NetworkDeadlineError, - NetworkDisposedError, - NetworkInvariantError, - NetworkOperationError, - NetworkQueueFullError, - type NetworkResourceLabel, -} from "./NetworkErrors"; -import { - DEFAULT_NETWORK_LANE_LIMITS, - MAX_NETWORK_DEADLINE_MS, - NetworkScheduler, - type NetworkClock, - type NetworkLane, - type NetworkSchedulerOptions, - type NetworkTask, -} from "./NetworkScheduler"; - -interface Deferred { - readonly promise: Promise; - resolve(value: T): void; - reject(error: unknown): void; -} - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -class ManualClock implements NetworkClock { - private nextHandle = 1; - private nowMs = 0; - private callbacks = new Map void; dueAt: number; delayMs: number }>(); - - now(): number { - return this.nowMs; - } - - setTimeout(callback: () => void, delayMs: number): number { - const handle = this.nextHandle; - this.nextHandle += 1; - this.callbacks.set(handle, { callback, dueAt: this.nowMs + delayMs, delayMs }); - return handle; - } - - clearTimeout(handle: unknown): void { - this.callbacks.delete(handle as number); - } - - advance(milliseconds: number): void { - this.nowMs += milliseconds; - } - - fire(handle: number): void { - const scheduled = this.callbacks.get(handle); - this.callbacks.delete(handle); - scheduled?.callback(); - } - - fireAtDueTime(handle: number): void { - const scheduled = this.callbacks.get(handle); - if (!scheduled) return; - this.nowMs = Math.max(this.nowMs, scheduled.dueAt); - this.fire(handle); - } - - pendingHandles(): number[] { - return [...this.callbacks.keys()]; - } - - pendingDelays(): number[] { - return [...this.callbacks.values()].map(({ delayMs }) => delayMs); - } -} - -const FEED_LABEL = createNetworkResourceLabel("feed metadata"); -const EPISODE_LABEL = createNetworkResourceLabel("episode media"); -const PROVIDER_LABEL = createNetworkResourceLabel("provider operation"); - -function task( - operation: (signal: AbortSignal) => T | PromiseLike, - overrides: Partial, "operation">> = {}, -): NetworkTask { - return { - lane: "metadata", - resourceLabel: FEED_LABEL, - timeoutMs: 30_000, - operation, - ...overrides, - }; -} - -async function flushScheduler(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} - -describe("NetworkScheduler construction and validation", () => { - it("publishes deeply frozen defaults and immutable snapshots", () => { - expect(Object.isFrozen(DEFAULT_NETWORK_LANE_LIMITS)).toBe(true); - expect(Object.isFrozen(DEFAULT_NETWORK_LANE_LIMITS.metadata)).toBe(true); - - const scheduler = new NetworkScheduler(); - const snapshot = scheduler.getSnapshot(); - expect(snapshot).toEqual({ - metadata: { active: 0, queued: 0, maxActive: 4, maxQueued: 512, failed: false }, - media: { active: 0, queued: 0, maxActive: 2, maxQueued: 32, failed: false }, - provider: { active: 0, queued: 0, maxActive: 2, maxQueued: 16, failed: false }, - }); - expect(Object.isFrozen(snapshot)).toBe(true); - expect(Object.isFrozen(snapshot.metadata)).toBe(true); - expect(() => Object.assign(snapshot.metadata, { maxActive: 99 })).toThrow(); - }); - - it("copies injected limits once and never lets later sessions reconfigure them", () => { - const limits = { - metadata: { maxActive: 1, maxQueued: 3 }, - media: { maxActive: 5, maxQueued: 7 }, - }; - const scheduler = new NetworkScheduler({ laneLimits: limits }); - const first = scheduler.createSession(); - - limits.metadata.maxActive = 100; - limits.metadata.maxQueued = 100; - const second = scheduler.createSession(); - - expect(first.getLaneSnapshot("metadata")).toEqual({ - active: 0, - queued: 0, - maxActive: 1, - maxQueued: 3, - failed: false, - }); - expect(second.getLaneSnapshot("media")).toMatchObject({ maxActive: 5, maxQueued: 7 }); - }); - - it("rejects inherited limit entries and inherited fields", () => { - const inheritedLimits = Object.create({ - metadata: { maxActive: 100, maxQueued: 100 }, - }) as Record; - const metadata = Object.create({ maxActive: 99 }) as { maxQueued: number }; - metadata.maxQueued = 1; - inheritedLimits.media = metadata; - - expect( - () => - new NetworkScheduler({ - laneLimits: inheritedLimits as NetworkSchedulerOptions["laneLimits"], - }), - ).toThrow("Network scheduler options are invalid"); - }); - - it.each([ - { laneLimits: null }, - { laneLimits: { metadata: null } }, - { laneLimits: { metdata: { maxActive: 1 } } }, - { laneLimits: { metadata: { maxActve: 1 } } }, - { laneLimits: { metadata: { maxActive: undefined } } }, - { unknown: true }, - ] as const)("rejects malformed scheduler configuration: %j", (options) => { - expect(() => new NetworkScheduler(options as unknown as NetworkSchedulerOptions)).toThrow( - TypeError, - ); - }); - - it.each([ - ["maxActive", 0], - ["maxActive", -1], - ["maxActive", 1.5], - ["maxActive", Number.NaN], - ["maxActive", Number.POSITIVE_INFINITY], - ["maxQueued", -1], - ["maxQueued", 1.5], - ["maxQueued", Number.NaN], - ["maxQueued", Number.POSITIVE_INFINITY], - ] as const)("rejects invalid lane limit %s=%s", (field, value) => { - expect( - () => - new NetworkScheduler({ - laneLimits: { metadata: { [field]: value } }, - }), - ).toThrow(TypeError); - }); - - it.each(["constructor", "toString", "__proto__", "Metadata", ""])( - "rejects hostile or unknown lane %s", - async (lane) => { - const session = new NetworkScheduler().createSession(); - const operation = vi.fn(); - - await expect( - session.schedule(task(operation, { lane: lane as NetworkLane })), - ).rejects.toThrow("Network task is invalid"); - expect(operation).not.toHaveBeenCalled(); - expect(() => session.getLaneSnapshot(lane as NetworkLane)).toThrow(TypeError); - }, - ); - - it("requires task fields to be own properties", async () => { - const operation = vi.fn(); - const inherited = Object.create({ lane: "metadata" }) as Record; - inherited.resourceLabel = FEED_LABEL; - inherited.timeoutMs = 1_000; - inherited.operation = operation; - const session = new NetworkScheduler().createSession(); - - await expect(session.schedule(inherited as unknown as NetworkTask)).rejects.toThrow( - "Network task is invalid", - ); - expect(operation).not.toHaveBeenCalled(); - }); - - it("rejects accessor and proxy task traps without exposing their errors", async () => { - const sentinel = "https://private.example/feed?token=TASK-SECRET"; - let accessorRead = false; - const accessorTask = task(() => undefined) as unknown as Record; - Object.defineProperty(accessorTask, "lane", { - enumerable: true, - get: () => { - accessorRead = true; - return "metadata"; - }, - }); - const trappedTask = new Proxy( - task(() => undefined), - { - ownKeys: () => { - throw new Error(sentinel); - }, - }, - ); - const session = new NetworkScheduler().createSession(); - - for (const hostile of [accessorTask, trappedTask]) { - const error = await session - .schedule(hostile as unknown as NetworkTask) - .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(TypeError); - expect(String(error)).toBe("TypeError: Network task is invalid."); - expect(String(error)).not.toContain(sentinel); - } - expect(accessorRead).toBe(false); - }); - - it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, MAX_NETWORK_DEADLINE_MS + 1])( - "rejects unsafe deadline %s without starting", - async (timeoutMs) => { - const operation = vi.fn(); - const session = new NetworkScheduler().createSession(); - await expect(session.schedule(task(operation, { timeoutMs }))).rejects.toThrow( - TypeError, - ); - expect(operation).not.toHaveBeenCalled(); - }, - ); - - it("accepts the maximum safe timer delay without truncation", async () => { - const clock = new ManualClock(); - const pending = deferred(); - const session = new NetworkScheduler({ clock }).createSession(); - const scheduled = session.schedule( - task(() => pending.promise, { timeoutMs: MAX_NETWORK_DEADLINE_MS }), - ); - - expect(clock.pendingDelays()).toEqual([MAX_NETWORK_DEADLINE_MS]); - session.dispose(); - await expect(scheduled).rejects.toBeInstanceOf(NetworkDisposedError); - pending.resolve(); - await flushScheduler(); - }); - - it("snapshots the injected clock methods", async () => { - const clock = new ManualClock(); - const originalSetTimeout = clock.setTimeout; - const scheduler = new NetworkScheduler({ clock }); - clock.setTimeout = vi.fn(() => { - throw new Error("mutated clock"); - }); - const session = scheduler.createSession(); - - await expect(session.schedule(task(() => "ok"))).resolves.toBe("ok"); - expect(clock.setTimeout).not.toHaveBeenCalled(); - expect(originalSetTimeout).not.toBe(clock.setTimeout); - }); - - it("turns timer setup failure into a stable invariant rejection", async () => { - const operation = vi.fn(); - const clock: NetworkClock = { - now: () => 0, - setTimeout: () => { - throw new Error("timer internals"); - }, - clearTimeout: () => undefined, - }; - const session = new NetworkScheduler({ clock }).createSession(); - - await expect(session.schedule(task(operation))).rejects.toBeInstanceOf( - NetworkInvariantError, - ); - expect(operation).not.toHaveBeenCalled(); - expect(session.getLaneSnapshot("metadata")).toMatchObject({ - active: 0, - queued: 0, - failed: true, - }); - }); - - it("sanitizes an initial clock-read failure", async () => { - const sentinel = "https://private.example/feed?token=CLOCK-SECRET"; - const clock: NetworkClock = { - now: () => { - throw new Error(sentinel); - }, - setTimeout: () => 1, - clearTimeout: () => undefined, - }; - const session = new NetworkScheduler({ clock }).createSession(); - const error = await session - .schedule(task(() => undefined)) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(NetworkInvariantError); - expect(String(error)).not.toContain(sentinel); - }); - - it("settles and releases capacity when the clock fails during completion", async () => { - let reads = 0; - const clock: NetworkClock = { - now: () => (++reads <= 2 ? 0 : Number.NaN), - setTimeout: () => 1, - clearTimeout: () => undefined, - }; - const operation = vi.fn(() => "value"); - const session = new NetworkScheduler({ clock }).createSession(); - - await expect(session.schedule(task(operation))).rejects.toBeInstanceOf( - NetworkInvariantError, - ); - expect(operation).toHaveBeenCalledOnce(); - await flushScheduler(); - expect(session.getLaneSnapshot("metadata")).toMatchObject({ - active: 0, - queued: 0, - failed: true, - }); - await expect(session.schedule(task(() => "must not run"))).rejects.toBeInstanceOf( - NetworkInvariantError, - ); - }); - - it("fails a lane closed while retaining every permit until its adapter settles", async () => { - let clockFailed = false; - const clock: NetworkClock = { - now: () => (clockFailed ? Number.NaN : 0), - setTimeout: () => 1, - clearTimeout: () => undefined, - }; - const firstGate = deferred(); - const secondGate = deferred(); - const queuedOperation = vi.fn(); - const signals: AbortSignal[] = []; - const scheduler = new NetworkScheduler({ - clock, - laneLimits: { metadata: { maxActive: 2, maxQueued: 1 } }, - }); - const firstSession = scheduler.createSession(); - const secondSession = scheduler.createSession(); - const first = firstSession.schedule( - task((signal) => { - signals.push(signal); - return firstGate.promise; - }), - ); - const second = secondSession.schedule( - task((signal) => { - signals.push(signal); - return secondGate.promise; - }), - ); - const queued = firstSession.schedule(task(queuedOperation)); - const observed = [first, second, queued].map((promise) => - promise.catch((error: unknown) => error), - ); - - clockFailed = true; - firstGate.resolve(); - const errors = await Promise.all(observed); - expect(errors.every((error) => error instanceof NetworkInvariantError)).toBe(true); - expect(signals).toHaveLength(2); - expect(signals.every((signal) => signal.aborted)).toBe(true); - expect(queuedOperation).not.toHaveBeenCalled(); - await flushScheduler(); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ - active: 1, - queued: 0, - failed: true, - }); - - secondGate.resolve(); - await flushScheduler(); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ - active: 0, - queued: 0, - failed: true, - }); - await expect(firstSession.schedule(task(() => undefined))).rejects.toBeInstanceOf( - NetworkInvariantError, - ); - }); - - it("does not strand a caller when timer cleanup throws", async () => { - const clock: NetworkClock = { - now: () => 0, - setTimeout: () => 1, - clearTimeout: () => { - throw new Error("timer cleanup internals"); - }, - }; - const session = new NetworkScheduler({ clock }).createSession(); - - await expect(session.schedule(task(() => "value"))).resolves.toBe("value"); - await flushScheduler(); - expect(session.getLaneSnapshot("metadata")).toMatchObject({ active: 0, queued: 0 }); - }); -}); - -describe("NetworkScheduler lanes and queueing", () => { - it("keeps all three lanes independent", async () => { - const clock = new ManualClock(); - const pending = [deferred(), deferred(), deferred()]; - const started: NetworkLane[] = []; - const scheduler = new NetworkScheduler({ - clock, - laneLimits: { - metadata: { maxActive: 1 }, - media: { maxActive: 1 }, - provider: { maxActive: 1 }, - }, - }); - const session = scheduler.createSession(); - const lanes = ["metadata", "media", "provider"] as const; - - const promises = lanes.map((lane, index) => - session.schedule( - task( - () => { - started.push(lane); - return pending[index].promise; - }, - { - lane, - resourceLabel: - lane === "metadata" - ? FEED_LABEL - : lane === "media" - ? EPISODE_LABEL - : PROVIDER_LABEL, - }, - ), - ), - ); - - expect(started).toEqual(lanes); - expect(scheduler.getSnapshot()).toMatchObject({ - metadata: { active: 1, queued: 0 }, - media: { active: 1, queued: 0 }, - provider: { active: 1, queued: 0 }, - }); - pending.forEach((entry, index) => entry.resolve(String(index))); - await Promise.all(promises); - await flushScheduler(); - }); - - it("runs each lane FIFO across sessions", async () => { - const clock = new ManualClock(); - const scheduler = new NetworkScheduler({ - clock, - laneLimits: { metadata: { maxActive: 1, maxQueued: 3 } }, - }); - const firstSession = scheduler.createSession(); - const secondSession = scheduler.createSession(); - const gates = [deferred(), deferred(), deferred()]; - const started: number[] = []; - const operation = (index: number) => () => { - started.push(index); - return gates[index].promise; - }; - - const first = firstSession.schedule(task(operation(0))); - const second = secondSession.schedule(task(operation(1))); - const third = firstSession.schedule(task(operation(2))); - expect(started).toEqual([0]); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ active: 1, queued: 2 }); - - gates[0].resolve(0); - await first; - await flushScheduler(); - expect(started).toEqual([0, 1]); - gates[1].resolve(1); - await second; - await flushScheduler(); - expect(started).toEqual([0, 1, 2]); - gates[2].resolve(2); - await third; - await flushScheduler(); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ active: 0, queued: 0 }); - }); - - it("bounds each queue, including a zero-queue lane", async () => { - const activeGate = deferred(); - const queuedGate = deferred(); - const oneQueued = new NetworkScheduler({ - clock: new ManualClock(), - laneLimits: { metadata: { maxActive: 1, maxQueued: 1 } }, - }).createSession(); - const active = oneQueued.schedule(task(() => activeGate.promise)); - const queued = oneQueued.schedule(task(() => queuedGate.promise)); - - await expect(oneQueued.schedule(task(() => undefined))).rejects.toBeInstanceOf( - NetworkQueueFullError, - ); - activeGate.resolve(); - await active; - await flushScheduler(); - queuedGate.resolve(); - await queued; - - const noQueueGate = deferred(); - const noQueue = new NetworkScheduler({ - clock: new ManualClock(), - laneLimits: { metadata: { maxActive: 1, maxQueued: 0 } }, - }).createSession(); - const sole = noQueue.schedule(task(() => noQueueGate.promise)); - await expect(noQueue.schedule(task(() => undefined))).rejects.toBeInstanceOf( - NetworkQueueFullError, - ); - noQueueGate.resolve(); - await sole; - }); - - it("defensively copies queued task fields before caller mutation", async () => { - const clock = new ManualClock(); - const scheduler = new NetworkScheduler({ - clock, - laneLimits: { metadata: { maxActive: 1, maxQueued: 1 } }, - }); - const session = scheduler.createSession(); - const firstGate = deferred(); - const originalOperation = vi.fn(() => "original"); - const replacementOperation = vi.fn(() => "replacement"); - const active = session.schedule(task(() => firstGate.promise, { timeoutMs: 60_000 })); - const mutableTask: NetworkTask = { - lane: "metadata", - resourceLabel: FEED_LABEL, - timeoutMs: 30_000, - operation: originalOperation, - }; - const queued = session.schedule(mutableTask); - - const callerOwned = mutableTask as unknown as { - lane: NetworkLane; - resourceLabel: NetworkResourceLabel; - timeoutMs: number; - operation: (signal: AbortSignal) => string; - }; - callerOwned.lane = "media"; - callerOwned.resourceLabel = EPISODE_LABEL; - callerOwned.timeoutMs = 1; - callerOwned.operation = replacementOperation; - firstGate.resolve(); - await active; - await expect(queued).resolves.toBe("original"); - expect(originalOperation).toHaveBeenCalledOnce(); - expect(replacementOperation).not.toHaveBeenCalled(); - expect(scheduler.getLaneSnapshot("media")).toMatchObject({ active: 0, queued: 0 }); - }); -}); - -describe("NetworkScheduler deadlines and cancellation", () => { - it("aborts timed-out active work but holds its permit until adapter settlement", async () => { - const clock = new ManualClock(); - const firstGate = deferred(); - const secondGate = deferred(); - let firstSignal: AbortSignal | undefined; - const secondOperation = vi.fn(() => secondGate.promise); - const scheduler = new NetworkScheduler({ - clock, - laneLimits: { metadata: { maxActive: 1, maxQueued: 1 } }, - }); - const session = scheduler.createSession(); - const timedOut = session.schedule( - task((signal) => { - firstSignal = signal; - return firstGate.promise; - }), - ); - const queued = session.schedule(task(secondOperation, { timeoutMs: 60_000 })); - const [firstDeadline] = clock.pendingHandles(); - - clock.fireAtDueTime(firstDeadline); - await expect(timedOut).rejects.toBeInstanceOf(NetworkDeadlineError); - expect(firstSignal?.aborted).toBe(true); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ active: 1, queued: 1 }); - expect(secondOperation).not.toHaveBeenCalled(); - - firstGate.resolve("late"); - await flushScheduler(); - expect(secondOperation).toHaveBeenCalledOnce(); - secondGate.resolve("next"); - await expect(queued).resolves.toBe("next"); - await flushScheduler(); - expect(scheduler.getLaneSnapshot("metadata").active).toBe(0); - }); - - it("removes queued work at its deadline without creating an AbortController", async () => { - const clock = new ManualClock(); - const activeGate = deferred(); - const queuedOperation = vi.fn(); - const scheduler = new NetworkScheduler({ - clock, - laneLimits: { metadata: { maxActive: 1, maxQueued: 1 } }, - }); - const session = scheduler.createSession(); - const active = session.schedule(task(() => activeGate.promise, { timeoutMs: 60_000 })); - const queued = session.schedule(task(queuedOperation)); - const [, queuedDeadline] = clock.pendingHandles(); - - clock.fireAtDueTime(queuedDeadline); - await expect(queued).rejects.toBeInstanceOf(NetworkDeadlineError); - expect(queuedOperation).not.toHaveBeenCalled(); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ active: 1, queued: 0 }); - activeGate.resolve(); - await active; - await flushScheduler(); - expect(queuedOperation).not.toHaveBeenCalled(); - }); - - it("checks absolute deadlines when timer delivery is late", async () => { - const clock = new ManualClock(); - const activeGate = deferred(); - const queuedOperation = vi.fn(() => "must not run"); - let activeSignal: AbortSignal | undefined; - const scheduler = new NetworkScheduler({ - clock, - laneLimits: { metadata: { maxActive: 1, maxQueued: 1 } }, - }); - const session = scheduler.createSession(); - const active = session.schedule( - task((signal) => { - activeSignal = signal; - return activeGate.promise; - }), - ); - const queued = session.schedule(task(queuedOperation)); - - clock.advance(30_001); - activeGate.resolve("late result"); - await expect(active).rejects.toBeInstanceOf(NetworkDeadlineError); - await expect(queued).rejects.toBeInstanceOf(NetworkDeadlineError); - expect(activeSignal?.aborted).toBe(true); - expect(queuedOperation).not.toHaveBeenCalled(); - await flushScheduler(); - expect(clock.pendingHandles()).toEqual([]); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ active: 0, queued: 0 }); - }); - - it("re-arms an early timer instead of expiring work early", async () => { - const clock = new ManualClock(); - const gate = deferred(); - let signal: AbortSignal | undefined; - const session = new NetworkScheduler({ clock }).createSession(); - const scheduled = session.schedule( - task((activeSignal) => { - signal = activeSignal; - return gate.promise; - }), - ); - const [firstTimer] = clock.pendingHandles(); - - clock.fire(firstTimer); - expect(signal?.aborted).toBe(false); - expect(clock.pendingDelays()).toEqual([30_000]); - gate.resolve("on time"); - await expect(scheduled).resolves.toBe("on time"); - expect(clock.pendingHandles()).toEqual([]); - }); - - it("makes the deadline win when settlement arrives at the exact boundary", async () => { - const clock = new ManualClock(); - const gate = deferred(); - let signal: AbortSignal | undefined; - const session = new NetworkScheduler({ clock }).createSession(); - const scheduled = session.schedule( - task((activeSignal) => { - signal = activeSignal; - return gate.promise; - }), - ); - - clock.advance(30_000); - gate.resolve("boundary"); - await expect(scheduled).rejects.toBeInstanceOf(NetworkDeadlineError); - expect(signal?.aborted).toBe(true); - }); - - it("creates a distinct AbortController for every active operation", async () => { - const gates = [deferred(), deferred()]; - const signals: AbortSignal[] = []; - const scheduler = new NetworkScheduler({ - clock: new ManualClock(), - laneLimits: { metadata: { maxActive: 2 } }, - }); - const session = scheduler.createSession(); - const promises = gates.map((gate) => - session.schedule( - task((signal) => { - signals.push(signal); - return gate.promise; - }), - ), - ); - - expect(signals).toHaveLength(2); - expect(signals[0]).not.toBe(signals[1]); - session.dispose(); - expect(signals.every((signal) => signal.aborted)).toBe(true); - await Promise.all(promises.map((promise) => promise.catch((error: unknown) => error))); - gates.forEach((gate) => gate.resolve()); - await flushScheduler(); - }); - - it("releases one permit exactly once after abort-driven rejection", async () => { - const clock = new ManualClock(); - const adapter = deferred(); - const scheduler = new NetworkScheduler({ - clock, - laneLimits: { metadata: { maxActive: 1, maxQueued: 1 } }, - }); - const session = scheduler.createSession(); - const first = session.schedule( - task((signal) => { - signal.addEventListener("abort", () => adapter.reject(new Error("aborted")), { - once: true, - }); - return adapter.promise; - }), - ); - const next = session.schedule(task(() => "next", { timeoutMs: 60_000 })); - const [deadline] = clock.pendingHandles(); - - clock.fireAtDueTime(deadline); - await expect(first).rejects.toBeInstanceOf(NetworkDeadlineError); - await expect(next).resolves.toBe("next"); - await flushScheduler(); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ active: 0, queued: 0 }); - }); -}); - -describe("NetworkScheduler disposal and error boundaries", () => { - it("disposes only one owner while preserving shared capacity and FIFO", async () => { - const clock = new ManualClock(); - const oldGate = deferred(); - let oldSignal: AbortSignal | undefined; - const otherOperation = vi.fn(() => "other"); - const removedOperation = vi.fn(() => "removed"); - const scheduler = new NetworkScheduler({ - clock, - laneLimits: { metadata: { maxActive: 1, maxQueued: 3 } }, - }); - const oldSession = scheduler.createSession(); - const otherSession = scheduler.createSession(); - const active = oldSession.schedule( - task((signal) => { - oldSignal = signal; - return oldGate.promise; - }), - ); - const other = otherSession.schedule(task(otherOperation, { timeoutMs: 60_000 })); - const removed = oldSession.schedule(task(removedOperation, { timeoutMs: 60_000 })); - - oldSession.dispose(); - oldSession.dispose(); - expect(oldSession.isDisposed()).toBe(true); - await expect(active).rejects.toBeInstanceOf(NetworkDisposedError); - await expect(removed).rejects.toBeInstanceOf(NetworkDisposedError); - expect(oldSignal?.aborted).toBe(true); - expect(removedOperation).not.toHaveBeenCalled(); - expect(otherOperation).not.toHaveBeenCalled(); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ active: 1, queued: 1 }); - await expect(oldSession.schedule(task(() => "new"))).rejects.toBeInstanceOf( - NetworkDisposedError, - ); - - oldGate.resolve(); - await expect(other).resolves.toBe("other"); - expect(otherOperation).toHaveBeenCalledOnce(); - await flushScheduler(); - expect(scheduler.getLaneSnapshot("metadata")).toMatchObject({ active: 0, queued: 0 }); - }); - - it.each([ - [ - "sync", - () => { - throw new Error("https://secret.example/feed?token=raw response body"); - }, - ], - ["async", () => Promise.reject(new Error("provider key sk-secret at https://api.example"))], - ] as const)( - "sanitizes %s adapter failures without retaining a raw cause", - async (_kind, operation) => { - const session = new NetworkScheduler({ clock: new ManualClock() }).createSession(); - const error = await session - .schedule(task(operation)) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(NetworkOperationError); - expect(error).toMatchObject({ - code: NETWORK_ERROR_CODES.operationFailed, - resourceLabel: FEED_LABEL, - }); - expect(String(error)).toBe( - `NetworkOperationError: NETWORK_OPERATION_FAILED: ${FEED_LABEL}`, - ); - expect(JSON.stringify(error)).not.toContain("secret"); - expect(error).not.toHaveProperty("cause"); - }, - ); - - it("keeps timeout errors stable after ignored late success and failure", async () => { - const clock = new ManualClock(); - const gate = deferred(); - const scheduler = new NetworkScheduler({ clock }); - const session = scheduler.createSession(); - const scheduled = session.schedule(task(() => gate.promise)); - const observed = scheduled.catch((error: unknown) => error); - const [deadline] = clock.pendingHandles(); - - clock.fireAtDueTime(deadline); - const error = await observed; - expect(error).toBeInstanceOf(NetworkDeadlineError); - gate.resolve("late secret response"); - await flushScheduler(); - expect(error).toMatchObject({ code: NETWORK_ERROR_CODES.deadlineExceeded }); - expect(scheduler.getLaneSnapshot("metadata").active).toBe(0); - }); -}); diff --git a/src/network/NetworkScheduler.ts b/src/network/NetworkScheduler.ts deleted file mode 100644 index 69f88f9..0000000 --- a/src/network/NetworkScheduler.ts +++ /dev/null @@ -1,631 +0,0 @@ -import { - createNetworkResourceLabel, - NetworkDeadlineError, - NetworkDisposedError, - NetworkInvariantError, - NetworkOperationError, - NetworkQueueFullError, - type NetworkResourceLabel, -} from "./NetworkErrors"; - -export const NETWORK_LANES = Object.freeze(["metadata", "media", "provider"] as const); -export type NetworkLane = (typeof NETWORK_LANES)[number]; - -export interface NetworkLaneLimit { - readonly maxActive: number; - readonly maxQueued: number; -} - -export type NetworkLaneLimits = Readonly>; - -export const DEFAULT_NETWORK_LANE_LIMITS: NetworkLaneLimits = Object.freeze({ - metadata: Object.freeze({ maxActive: 4, maxQueued: 512 }), - media: Object.freeze({ maxActive: 2, maxQueued: 32 }), - provider: Object.freeze({ maxActive: 2, maxQueued: 16 }), -}); - -/** The largest delay that browsers and Node schedule without 32-bit overflow. */ -export const MAX_NETWORK_DEADLINE_MS = 2_147_483_647; - -export interface NetworkClock { - now(): number; - setTimeout(callback: () => void, delayMs: number): unknown; - clearTimeout(handle: unknown): void; -} - -export interface NetworkSchedulerOptions { - readonly clock?: NetworkClock; - readonly laneLimits?: Partial>>; -} - -export interface NetworkTask { - readonly lane: NetworkLane; - readonly resourceLabel: NetworkResourceLabel; - readonly timeoutMs: number; - readonly operation: (signal: AbortSignal) => T | PromiseLike; -} - -export interface NetworkLaneSnapshot { - readonly active: number; - readonly queued: number; - readonly maxActive: number; - readonly maxQueued: number; - readonly failed: boolean; -} - -export type NetworkSchedulerSnapshot = Readonly>; - -interface Deferred { - readonly promise: Promise; - resolve(value: T): void; - reject(error: unknown): void; -} - -interface SessionState { - disposed: boolean; - readonly activeJobs: Set; -} - -interface PreparedTask { - readonly lane: NetworkLane; - readonly resourceLabel: NetworkResourceLabel; - readonly timeoutMs: number; - readonly operation: (signal: AbortSignal) => unknown | PromiseLike; -} - -interface ScheduledJob { - readonly owner: SessionState; - readonly task: PreparedTask; - readonly deferred: Deferred; - readonly deadlineAt: number; - callerSettled: boolean; - deadlineTimerArmed: boolean; - permitState: "none" | "held" | "released"; - deadlineTimer?: unknown; - abortController?: AbortController; -} - -interface LaneState { - active: number; - failed: boolean; - readonly activeJobs: Set; - readonly queue: ScheduledJob[]; - readonly limit: NetworkLaneLimit; -} - -type NetworkLaneLimitOverrides = Partial>>; - -const defaultClock: NetworkClock = { - now: () => performance.now(), - setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs), - clearTimeout: (handle) => window.clearTimeout(handle as number), -}; - -const OWN = Object.prototype.hasOwnProperty; -const NETWORK_LANE_MEMBERSHIP = Object.freeze({ - metadata: true, - media: true, - provider: true, -}); - -/** - * A shared, transport-independent capacity scheduler. Construct exactly one for - * a plugin generation and create one session per lifecycle owner. Disposing a - * session cannot reset permits still held by another session or by an adapter - * that ignored cancellation. - */ -export class NetworkScheduler { - private readonly clock: NetworkClock; - private readonly lanes: Record; - - constructor(options: NetworkSchedulerOptions = {}) { - const { clock, laneLimits } = snapshotSchedulerOptions(options); - this.clock = snapshotClock(clock ?? defaultClock); - this.lanes = { - metadata: createLaneState("metadata", laneLimits), - media: createLaneState("media", laneLimits), - provider: createLaneState("provider", laneLimits), - }; - } - - createSession(): NetworkSchedulerSession { - return new NetworkSchedulerSession(this, { - disposed: false, - activeJobs: new Set(), - }); - } - - getLaneSnapshot(lane: NetworkLane): NetworkLaneSnapshot { - const laneName = validateLane(lane); - const state = this.lanes[laneName]; - return Object.freeze({ - active: state.active, - queued: state.queue.length, - maxActive: state.limit.maxActive, - maxQueued: state.limit.maxQueued, - failed: state.failed, - }); - } - - getSnapshot(): NetworkSchedulerSnapshot { - return Object.freeze({ - metadata: this.getLaneSnapshot("metadata"), - media: this.getLaneSnapshot("media"), - provider: this.getLaneSnapshot("provider"), - }); - } - - /** @internal Called only by sessions created by this scheduler. */ - schedule(owner: SessionState, options: NetworkTask): Promise { - let task: PreparedTask; - try { - task = prepareTask(options); - } catch (error) { - return Promise.reject(error); - } - - if (owner.disposed) { - return Promise.reject(new NetworkDisposedError(task.resourceLabel)); - } - - const lane = this.lanes[task.lane]; - if (lane.failed) { - return Promise.reject(new NetworkInvariantError(task.resourceLabel)); - } - if (lane.active >= lane.limit.maxActive && lane.queue.length >= lane.limit.maxQueued) { - return Promise.reject(new NetworkQueueFullError(task.resourceLabel)); - } - - let now: number; - try { - now = readClock(this.clock); - } catch { - return Promise.reject(new NetworkInvariantError(task.resourceLabel)); - } - - const job: ScheduledJob = { - owner, - task, - deferred: createDeferred(), - deadlineAt: now + task.timeoutMs, - callerSettled: false, - deadlineTimerArmed: false, - permitState: "none", - }; - if (!this.armDeadline(job, task.timeoutMs)) { - this.failLane(job); - return job.deferred.promise as Promise; - } - - if (lane.active < lane.limit.maxActive) { - this.start(job); - } else { - lane.queue.push(job); - } - - return job.deferred.promise as Promise; - } - - /** @internal Called only by sessions created by this scheduler. */ - disposeSession(owner: SessionState): void { - if (owner.disposed) return; - owner.disposed = true; - - for (const lane of Object.values(this.lanes)) { - for (let index = lane.queue.length - 1; index >= 0; index -= 1) { - const job = lane.queue[index]; - if (job.owner !== owner) continue; - lane.queue.splice(index, 1); - this.rejectCaller(job, new NetworkDisposedError(job.task.resourceLabel)); - } - } - - for (const job of owner.activeJobs) { - this.abort(job); - this.rejectCaller(job, new NetworkDisposedError(job.task.resourceLabel)); - } - } - - private start(job: ScheduledJob): void { - if (job.owner.disposed) { - this.rejectCaller(job, new NetworkDisposedError(job.task.resourceLabel)); - return; - } - const lane = this.lanes[job.task.lane]; - if (lane.failed) { - this.rejectCaller(job, new NetworkInvariantError(job.task.resourceLabel)); - return; - } - - let now: number; - try { - now = readClock(this.clock); - } catch { - this.failLane(job); - return; - } - if (now >= job.deadlineAt) { - this.expire(job); - return; - } - - lane.active += 1; - job.owner.activeJobs.add(job); - lane.activeJobs.add(job); - job.permitState = "held"; - job.abortController = new AbortController(); - - let adapterPromise: Promise; - try { - adapterPromise = Promise.resolve(job.task.operation(job.abortController.signal)); - } catch { - adapterPromise = Promise.reject(); - } - - void adapterPromise - .then( - (value) => this.complete(job, value), - () => this.fail(job), - ) - .finally(() => this.release(job)) - .catch(() => this.failLane(job)); - } - - private complete(job: ScheduledJob, value: unknown): void { - if (job.callerSettled) return; - let now: number; - try { - now = readClock(this.clock); - } catch { - this.failLane(job); - return; - } - if (now >= job.deadlineAt) { - this.expire(job); - return; - } - this.resolveCaller(job, value); - } - - private fail(job: ScheduledJob): void { - if (job.callerSettled) return; - let now: number; - try { - now = readClock(this.clock); - } catch { - this.failLane(job); - return; - } - if (now >= job.deadlineAt) { - this.expire(job); - return; - } - this.rejectCaller(job, new NetworkOperationError(job.task.resourceLabel)); - } - - private expire(job: ScheduledJob): void { - if (job.callerSettled) return; - - let now: number; - try { - now = readClock(this.clock); - } catch { - this.failLane(job); - return; - } - if (now < job.deadlineAt) { - if (!this.armDeadline(job, job.deadlineAt - now)) { - this.failLane(job); - } - return; - } - - this.removeQueuedOrAbort(job); - this.rejectCaller(job, new NetworkDeadlineError(job.task.resourceLabel)); - } - - private release(job: ScheduledJob): void { - const lane = this.lanes[job.task.lane]; - if (lane.failed) { - this.releaseFailedLanePermit(job); - return; - } - if ( - job.permitState !== "held" || - !job.owner.activeJobs.has(job) || - !lane.activeJobs.has(job) || - lane.active <= 0 - ) { - this.failLane(job); - return; - } - job.owner.activeJobs.delete(job); - lane.activeJobs.delete(job); - job.permitState = "released"; - lane.active -= 1; - this.clearDeadline(job); - this.drain(job.task.lane); - } - - private drain(laneName: NetworkLane): void { - const lane = this.lanes[laneName]; - while (!lane.failed && lane.active < lane.limit.maxActive && lane.queue.length > 0) { - const next = lane.queue.shift(); - if (next) this.start(next); - } - } - - private armDeadline(job: ScheduledJob, delayMs: number): boolean { - this.clearDeadline(job); - try { - job.deadlineTimer = this.clock.setTimeout( - () => this.expire(job), - Math.min(MAX_NETWORK_DEADLINE_MS, Math.max(0, delayMs)), - ); - job.deadlineTimerArmed = true; - return true; - } catch { - job.deadlineTimer = undefined; - job.deadlineTimerArmed = false; - return false; - } - } - - private clearDeadline(job: ScheduledJob): void { - if (!job.deadlineTimerArmed) return; - const handle = job.deadlineTimer; - job.deadlineTimerArmed = false; - job.deadlineTimer = undefined; - try { - this.clock.clearTimeout(handle); - } catch { - // Timer cleanup failure cannot strand or re-settle the caller. - } - } - - private abort(job: ScheduledJob): void { - if (!job.abortController || job.abortController.signal.aborted) return; - job.abortController.abort(); - } - - private resolveCaller(job: ScheduledJob, value: unknown): void { - if (job.callerSettled) return; - job.callerSettled = true; - this.clearDeadline(job); - job.deferred.resolve(value); - } - - private rejectCaller(job: ScheduledJob, error: unknown): void { - if (job.callerSettled) return; - job.callerSettled = true; - this.clearDeadline(job); - job.deferred.reject(error); - } - - private removeQueuedOrAbort(job: ScheduledJob): void { - if (job.abortController) { - this.abort(job); - return; - } - const queue = this.lanes[job.task.lane].queue; - const queueIndex = queue.indexOf(job); - if (queueIndex !== -1) queue.splice(queueIndex, 1); - } - - private failLane(job: ScheduledJob): void { - try { - const lane = this.lanes[job.task.lane]; - lane.failed = true; - for (const active of lane.activeJobs) { - this.abort(active); - this.rejectCaller(active, new NetworkInvariantError(active.task.resourceLabel)); - this.clearDeadline(active); - } - for (const queued of lane.queue.splice(0)) { - this.rejectCaller(queued, new NetworkInvariantError(queued.task.resourceLabel)); - } - this.rejectCaller(job, new NetworkInvariantError(job.task.resourceLabel)); - this.clearDeadline(job); - } catch { - // The lane remains failed and the terminal promise chain stays observed. - } - } - - private releaseFailedLanePermit(job: ScheduledJob): void { - if (job.permitState !== "held") return; - job.permitState = "released"; - job.owner.activeJobs.delete(job); - const lane = this.lanes[job.task.lane]; - lane.activeJobs.delete(job); - if (lane.active > 0) lane.active -= 1; - this.clearDeadline(job); - } -} - -export class NetworkSchedulerSession { - constructor( - private readonly scheduler: NetworkScheduler, - private readonly state: SessionState, - ) {} - - schedule(task: NetworkTask): Promise { - return this.scheduler.schedule(this.state, task); - } - - dispose(): void { - this.scheduler.disposeSession(this.state); - } - - isDisposed(): boolean { - return this.state.disposed; - } - - getLaneSnapshot(lane: NetworkLane): NetworkLaneSnapshot { - return this.scheduler.getLaneSnapshot(lane); - } - - getSnapshot(): NetworkSchedulerSnapshot { - return this.scheduler.getSnapshot(); - } -} - -function prepareTask(options: NetworkTask): PreparedTask { - try { - const record = strictPlainDataRecord(options, [ - "lane", - "resourceLabel", - "timeoutMs", - "operation", - ]); - for (const property of ["lane", "resourceLabel", "timeoutMs", "operation"] as const) { - if (!OWN.call(record, property)) throw new Error(); - } - - const lane = validateLane(record.lane); - const resourceLabel = createNetworkResourceLabel(record.resourceLabel as string); - const timeoutMs = record.timeoutMs; - if ( - !Number.isSafeInteger(timeoutMs) || - (timeoutMs as number) <= 0 || - (timeoutMs as number) > MAX_NETWORK_DEADLINE_MS - ) { - throw new Error(); - } - const operation = record.operation; - if (typeof operation !== "function") throw new Error(); - - return Object.freeze({ - lane, - resourceLabel, - timeoutMs: timeoutMs as number, - operation: operation as (signal: AbortSignal) => unknown | PromiseLike, - }); - } catch { - throw new TypeError("Network task is invalid."); - } -} - -function createLaneState(lane: NetworkLane, overrides: NetworkLaneLimitOverrides): LaneState { - const defaults = DEFAULT_NETWORK_LANE_LIMITS[lane]; - const override = overrides[lane]; - const maxActive = - override && OWN.call(override, "maxActive") ? override.maxActive : defaults.maxActive; - const maxQueued = - override && OWN.call(override, "maxQueued") ? override.maxQueued : defaults.maxQueued; - - validatePositiveInteger(maxActive, `${lane}.maxActive`); - validateNonNegativeInteger(maxQueued, `${lane}.maxQueued`); - - return { - active: 0, - failed: false, - activeJobs: new Set(), - queue: [], - limit: Object.freeze({ maxActive, maxQueued }), - }; -} - -function snapshotSchedulerOptions(options: unknown): { - readonly clock?: NetworkClock; - readonly laneLimits: NetworkLaneLimitOverrides; -} { - try { - const record = strictPlainDataRecord(options, ["clock", "laneLimits"]); - const clock = OWN.call(record, "clock") ? record.clock : undefined; - const laneLimits = snapshotLaneLimitOverrides( - OWN.call(record, "laneLimits") ? record.laneLimits : undefined, - ); - return Object.freeze({ - ...(clock === undefined ? {} : { clock: clock as NetworkClock }), - laneLimits, - }); - } catch { - throw new TypeError("Network scheduler options are invalid."); - } -} - -function snapshotLaneLimitOverrides(value: unknown): NetworkLaneLimitOverrides { - if (value === undefined) return Object.freeze({}); - const record = strictPlainDataRecord(value, NETWORK_LANES); - const snapshot: Partial>> = {}; - for (const lane of NETWORK_LANES) { - if (!OWN.call(record, lane)) continue; - const limit = strictPlainDataRecord(record[lane], ["maxActive", "maxQueued"]); - const copied: { maxActive?: number; maxQueued?: number } = {}; - if (OWN.call(limit, "maxActive")) copied.maxActive = limit.maxActive as number; - if (OWN.call(limit, "maxQueued")) copied.maxQueued = limit.maxQueued as number; - snapshot[lane] = Object.freeze(copied); - } - return Object.freeze(snapshot); -} - -function strictPlainDataRecord( - value: unknown, - allowedKeys: readonly string[], -): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(); - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) throw new Error(); - const record = Object.create(null) as Record; - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== "string" || !allowedKeys.includes(key)) throw new Error(); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor?.enumerable || !("value" in descriptor)) throw new Error(); - record[key] = descriptor.value; - } - return record; -} - -function validateLane(value: unknown): NetworkLane { - if (typeof value !== "string" || !OWN.call(NETWORK_LANE_MEMBERSHIP, value)) { - throw new TypeError("Network lane must be metadata, media, or provider."); - } - return value as NetworkLane; -} - -function snapshotClock(clock: NetworkClock): NetworkClock { - if ( - !clock || - typeof clock.now !== "function" || - typeof clock.setTimeout !== "function" || - typeof clock.clearTimeout !== "function" - ) { - throw new TypeError("Network scheduler clock is invalid."); - } - - return Object.freeze({ - now: clock.now.bind(clock), - setTimeout: clock.setTimeout.bind(clock), - clearTimeout: clock.clearTimeout.bind(clock), - }); -} - -function readClock(clock: NetworkClock): number { - const now = clock.now(); - if (!Number.isFinite(now)) { - throw new TypeError("Network scheduler clock returned an invalid time."); - } - return now; -} - -function createDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -function validatePositiveInteger(value: unknown, name: string): asserts value is number { - if (!Number.isSafeInteger(value) || (value as number) <= 0) { - throw new TypeError(`${name} must be a positive integer.`); - } -} - -function validateNonNegativeInteger(value: unknown, name: string): asserts value is number { - if (!Number.isSafeInteger(value) || (value as number) < 0) { - throw new TypeError(`${name} must be a non-negative integer.`); - } -} diff --git a/src/network/PinnedNetworkHop.ts b/src/network/PinnedNetworkHop.ts deleted file mode 100644 index 2119d93..0000000 --- a/src/network/PinnedNetworkHop.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { - isResolvedAddressLiteral, - resolvedAddressesEqual, - type NetworkProtocol, - type TargetPolicyView, -} from "./LiteralTargetClassifier"; -import { - CAPABILITY_TRANSPORT_ERROR_CODES, - createCapabilityTransportError, - type NetworkResourceLabel, -} from "./NetworkErrors"; - -export const MAX_NETWORK_RESOLVED_ADDRESSES = 16; - -/** - * Trusted DNS primitive. The core strictly validates and chooses returned - * addresses. The resolver must observe abort, synchronously initiate - * cancellation, and settle promptly so cancelled work cannot retain a permit. - */ -export type NetworkNameResolver = ( - hostname: string, - signal: AbortSignal, -) => readonly string[] | PromiseLike; - -/** - * A pinned request that cannot trigger a second hostname lookup by itself. - * - * The adapter is part of the trusted network boundary. It must connect directly - * to `connectAddress`, use `serverName` only for TLS SNI, send `hostHeader` and - * `requestTarget` without ambient credentials, disable redirects, report the - * socket's actual remote address, and make `close` synchronously initiate - * shutdown, settle promptly, and promptly settle pending body iteration. HTTPS - * must use normal CA-chain validation and verify the certificate against - * `serverName`, or against the IP SAN for a literal target. Insecure TLS - * overrides are prohibited. Before returning a response, the adapter must - * observe abort, synchronously cancel its work, and settle promptly. Platforms - * that cannot satisfy this contract must fail closed. - */ -export interface PinnedNetworkHopRequest { - readonly protocol: NetworkProtocol; - readonly connectAddress: string; - readonly port: number; - readonly serverName?: string; - readonly hostHeader: string; - /** Exact path and query characters derived from the capability target. */ - readonly requestTarget: string; - readonly method: "GET"; - readonly credentials: "omit"; - readonly redirect: "manual"; - readonly signal: AbortSignal; -} - -export interface PinnedNetworkHopResponse { - readonly status: number; - readonly location?: string; - readonly body: AsyncIterable; - /** Actual remote address read from the connected socket. */ - readonly connectedAddress: string; - /** Must synchronously begin shutdown and promptly settle close plus body work. */ - readonly close: () => void | PromiseLike; -} - -export type PinnedNetworkHopAdapter = ( - request: PinnedNetworkHopRequest, -) => PinnedNetworkHopResponse | PromiseLike; - -export interface PinnedNetworkHop { - readonly status: number; - readonly location?: string; - readonly body: AsyncIterable; - readonly close: () => Promise; -} - -const missingProperty = Symbol("missing-property"); - -function isObject(value: unknown): value is object { - return (typeof value === "object" && value !== null) || typeof value === "function"; -} - -function getOwnDataValue(record: object, property: string): unknown | typeof missingProperty { - const descriptor = Object.getOwnPropertyDescriptor(record, property); - return descriptor && "value" in descriptor ? descriptor.value : missingProperty; -} - -/** @internal Pure validation helper for CapabilityScopedTransport. */ -export function snapshotResolvedAddresses( - value: unknown, - resourceLabel: NetworkResourceLabel, -): readonly string[] { - try { - if ( - !Array.isArray(value) || - value.length === 0 || - value.length > MAX_NETWORK_RESOLVED_ADDRESSES - ) { - throw new Error(); - } - const keys = Reflect.ownKeys(value); - if (keys.length !== value.length + 1 || !keys.includes("length")) throw new Error(); - - const addresses: string[] = []; - for (let index = 0; index < value.length; index += 1) { - const address = getOwnDataValue(value, String(index)); - if ( - address === missingProperty || - !isResolvedAddressLiteral(address) || - addresses.some((existing) => resolvedAddressesEqual(existing, address)) - ) { - throw new Error(); - } - addresses.push(address); - } - return Object.freeze(addresses); - } catch { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.addressRejected, - resourceLabel, - ); - } -} - -/** @internal Pure validation helper for CapabilityScopedTransport. */ -export function snapshotHopResponse( - value: unknown, - expectedAddress: string, - resourceLabel: NetworkResourceLabel, -): Omit & { readonly close: () => void | PromiseLike } { - try { - if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(); - const keys = Reflect.ownKeys(value); - const required = ["status", "body", "connectedAddress", "close"]; - if ( - keys.some((key) => typeof key !== "string") || - keys.length < required.length || - keys.length > required.length + 1 || - required.some((key) => !keys.includes(key)) || - (keys.length === required.length + 1 && !keys.includes("location")) - ) { - throw new Error(); - } - - const status = getOwnDataValue(value, "status"); - const body = getOwnDataValue(value, "body"); - const connectedAddress = getOwnDataValue(value, "connectedAddress"); - const close = getOwnDataValue(value, "close"); - const location = keys.includes("location") ? getOwnDataValue(value, "location") : undefined; - if ( - !Number.isInteger(status) || - (status as number) < 100 || - (status as number) > 599 || - !isObject(body) || - typeof (body as AsyncIterable)[Symbol.asyncIterator] !== "function" || - !resolvedAddressesEqual(connectedAddress, expectedAddress) || - typeof close !== "function" || - (location !== undefined && typeof location !== "string") - ) { - throw new Error(); - } - - return Object.freeze({ - status: status as number, - body: body as AsyncIterable, - close: (): void | PromiseLike => - Reflect.apply( - close as (...arguments_: unknown[]) => unknown, - value, - [], - ) as void | PromiseLike, - ...(location === undefined ? {} : { location }), - }); - } catch { - throw createCapabilityTransportError( - CAPABILITY_TRANSPORT_ERROR_CODES.adapterResponseInvalid, - resourceLabel, - ); - } -} - -/** @internal Pure request derivation helper for CapabilityScopedTransport. */ -export function targetRequestParts( - target: string, - policy: TargetPolicyView, -): { readonly hostHeader: string; readonly requestTarget: string } { - const authorityStart = target.indexOf("//") + 2; - const remainder = target.slice(authorityStart); - const separatorIndex = remainder.search(/[/?]/); - const requestTarget = - separatorIndex === -1 - ? "/" - : remainder[separatorIndex] === "?" - ? `/${remainder.slice(separatorIndex)}` - : remainder.slice(separatorIndex); - const host = policy.normalizedHostname.includes(":") - ? `[${policy.normalizedHostname}]` - : policy.normalizedHostname; - const defaultPort = policy.protocol === "https:" ? 443 : 80; - return Object.freeze({ - hostHeader: `${host}${policy.port === defaultPort ? "" : `:${policy.port}`}`, - requestTarget, - }); -} - -/** @internal One-shot response lifecycle helper for CapabilityScopedTransport. */ -export function manageHopResponse( - response: ReturnType, - signal: AbortSignal, -): PinnedNetworkHop { - let closePromise: Promise | undefined; - const closeOnce = (): Promise => { - if (!closePromise) { - let resolveClose!: () => void; - let rejectClose!: (error: unknown) => void; - closePromise = new Promise((resolve, reject) => { - resolveClose = resolve; - rejectClose = reject; - }); - try { - Promise.resolve(response.close()).then(resolveClose, rejectClose); - } catch (error) { - rejectClose(error); - } - } - return closePromise; - }; - const onAbort = (): void => { - void closeOnce().catch(() => undefined); - }; - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) onAbort(); - - return Object.freeze({ - status: response.status, - body: response.body, - ...(response.location === undefined ? {} : { location: response.location }), - close: async (): Promise => { - signal.removeEventListener("abort", onAbort); - await closeOnce(); - }, - }); -} - -/** @internal Best-effort cleanup helper for malformed adapter responses. */ -export async function closeInvalidHopResponse(value: unknown): Promise { - if (!isObject(value)) return; - try { - const close = getOwnDataValue(value, "close"); - if (typeof close === "function") await Reflect.apply(close, value, []); - } catch { - // The invalid adapter response remains the only caller-visible failure. - } -} diff --git a/src/parser/feedDocumentParser.ts b/src/parser/feedDocumentParser.ts index 8fe176e..419800f 100644 --- a/src/parser/feedDocumentParser.ts +++ b/src/parser/feedDocumentParser.ts @@ -8,10 +8,8 @@ import { parseDurationToSeconds } from "src/utility/parseDuration"; import { parseEpisodeNumber } from "src/utility/parseEpisodeNumber"; /** - * Transient parser output. Raw targets stay purpose-explicit here so the - * capability broker can seal each one under the correct resource kind. This - * object must never be persisted or exposed to the UI, public API, or template - * engine. + * Transient parser output. This object must never be persisted or exposed to + * the UI, public API, or template engine. */ export interface ParsedFeedDocument { title: string; diff --git a/src/parser/feedDocumentSource.ts b/src/parser/feedDocumentSource.ts index cf69304..6ff56da 100644 --- a/src/parser/feedDocumentSource.ts +++ b/src/parser/feedDocumentSource.ts @@ -8,10 +8,9 @@ export interface FeedDocumentSource { /** * Current Obsidian compatibility source. * - * This is deliberately named legacy: `requestUrl` does not expose DNS pinning, - * the connected peer, redirect hops, or native cancellation, so it must never - * be presented as a `PinnedNetworkHopAdapter` or as equivalent to the - * capability-scoped transport. + * `requestUrl` does not expose redirect hops, the final URL, DNS answers, the + * connected peer, or native cancellation. Callers must not infer those + * guarantees from this adapter. */ export const legacyObsidianFeedDocumentSource: FeedDocumentSource = Object.freeze({ async load(sourceUrl: string): Promise { diff --git a/src/parser/feedParser.ts b/src/parser/feedParser.ts index 5646a0a..629bb45 100644 --- a/src/parser/feedParser.ts +++ b/src/parser/feedParser.ts @@ -41,8 +41,8 @@ function projectLegacyEpisode(parsed: ParsedEpisodeDocument, feed: PodcastFeed): /** * Compatibility facade for the existing target-shaped runtime. - * Retrieval is injected so parsing can migrate to the capability broker without - * pretending Obsidian's opaque `requestUrl` primitive is a pinned transport. + * Retrieval stays injected so transport policy can evolve independently from + * pure feed parsing. * Legacy target fallbacks live only here and never erase parser provenance. */ export default class FeedParser { diff --git a/src/persistence/v3/__tests__/fixtures.ts b/src/persistence/v3/__tests__/fixtures.ts deleted file mode 100644 index 20f3c67..0000000 --- a/src/persistence/v3/__tests__/fixtures.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { EpisodeHandle, FeedHandle } from "src/security/resourceHandles"; -import { feedCapabilityReferenceForAttempt } from "src/security/feedCapabilityReferences"; -import { LIBRARY_V3_SCHEMA_VERSION } from "../libraryModel"; - -export const remoteFeedId = `podnotes-feed-${"11".repeat(32)}` as FeedHandle; -export const localFeedId = `podnotes-feed-${"22".repeat(32)}` as FeedHandle; -export const foreignFeedId = `podnotes-feed-${"55".repeat(32)}` as FeedHandle; -export const remoteEpisodeId = `podnotes-episode-${"33".repeat(32)}` as EpisodeHandle; -export const localEpisodeId = `podnotes-episode-${"44".repeat(32)}` as EpisodeHandle; -export const missingEpisodeId = `podnotes-episode-${"66".repeat(32)}` as EpisodeHandle; -export const secondRemoteEpisodeId = `podnotes-episode-${"77".repeat(32)}` as EpisodeHandle; -export const remoteCapabilityRef = feedCapabilityReferenceForAttempt(remoteFeedId, 1)!; -export const foreignCapabilityRef = feedCapabilityReferenceForAttempt(foreignFeedId, 1)!; - -export function playlist(name: string, episodeIds: EpisodeHandle[] = []) { - return { - name, - icon: "list", - episodeIds, - shouldEpisodeRemoveAfterPlay: false, - shouldRepeat: false, - }; -} - -export function validLibrary(): Record { - return { - schemaVersion: LIBRARY_V3_SCHEMA_VERSION, - feeds: { - [remoteFeedId]: { - feedId: remoteFeedId, - kind: "remote", - capabilityRef: remoteCapabilityRef, - title: "Remote show", - collectionId: "12345", - author: "Host", - descriptionText: "A target-free feed description.", - }, - [localFeedId]: { - feedId: localFeedId, - kind: "local", - title: "Local files", - }, - }, - episodes: { - [remoteEpisodeId]: { - episodeId: remoteEpisodeId, - feedId: remoteFeedId, - kind: "remote", - title: "Remote episode", - descriptionText: "Description", - contentText: "Show notes\nwithout active markup.", - episodeDate: "2026-07-10T10:20:30Z", - itunesTitle: "Remote episode", - episodeNumber: 12, - duration: 1234, - mediaType: "audio", - }, - [localEpisodeId]: { - episodeId: localEpisodeId, - feedId: localFeedId, - kind: "local", - title: "Local episode", - mediaType: "audio", - }, - }, - queue: { - ...playlist("Queue", [remoteEpisodeId]), - currentEpisodeId: remoteEpisodeId, - shouldEpisodeRemoveAfterPlay: true, - }, - favorites: playlist("Favorites", [remoteEpisodeId]), - localFiles: playlist("Local Files", [localEpisodeId]), - playlists: { - Research: playlist("Research", [localEpisodeId, remoteEpisodeId]), - }, - currentEpisodeId: remoteEpisodeId, - progress: { - [remoteEpisodeId]: { - episodeId: remoteEpisodeId, - time: 42.5, - duration: 1234, - finished: false, - }, - }, - podNotes: { - [remoteEpisodeId]: { - episodeId: remoteEpisodeId, - filePath: "PodNotes/Remote show/Remote episode.md", - }, - }, - downloads: { - [remoteEpisodeId]: [ - { - filePath: "PodNotes/Remote show/Remote episode.mp3", - size: 4096, - }, - ], - }, - localAssets: { - [localEpisodeId]: { - episodeId: localEpisodeId, - filePath: "Audio/Local episode.mp3", - }, - }, - extensions: { schemaVersion: 1 }, - }; -} - -export function cloneLibrary(): Record { - return structuredClone(validLibrary()); -} diff --git a/src/persistence/v3/canonicalEncoding.ts b/src/persistence/v3/canonicalEncoding.ts deleted file mode 100644 index 4bed6c9..0000000 --- a/src/persistence/v3/canonicalEncoding.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { LibraryV3ValidationError, MAX_LIBRARY_V3_BYTES, type LibraryV3 } from "./model"; -import { utf8ByteLength } from "./scalars"; -import { validateLibraryV3 } from "./validateLibrary"; - -export function decodeLibraryV3(serialized: string): LibraryV3 | null { - if ( - typeof serialized !== "string" || - serialized.length === 0 || - serialized.length > MAX_LIBRARY_V3_BYTES || - utf8ByteLength(serialized) > MAX_LIBRARY_V3_BYTES - ) { - return null; - } - try { - return validateLibraryV3(JSON.parse(serialized)); - } catch { - return null; - } -} - -export function encodeLibraryV3(value: unknown): string { - const normalized = validateLibraryV3(value); - if (!normalized) throw new LibraryV3ValidationError(); - return JSON.stringify(normalized); -} diff --git a/src/persistence/v3/collections.ts b/src/persistence/v3/collections.ts deleted file mode 100644 index d8a9e90..0000000 --- a/src/persistence/v3/collections.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { isEpisodeHandle, type EpisodeHandle, type FeedHandle } from "src/security/resourceHandles"; -import { normalizeEpisode, normalizeFeed, normalizePlaylist } from "./entities"; -import { - LIBRARY_V3_EXTENSIONS_SCHEMA_VERSION, - MAX_CUSTOM_PLAYLISTS, - MAX_DOWNLOADS_PER_EPISODE, - MAX_EPISODES, - MAX_FEEDS, - MAX_LOCAL_ASSETS, - MAX_PLAYLIST_NAME_BYTES, - MAX_POD_NOTE_ENTRIES, - MAX_PROGRESS_ENTRIES, - MAX_TOTAL_EPISODE_REFERENCES, - type DownloadAssetV3, - type DownloadMapV3, - type EpisodeMapV3, - type EpisodeProgressV3, - type FeedMapV3, - type LibraryExtensionsV1, - type LibraryPlaylistV3, - type LocalAssetMapV3, - type PodNoteMapV3, - type ValidationContext, -} from "./model"; -import { - compareCodeUnits, - INVALID, - isPlainDataRecord, - isStrictRecord, - normalizeText, - normalizeVaultPath, -} from "./scalars"; - -const PROGRESS_KEYS = new Set(["episodeId", "time", "duration", "finished"]); -const POD_NOTE_KEYS = new Set(["episodeId", "filePath"]); -const DOWNLOAD_ASSET_KEYS = new Set(["filePath", "size"]); -const LOCAL_ASSET_KEYS = new Set(["episodeId", "filePath"]); -const EXTENSIONS_KEYS = new Set(["schemaVersion"]); - -export function normalizeFeeds(value: unknown, context: ValidationContext): FeedMapV3 | null { - if (!isPlainDataRecord(value)) return null; - const keys = Object.keys(value); - if (keys.length > MAX_FEEDS) return null; - - const result: Partial>> = {}; - for (const key of keys.sort()) { - const feed = normalizeFeed(value[key], key, context); - if (!feed) return null; - result[feed.feedId] = feed; - } - return result; -} - -export function normalizeEpisodes(value: unknown, context: ValidationContext): EpisodeMapV3 | null { - if (!isPlainDataRecord(value)) return null; - const keys = Object.keys(value); - if (keys.length > MAX_EPISODES) return null; - - const result: Partial>> = {}; - for (const key of keys.sort()) { - const episode = normalizeEpisode(value[key], key, context); - if (!episode) return null; - result[episode.episodeId] = episode; - } - return result; -} - -export function normalizeCustomPlaylists( - value: unknown, - context: ValidationContext, -): Readonly> | null { - if (!isPlainDataRecord(value)) return null; - const keys = Object.keys(value); - if (keys.length > MAX_CUSTOM_PLAYLISTS) return null; - - const result: Record = {}; - for (const key of keys.sort()) { - const normalizedKey = normalizeText(key, MAX_PLAYLIST_NAME_BYTES, context); - const playlist = normalizePlaylist(value[key], context); - if (normalizedKey === INVALID || !playlist || playlist.name !== key) return null; - result[key] = playlist; - } - return result; -} - -export function normalizeProgress( - value: unknown, -): Readonly>> | null { - if (!isPlainDataRecord(value)) return null; - const keys = Object.keys(value); - if (keys.length > MAX_PROGRESS_ENTRIES) return null; - - const result: Partial> = {}; - for (const key of keys.sort()) { - const entry = value[key]; - if ( - !isEpisodeHandle(key) || - !isStrictRecord(entry, PROGRESS_KEYS) || - entry.episodeId !== key || - typeof entry.time !== "number" || - !Number.isFinite(entry.time) || - entry.time < 0 || - entry.time > Number.MAX_SAFE_INTEGER || - typeof entry.duration !== "number" || - !Number.isFinite(entry.duration) || - entry.duration < 0 || - entry.duration > Number.MAX_SAFE_INTEGER || - (entry.duration > 0 && entry.time > entry.duration) || - typeof entry.finished !== "boolean" - ) { - return null; - } - result[key] = { - episodeId: key, - time: entry.time, - duration: entry.duration, - finished: entry.finished, - }; - } - return result; -} - -export function normalizePodNotes(value: unknown, context: ValidationContext): PodNoteMapV3 | null { - if (!isPlainDataRecord(value)) return null; - const keys = Object.keys(value); - if (keys.length > MAX_POD_NOTE_ENTRIES) return null; - - const result: Partial>> = {}; - for (const key of keys.sort()) { - const entry = value[key]; - if ( - !isEpisodeHandle(key) || - !isStrictRecord(entry, POD_NOTE_KEYS) || - entry.episodeId !== key - ) { - return null; - } - const filePath = normalizeVaultPath(entry.filePath, context); - if (filePath === INVALID) return null; - result[key] = { episodeId: key, filePath }; - } - return result; -} - -export function normalizeDownloads( - value: unknown, - context: ValidationContext, -): DownloadMapV3 | null { - if (!isPlainDataRecord(value)) return null; - const keys = Object.keys(value); - if (keys.length > MAX_EPISODES) return null; - - const result: Partial> = {}; - for (const key of keys.sort()) { - const entries = value[key]; - if ( - !isEpisodeHandle(key) || - !Array.isArray(entries) || - entries.length === 0 || - entries.length > MAX_DOWNLOADS_PER_EPISODE - ) { - return null; - } - - const normalized: DownloadAssetV3[] = []; - for (const entry of entries) { - if ( - !isStrictRecord(entry, DOWNLOAD_ASSET_KEYS) || - typeof entry.size !== "number" || - !Number.isSafeInteger(entry.size) || - entry.size < 0 - ) { - return null; - } - const filePath = normalizeVaultPath(entry.filePath, context); - if (filePath === INVALID) return null; - normalized.push({ filePath, size: entry.size }); - } - context.episodeReferences += normalized.length; - if (context.episodeReferences > MAX_TOTAL_EPISODE_REFERENCES) return null; - result[key] = normalized.sort((a, b) => compareCodeUnits(a.filePath, b.filePath)); - } - return result; -} - -export function normalizeLocalAssets( - value: unknown, - context: ValidationContext, -): LocalAssetMapV3 | null { - if (!isPlainDataRecord(value)) return null; - const keys = Object.keys(value); - if (keys.length > MAX_LOCAL_ASSETS) return null; - - const result: Partial>> = {}; - for (const key of keys.sort()) { - const entry = value[key]; - if ( - !isEpisodeHandle(key) || - !isStrictRecord(entry, LOCAL_ASSET_KEYS) || - entry.episodeId !== key - ) { - return null; - } - const filePath = normalizeVaultPath(entry.filePath, context); - if (filePath === INVALID) return null; - result[key] = { episodeId: key, filePath }; - } - context.episodeReferences += keys.length; - return context.episodeReferences <= MAX_TOTAL_EPISODE_REFERENCES ? result : null; -} - -export function normalizeExtensions(value: unknown): LibraryExtensionsV1 | null { - return isStrictRecord(value, EXTENSIONS_KEYS) && - value.schemaVersion === LIBRARY_V3_EXTENSIONS_SCHEMA_VERSION - ? { schemaVersion: LIBRARY_V3_EXTENSIONS_SCHEMA_VERSION } - : null; -} diff --git a/src/persistence/v3/entities.ts b/src/persistence/v3/entities.ts deleted file mode 100644 index eb53a78..0000000 --- a/src/persistence/v3/entities.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { isEpisodeHandle, isFeedHandle, type EpisodeHandle } from "src/security/resourceHandles"; -import { isFeedCapabilityReferenceFor } from "src/security/feedCapabilityReferences"; -import { - MAX_AUTHOR_BYTES, - MAX_COLLECTION_ID_BYTES, - MAX_CONTENT_TEXT_BYTES, - MAX_DESCRIPTION_TEXT_BYTES, - MAX_ICON_BYTES, - MAX_PLAYLIST_EPISODE_REFERENCES, - MAX_PLAYLIST_NAME_BYTES, - MAX_TITLE_BYTES, - MAX_TOTAL_EPISODE_REFERENCES, - type LibraryEpisodeV3, - type LibraryFeedV3, - type LibraryPlaylistV3, - type ValidationContext, -} from "./model"; -import { - INVALID, - hasOwn, - isPlainDataRecord, - isStrictRecord, - normalizeText, - optionalDate, - optionalNonNegativeNumber, - optionalText, -} from "./scalars"; - -const REMOTE_FEED_KEYS = new Set([ - "feedId", - "kind", - "capabilityRef", - "title", - "collectionId", - "author", - "descriptionText", -]); -const LOCAL_FEED_KEYS = new Set([ - "feedId", - "kind", - "title", - "collectionId", - "author", - "descriptionText", -]); -const EPISODE_KEYS = new Set([ - "episodeId", - "feedId", - "kind", - "title", - "descriptionText", - "contentText", - "episodeDate", - "itunesTitle", - "episodeNumber", - "duration", - "mediaType", -]); -const PLAYLIST_KEYS = new Set([ - "name", - "icon", - "episodeIds", - "currentEpisodeId", - "shouldEpisodeRemoveAfterPlay", - "shouldRepeat", -]); - -export function normalizeFeed( - value: unknown, - expectedFeedId: string, - context: ValidationContext, -): LibraryFeedV3 | null { - if (!isPlainDataRecord(value) || !isFeedHandle(expectedFeedId)) return null; - if (value.kind !== "remote" && value.kind !== "local") return null; - const allowedKeys = value.kind === "remote" ? REMOTE_FEED_KEYS : LOCAL_FEED_KEYS; - if (!isStrictRecord(value, allowedKeys) || value.feedId !== expectedFeedId) return null; - - const title = normalizeText(value.title, MAX_TITLE_BYTES, context); - const collectionId = optionalText(value, "collectionId", MAX_COLLECTION_ID_BYTES, context); - const author = optionalText(value, "author", MAX_AUTHOR_BYTES, context); - const descriptionText = optionalText( - value, - "descriptionText", - MAX_DESCRIPTION_TEXT_BYTES, - context, - { multiline: true, rejectHtml: true }, - ); - if ( - title === INVALID || - collectionId === INVALID || - author === INVALID || - descriptionText === INVALID - ) { - return null; - } - - const metadata = { - feedId: expectedFeedId, - title, - ...(collectionId ? { collectionId } : {}), - ...(author ? { author } : {}), - ...(descriptionText ? { descriptionText } : {}), - }; - if (value.kind === "local") return { ...metadata, kind: "local" }; - if (!isFeedCapabilityReferenceFor(expectedFeedId, value.capabilityRef)) return null; - return { ...metadata, kind: "remote", capabilityRef: value.capabilityRef }; -} - -export function normalizeEpisode( - value: unknown, - expectedEpisodeId: string, - context: ValidationContext, -): LibraryEpisodeV3 | null { - if ( - !isStrictRecord(value, EPISODE_KEYS) || - !isEpisodeHandle(expectedEpisodeId) || - value.episodeId !== expectedEpisodeId || - !isFeedHandle(value.feedId) || - (value.kind !== "remote" && value.kind !== "local") - ) { - return null; - } - - const title = normalizeText(value.title, MAX_TITLE_BYTES, context); - const descriptionText = optionalText( - value, - "descriptionText", - MAX_DESCRIPTION_TEXT_BYTES, - context, - { multiline: true, rejectHtml: true }, - ); - const contentText = optionalText(value, "contentText", MAX_CONTENT_TEXT_BYTES, context, { - multiline: true, - rejectHtml: true, - }); - const episodeDate = optionalDate(value, "episodeDate"); - const itunesTitle = optionalText(value, "itunesTitle", MAX_TITLE_BYTES, context); - const episodeNumber = optionalNonNegativeNumber(value, "episodeNumber", true); - const duration = optionalNonNegativeNumber(value, "duration", false); - if ( - title === INVALID || - descriptionText === INVALID || - contentText === INVALID || - episodeDate === INVALID || - itunesTitle === INVALID || - episodeNumber === INVALID || - duration === INVALID || - (hasOwn(value, "mediaType") && value.mediaType !== "audio" && value.mediaType !== "video") - ) { - return null; - } - - return { - episodeId: expectedEpisodeId, - feedId: value.feedId, - kind: value.kind, - title, - ...(descriptionText ? { descriptionText } : {}), - ...(contentText ? { contentText } : {}), - ...(episodeDate ? { episodeDate } : {}), - ...(itunesTitle ? { itunesTitle } : {}), - ...(episodeNumber !== undefined ? { episodeNumber } : {}), - ...(duration !== undefined ? { duration } : {}), - ...(value.mediaType === "audio" || value.mediaType === "video" - ? { mediaType: value.mediaType } - : {}), - }; -} - -export function normalizePlaylist( - value: unknown, - context: ValidationContext, -): LibraryPlaylistV3 | null { - if (!isStrictRecord(value, PLAYLIST_KEYS) || !Array.isArray(value.episodeIds)) return null; - if (value.episodeIds.length > MAX_PLAYLIST_EPISODE_REFERENCES) return null; - if ( - typeof value.shouldEpisodeRemoveAfterPlay !== "boolean" || - typeof value.shouldRepeat !== "boolean" - ) { - return null; - } - - const name = normalizeText(value.name, MAX_PLAYLIST_NAME_BYTES, context); - const icon = normalizeText(value.icon, MAX_ICON_BYTES, context); - if (name === INVALID || icon === INVALID) return null; - - const episodeIds: EpisodeHandle[] = []; - const membership = new Set(); - for (const episodeId of value.episodeIds) { - if (!isEpisodeHandle(episodeId)) return null; - membership.add(episodeId); - episodeIds.push(episodeId); - } - context.episodeReferences += episodeIds.length; - if (context.episodeReferences > MAX_TOTAL_EPISODE_REFERENCES) return null; - - let currentEpisodeId: EpisodeHandle | undefined; - if (hasOwn(value, "currentEpisodeId")) { - if (!isEpisodeHandle(value.currentEpisodeId) || !membership.has(value.currentEpisodeId)) { - return null; - } - currentEpisodeId = value.currentEpisodeId; - context.episodeReferences += 1; - if (context.episodeReferences > MAX_TOTAL_EPISODE_REFERENCES) return null; - } - - return { - name, - icon, - episodeIds, - ...(currentEpisodeId ? { currentEpisodeId } : {}), - shouldEpisodeRemoveAfterPlay: value.shouldEpisodeRemoveAfterPlay, - shouldRepeat: value.shouldRepeat, - }; -} diff --git a/src/persistence/v3/libraryModel.hostile.test.ts b/src/persistence/v3/libraryModel.hostile.test.ts deleted file mode 100644 index d3ed240..0000000 --- a/src/persistence/v3/libraryModel.hostile.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - MAX_FEEDS, - MAX_PLAYLIST_EPISODE_REFERENCES, - MAX_TITLE_BYTES, - validateLibraryV3, -} from "./libraryModel"; -import { - cloneLibrary, - foreignFeedId, - localEpisodeId, - localFeedId, - missingEpisodeId, - playlist, - remoteCapabilityRef, - remoteEpisodeId, - remoteFeedId, - secondRemoteEpisodeId, -} from "./__tests__/fixtures"; -import { serializedLibraryFits } from "./scalars"; - -describe("schema-v3 hostile input rejection", () => { - it("does not classify non-serializable roots as fitting the byte budget", () => { - expect(serializedLibraryFits(undefined)).toBe(false); - expect(serializedLibraryFits(Symbol("unsupported"))).toBe(false); - }); - - it.each([ - ["feed url", "feed", "url"], - ["feed artwork", "feed", "artworkUrl"], - ["feed site link", "feed", "link"], - ["episode url", "episode", "url"], - ["stream target", "episode", "streamUrl"], - ["feed target", "episode", "feedUrl"], - ["episode artwork", "episode", "artworkUrl"], - ["chapters target", "episode", "chaptersUrl"], - ["raw guid", "episode", "guid"], - ["episode target reference", "episode", "targetRef"], - ["legacy HTML content", "episode", "content"], - ])("rejects hidden target field %s", (_label, owner, field) => { - const value = cloneLibrary(); - const target = - owner === "feed" ? value.feeds[remoteFeedId] : value.episodes[remoteEpisodeId]; - target[field] = - field === "content" - ? "secret" - : "https://secret.example/value"; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it.each(["descriptionText", "contentText"])( - "rejects HTML disguised as target-free episode %s", - (field) => { - const value = cloneLibrary(); - value.episodes[remoteEpisodeId][field] = - ""; - expect(validateLibraryV3(value)).toBeNull(); - }, - ); - - it("rejects HTML disguised as target-free feed description text", () => { - const value = cloneLibrary(); - value.feeds[remoteFeedId].descriptionText = - ""; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it("rejects a capability reference on a local feed", () => { - const value = cloneLibrary(); - value.feeds[localFeedId].capabilityRef = remoteCapabilityRef; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it("rejects a nested legacy playlist currentEpisode snapshot", () => { - const value = cloneLibrary(); - value.queue.currentEpisode = { - ...value.episodes[remoteEpisodeId], - streamUrl: "https://secret.example/audio.mp3", - }; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it("rejects cross-feed and kind ownership mismatches", () => { - const crossFeed = cloneLibrary(); - crossFeed.episodes[remoteEpisodeId].feedId = localFeedId; - expect(validateLibraryV3(crossFeed)).toBeNull(); - - const wrongKind = cloneLibrary(); - wrongKind.episodes[remoteEpisodeId].kind = "local"; - expect(validateLibraryV3(wrongKind)).toBeNull(); - - const foreignOwner = cloneLibrary(); - foreignOwner.episodes[remoteEpisodeId].feedId = foreignFeedId; - expect(validateLibraryV3(foreignOwner)).toBeNull(); - }); - - it("rejects missing references from every top-level reference map", () => { - const current = cloneLibrary(); - current.currentEpisodeId = missingEpisodeId; - expect(validateLibraryV3(current)).toBeNull(); - - const progress = cloneLibrary(); - progress.progress[missingEpisodeId] = { - episodeId: missingEpisodeId, - time: 0, - duration: 0, - finished: false, - }; - expect(validateLibraryV3(progress)).toBeNull(); - - const note = cloneLibrary(); - note.podNotes[missingEpisodeId] = { - episodeId: missingEpisodeId, - filePath: "PodNotes/Missing.md", - }; - expect(validateLibraryV3(note)).toBeNull(); - - const download = cloneLibrary(); - download.downloads[missingEpisodeId] = [{ filePath: "PodNotes/Missing.mp3", size: 100 }]; - expect(validateLibraryV3(download)).toBeNull(); - }); - - it("rejects prototype-pollution keys at every map boundary", () => { - const root = cloneLibrary(); - Object.setPrototypeOf(root, { polluted: true }); - expect(validateLibraryV3(root)).toBeNull(); - - const feeds = cloneLibrary(); - Object.defineProperty(feeds.feeds, "constructor", { - value: { feedId: remoteFeedId }, - enumerable: true, - }); - expect(validateLibraryV3(feeds)).toBeNull(); - - const playlists = cloneLibrary(); - Object.defineProperty(playlists.playlists, "prototype", { - value: playlist("prototype"), - enumerable: true, - }); - expect(validateLibraryV3(playlists)).toBeNull(); - }); - - it("rejects count and string bombs before accepting the model", () => { - const tooManyFeeds = cloneLibrary(); - tooManyFeeds.feeds = Object.fromEntries( - Array.from({ length: MAX_FEEDS + 1 }, (_, index) => { - const hex = index.toString(16).padStart(64, "0"); - const feedId = `podnotes-feed-${hex}`; - return [feedId, { feedId, kind: "local", title: `Feed ${index}` }]; - }), - ); - expect(validateLibraryV3(tooManyFeeds)).toBeNull(); - - const tooManyRefs = cloneLibrary(); - tooManyRefs.queue.episodeIds = Array.from( - { length: MAX_PLAYLIST_EPISODE_REFERENCES + 1 }, - () => remoteEpisodeId, - ); - expect(validateLibraryV3(tooManyRefs)).toBeNull(); - - const longTitle = cloneLibrary(); - longTitle.feeds[remoteFeedId].title = "x".repeat(MAX_TITLE_BYTES + 1); - expect(validateLibraryV3(longTitle)).toBeNull(); - }); - - it.each([ - "not-a-date", - "2026-02-30T12:00:00Z", - "2026-01-01", - "2026-01-01T25:00:00Z", - "2026-01-01T12:00:00", - "0001-01-01T00:00:00+23:59", - "9999-12-31T23:59:59-23:59", - ])("rejects invalid or timezone-free date %s", (episodeDate) => { - const value = cloneLibrary(); - value.episodes[remoteEpisodeId].episodeDate = episodeDate; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it.each([ - "/absolute/file.mp3", - "../escape.mp3", - "Audio/../escape.mp3", - "Audio\\file.mp3", - "https://secret.example/file.mp3", - "C:/absolute/file.mp3", - "Audio//file.mp3", - "Audio/", - " Audio/file.mp3", - ".obsidian/plugins/podnotes/data.json", - ".TRASH/deleted.mp3", - ".Git/config", - ".Hg/store", - ".SvN/entries", - "Audio/CON", - "Audio/prn.mp3", - "Audio/COM1.wav", - "Audio/lPt9.txt", - "Audio/bad.mp3", - "Audio/bad:name.mp3", - "Audio/file.mp3.", - "Audio./file.mp3", - "Audio/Cafe\u0301.mp3", - ])("rejects non-canonical vault path %s", (filePath) => { - const value = cloneLibrary(); - value.localAssets[localEpisodeId].filePath = filePath; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it("rejects local-vs-remote asset mismatches", () => { - const remoteLocalAsset = cloneLibrary(); - remoteLocalAsset.localAssets[remoteEpisodeId] = { - episodeId: remoteEpisodeId, - filePath: "Audio/remote.mp3", - }; - expect(validateLibraryV3(remoteLocalAsset)).toBeNull(); - - const localDownload = cloneLibrary(); - localDownload.downloads[localEpisodeId] = [{ filePath: "Audio/local.mp3", size: 100 }]; - expect(validateLibraryV3(localDownload)).toBeNull(); - - const remoteInLocalFiles = cloneLibrary(); - remoteInLocalFiles.localFiles.episodeIds.push(remoteEpisodeId); - expect(validateLibraryV3(remoteInLocalFiles)).toBeNull(); - }); - - it("rejects media paths claimed by more than one episode", () => { - const value = cloneLibrary(); - value.episodes[secondRemoteEpisodeId] = { - ...value.episodes[remoteEpisodeId], - episodeId: secondRemoteEpisodeId, - title: "Second remote episode", - }; - value.downloads[secondRemoteEpisodeId] = [ - { - filePath: value.downloads[remoteEpisodeId][0].filePath, - size: 4096, - }, - ]; - - expect(validateLibraryV3(value)).toBeNull(); - }); - - it("rejects a download path colliding with a local asset", () => { - const value = cloneLibrary(); - value.downloads[remoteEpisodeId][0].filePath = value.localAssets[localEpisodeId].filePath; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it("rejects a pod-note path colliding with a media asset", () => { - const value = cloneLibrary(); - value.podNotes[remoteEpisodeId].filePath = value.downloads[remoteEpisodeId][0].filePath; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it("rejects two pod notes that claim the same portable path", () => { - const value = cloneLibrary(); - value.episodes[secondRemoteEpisodeId] = { - ...value.episodes[remoteEpisodeId], - episodeId: secondRemoteEpisodeId, - }; - value.podNotes[remoteEpisodeId].filePath = "PodNotes/Caf\u00e9.md"; - value.podNotes[secondRemoteEpisodeId] = { - episodeId: secondRemoteEpisodeId, - filePath: "podnotes/CAF\u00c9.md", - }; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it("rejects case-insensitive aliases across download and local assets", () => { - const value = cloneLibrary(); - value.localAssets[localEpisodeId].filePath = - value.downloads[remoteEpisodeId][0].filePath.toUpperCase(); - expect(validateLibraryV3(value)).toBeNull(); - }); -}); diff --git a/src/persistence/v3/libraryModel.test.ts b/src/persistence/v3/libraryModel.test.ts deleted file mode 100644 index 7d40769..0000000 --- a/src/persistence/v3/libraryModel.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - decodeLibraryV3, - encodeLibraryV3, - portableVaultPathOwnershipKey, - validateLibraryV3, -} from "./libraryModel"; -import { - cloneLibrary, - foreignCapabilityRef, - foreignFeedId, - localEpisodeId, - missingEpisodeId, - remoteEpisodeId, - remoteFeedId, - secondRemoteEpisodeId, - validLibrary, -} from "./__tests__/fixtures"; - -describe("schema-v3 target-free library model", () => { - it("normalizes dates and has a stable deterministic round trip", () => { - const first = validateLibraryV3(validLibrary()); - expect(first).not.toBeNull(); - expect(first?.episodes[remoteEpisodeId]?.episodeDate).toBe("2026-07-10T10:20:30.000Z"); - expect(first?.episodes[remoteEpisodeId]).not.toHaveProperty("podcastName"); - - const reversed = cloneLibrary(); - reversed.feeds = Object.fromEntries(Object.entries(reversed.feeds).reverse()); - reversed.episodes = Object.fromEntries(Object.entries(reversed.episodes).reverse()); - const encoded = encodeLibraryV3(validLibrary()); - expect(encodeLibraryV3(reversed)).toBe(encoded); - expect(encodeLibraryV3(decodeLibraryV3(encoded))).toBe(encoded); - }); - - it("uses Gregorian leap-year rules for early four-digit years", () => { - const value = cloneLibrary(); - value.episodes[remoteEpisodeId].episodeDate = "0096-02-29T00:00:00Z"; - - expect(validateLibraryV3(value)?.episodes[remoteEpisodeId]?.episodeDate).toBe( - "0096-02-29T00:00:00.000Z", - ); - }); - - it("uses handles rather than titles as episode identity", () => { - const value = cloneLibrary(); - value.episodes[secondRemoteEpisodeId] = { - ...value.episodes[remoteEpisodeId], - episodeId: secondRemoteEpisodeId, - }; - - const normalized = validateLibraryV3(value); - expect(normalized?.episodes[remoteEpisodeId]?.title).toBe("Remote episode"); - expect(normalized?.episodes[secondRemoteEpisodeId]?.title).toBe("Remote episode"); - }); - - it("sorts download assets with a deterministic code-unit order", () => { - const value = cloneLibrary(); - value.downloads[remoteEpisodeId] = [ - { filePath: "Audio/a.mp3", size: 2 }, - { filePath: "Audio/Z.mp3", size: 1 }, - ]; - - expect(validateLibraryV3(value)?.downloads[remoteEpisodeId]).toEqual([ - { filePath: "Audio/Z.mp3", size: 1 }, - { filePath: "Audio/a.mp3", size: 2 }, - ]); - }); - - it("derives a locale-independent Unicode-normalized path ownership key", () => { - expect(portableVaultPathOwnershipKey("Audio/CAFE\u0301.mp3")).toBe( - portableVaultPathOwnershipKey("audio/caf\u00e9.mp3"), - ); - }); - - it("binds a remote feed to its exact capability reference", () => { - const value = cloneLibrary(); - value.feeds[remoteFeedId].capabilityRef = foreignCapabilityRef; - expect(validateLibraryV3(value)).toBeNull(); - }); - - it("preserves duplicate playlist positions and rejects missing episode references", () => { - const duplicate = cloneLibrary(); - duplicate.queue.episodeIds.push(remoteEpisodeId); - expect(validateLibraryV3(duplicate)?.queue.episodeIds).toEqual([ - remoteEpisodeId, - remoteEpisodeId, - ]); - - const missing = cloneLibrary(); - missing.favorites.episodeIds.push(missingEpisodeId); - expect(validateLibraryV3(missing)).toBeNull(); - - const detachedCurrent = cloneLibrary(); - detachedCurrent.queue.currentEpisodeId = localEpisodeId; - expect(validateLibraryV3(detachedCurrent)).toBeNull(); - }); - - it("rejects mismatched map keys", () => { - const feed = cloneLibrary(); - feed.feeds[remoteFeedId].feedId = foreignFeedId; - expect(validateLibraryV3(feed)).toBeNull(); - - const episode = cloneLibrary(); - episode.episodes[remoteEpisodeId].episodeId = missingEpisodeId; - expect(validateLibraryV3(episode)).toBeNull(); - - const progress = cloneLibrary(); - progress.progress[remoteEpisodeId].episodeId = localEpisodeId; - expect(validateLibraryV3(progress)).toBeNull(); - - const note = cloneLibrary(); - note.podNotes[remoteEpisodeId].episodeId = localEpisodeId; - expect(validateLibraryV3(note)).toBeNull(); - - const localAsset = cloneLibrary(); - localAsset.localAssets[localEpisodeId].episodeId = remoteEpisodeId; - expect(validateLibraryV3(localAsset)).toBeNull(); - }); - - it("keeps the reserved extension namespace strict, versioned, and empty", () => { - const data = cloneLibrary(); - data.extensions.unresolved = []; - expect(validateLibraryV3(data)).toBeNull(); - - const version = cloneLibrary(); - version.extensions.schemaVersion = 2; - expect(validateLibraryV3(version)).toBeNull(); - }); -}); diff --git a/src/persistence/v3/libraryModel.ts b/src/persistence/v3/libraryModel.ts deleted file mode 100644 index 3055b8d..0000000 --- a/src/persistence/v3/libraryModel.ts +++ /dev/null @@ -1,44 +0,0 @@ -export { decodeLibraryV3, encodeLibraryV3 } from "./canonicalEncoding"; -export { - LIBRARY_V3_EXTENSIONS_SCHEMA_VERSION, - LIBRARY_V3_SCHEMA_VERSION, - MAX_AUTHOR_BYTES, - MAX_COLLECTION_ID_BYTES, - MAX_CONTENT_TEXT_BYTES, - MAX_CUSTOM_PLAYLISTS, - MAX_DESCRIPTION_TEXT_BYTES, - MAX_DOWNLOADS_PER_EPISODE, - MAX_EPISODES, - MAX_FEEDS, - MAX_ICON_BYTES, - MAX_LIBRARY_V3_BYTES, - MAX_LOCAL_ASSETS, - MAX_PLAYLIST_EPISODE_REFERENCES, - MAX_PLAYLIST_NAME_BYTES, - MAX_POD_NOTE_ENTRIES, - MAX_PROGRESS_ENTRIES, - MAX_TITLE_BYTES, - MAX_TOTAL_EPISODE_REFERENCES, - MAX_TOTAL_TEXT_BYTES, - MAX_VAULT_PATH_BYTES, - LibraryV3ValidationError, - type DownloadAssetV3, - type DownloadMapV3, - type EpisodeMapV3, - type EpisodeProgressV3, - type FeedMapV3, - type LibraryEpisodeV3, - type LibraryExtensionsV1, - type LibraryFeedV3, - type LibraryPlaylistV3, - type LibraryV3, - type LocalAssetMapV3, - type LocalAssetV3, - type LocalFeedV3, - type PodNoteMapV3, - type PodNoteV3, - type ProgressMapV3, - type RemoteFeedV3, -} from "./model"; -export { validateLibraryV3 } from "./validateLibrary"; -export { portableVaultPathOwnershipKey } from "./scalars"; diff --git a/src/persistence/v3/model.ts b/src/persistence/v3/model.ts deleted file mode 100644 index 1ffea83..0000000 --- a/src/persistence/v3/model.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { EpisodeHandle, FeedHandle } from "src/security/resourceHandles"; -import type { FeedCapabilityReference } from "src/security/feedCapabilityReferences"; - -export const LIBRARY_V3_SCHEMA_VERSION = 3; -export const LIBRARY_V3_EXTENSIONS_SCHEMA_VERSION = 1; - -export const MAX_FEEDS = 4_096; -export const MAX_EPISODES = 50_000; -export const MAX_CUSTOM_PLAYLISTS = 512; -export const MAX_PLAYLIST_EPISODE_REFERENCES = 4_096; -export const MAX_PROGRESS_ENTRIES = 50_000; -export const MAX_POD_NOTE_ENTRIES = 50_000; -export const MAX_DOWNLOADS_PER_EPISODE = 32; -export const MAX_LOCAL_ASSETS = 50_000; -export const MAX_TOTAL_EPISODE_REFERENCES = 200_000; - -export const MAX_TITLE_BYTES = 2 * 1024; -export const MAX_COLLECTION_ID_BYTES = 512; -export const MAX_AUTHOR_BYTES = 2 * 1024; -export const MAX_DESCRIPTION_TEXT_BYTES = 256 * 1024; -export const MAX_CONTENT_TEXT_BYTES = 512 * 1024; -export const MAX_PLAYLIST_NAME_BYTES = 1024; -export const MAX_ICON_BYTES = 256; -export const MAX_VAULT_PATH_BYTES = 8 * 1024; -export const MAX_TOTAL_TEXT_BYTES = 8 * 1024 * 1024; -export const MAX_LIBRARY_V3_BYTES = 16 * 1024 * 1024; - -export interface ValidationContext { - textBytes: number; - episodeReferences: number; -} - -interface FeedMetadataV3 { - feedId: FeedHandle; - title: string; - collectionId?: string; - author?: string; - descriptionText?: string; -} - -export interface RemoteFeedV3 extends FeedMetadataV3 { - kind: "remote"; - capabilityRef: FeedCapabilityReference; -} - -export interface LocalFeedV3 extends FeedMetadataV3 { - kind: "local"; -} - -export type LibraryFeedV3 = RemoteFeedV3 | LocalFeedV3; - -export interface LibraryEpisodeV3 { - episodeId: EpisodeHandle; - feedId: FeedHandle; - kind: "remote" | "local"; - title: string; - descriptionText?: string; - contentText?: string; - episodeDate?: string; - itunesTitle?: string; - episodeNumber?: number; - duration?: number; - mediaType?: "audio" | "video"; -} - -export interface LibraryPlaylistV3 { - name: string; - icon: string; - episodeIds: EpisodeHandle[]; - currentEpisodeId?: EpisodeHandle; - shouldEpisodeRemoveAfterPlay: boolean; - shouldRepeat: boolean; -} - -export interface EpisodeProgressV3 { - episodeId: EpisodeHandle; - time: number; - duration: number; - finished: boolean; -} - -export interface PodNoteV3 { - episodeId: EpisodeHandle; - filePath: string; -} - -export interface DownloadAssetV3 { - filePath: string; - size: number; -} - -export interface LocalAssetV3 { - episodeId: EpisodeHandle; - filePath: string; -} - -export interface LibraryExtensionsV1 { - schemaVersion: typeof LIBRARY_V3_EXTENSIONS_SCHEMA_VERSION; -} - -export type FeedMapV3 = Readonly>>; -export type EpisodeMapV3 = Readonly>>; -export type ProgressMapV3 = Readonly>>; -export type PodNoteMapV3 = Readonly>>; -export type DownloadMapV3 = Readonly>>; -export type LocalAssetMapV3 = Readonly>>; - -export interface LibraryV3 { - schemaVersion: typeof LIBRARY_V3_SCHEMA_VERSION; - feeds: FeedMapV3; - episodes: EpisodeMapV3; - queue: LibraryPlaylistV3; - favorites: LibraryPlaylistV3; - localFiles: LibraryPlaylistV3; - playlists: Readonly>; - currentEpisodeId?: EpisodeHandle; - progress: ProgressMapV3; - podNotes: PodNoteMapV3; - downloads: DownloadMapV3; - localAssets: LocalAssetMapV3; - extensions: LibraryExtensionsV1; -} - -export class LibraryV3ValidationError extends Error { - constructor() { - super("PodNotes schema-v3 library data is invalid."); - this.name = "LibraryV3ValidationError"; - } -} diff --git a/src/persistence/v3/referentialIntegrity.ts b/src/persistence/v3/referentialIntegrity.ts deleted file mode 100644 index c649e7d..0000000 --- a/src/persistence/v3/referentialIntegrity.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { EpisodeHandle } from "src/security/resourceHandles"; -import type { LibraryV3 } from "./model"; -import { portableVaultPathOwnershipKey } from "./scalars"; - -export function libraryReferencesAreValid(model: LibraryV3): boolean { - for (const episode of Object.values(model.episodes)) { - if (!episode) continue; - const feed = model.feeds[episode.feedId]; - if (!feed || feed.kind !== episode.kind) return false; - } - - const episodeExists = (episodeId: EpisodeHandle): boolean => Boolean(model.episodes[episodeId]); - if (model.currentEpisodeId && !episodeExists(model.currentEpisodeId)) return false; - - const allPlaylists = [ - model.queue, - model.favorites, - model.localFiles, - ...Object.values(model.playlists), - ]; - for (const playlist of allPlaylists) { - if (playlist.episodeIds.some((episodeId) => !episodeExists(episodeId))) return false; - if (playlist.currentEpisodeId && !episodeExists(playlist.currentEpisodeId)) return false; - } - if ( - model.localFiles.episodeIds.some((episodeId) => model.episodes[episodeId]?.kind !== "local") - ) { - return false; - } - - for (const [episodeId, progress] of Object.entries(model.progress)) { - if ( - !progress || - progress.episodeId !== episodeId || - !model.episodes[episodeId as EpisodeHandle] - ) - return false; - } - const claimedPaths = new Set(); - const claimPath = (path: string): boolean => { - const ownershipKey = portableVaultPathOwnershipKey(path); - if (claimedPaths.has(ownershipKey)) return false; - claimedPaths.add(ownershipKey); - return true; - }; - - for (const [episodeId, note] of Object.entries(model.podNotes)) { - if (!note || note.episodeId !== episodeId || !model.episodes[episodeId as EpisodeHandle]) { - return false; - } - if (!claimPath(note.filePath)) return false; - } - - for (const [episodeId, assets] of Object.entries(model.downloads)) { - const episode = model.episodes[episodeId as EpisodeHandle]; - if (!episode || episode.kind !== "remote" || !assets) return false; - for (const asset of assets) { - if (!claimPath(asset.filePath)) return false; - } - } - - const localFileIds = new Set(model.localFiles.episodeIds); - for (const [episodeId, asset] of Object.entries(model.localAssets)) { - const episode = model.episodes[episodeId as EpisodeHandle]; - if ( - !asset || - asset.episodeId !== episodeId || - !episode || - episode.kind !== "local" || - !localFileIds.has(episodeId as EpisodeHandle) - ) { - return false; - } - if (!claimPath(asset.filePath)) return false; - } - if (localFileIds.size !== Object.keys(model.localAssets).length) return false; - - for (const episode of Object.values(model.episodes)) { - if (episode?.kind === "local" && !model.localAssets[episode.episodeId]) return false; - } - return true; -} diff --git a/src/persistence/v3/scalars.ts b/src/persistence/v3/scalars.ts deleted file mode 100644 index 1487f0d..0000000 --- a/src/persistence/v3/scalars.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { - MAX_LIBRARY_V3_BYTES, - MAX_TOTAL_TEXT_BYTES, - MAX_VAULT_PATH_BYTES, - type ValidationContext, -} from "./model"; - -export const INVALID = Symbol("invalid-library-v3-value"); -export type Invalid = typeof INVALID; -export type UnknownRecord = Record; - -const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); -const RESERVED_VAULT_ROOTS = new Set([".obsidian", ".trash", ".git", ".hg", ".svn"]); -const WINDOWS_RESERVED_CHARACTERS = /[<>:"|?*]/; -const WINDOWS_RESERVED_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; -const textEncoder = new TextEncoder(); - -export function utf8ByteLength(value: string): number { - return textEncoder.encode(value).byteLength; -} - -export function serializedLibraryFits(value: unknown): boolean { - try { - const serialized = JSON.stringify(value); - return typeof serialized === "string" && utf8ByteLength(serialized) <= MAX_LIBRARY_V3_BYTES; - } catch { - return false; - } -} - -export function isPlainDataRecord(value: unknown): value is UnknownRecord { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) return false; - - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== "string" || DANGEROUS_KEYS.has(key)) return false; - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor?.enumerable || !("value" in descriptor)) return false; - } - return true; -} - -export function isStrictRecord( - value: unknown, - allowedKeys: ReadonlySet, -): value is UnknownRecord { - return isPlainDataRecord(value) && Object.keys(value).every((key) => allowedKeys.has(key)); -} - -export function hasOwn(record: UnknownRecord, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key); -} - -function hasInvalidUnicode(value: string, allowMultiline: boolean): boolean { - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - const allowedWhitespace = allowMultiline && (code === 0x09 || code === 0x0a); - if ((code <= 0x1f && !allowedWhitespace) || code === 0x7f) return true; - if ( - (code >= 0x202a && code <= 0x202e) || - (code >= 0x2066 && code <= 0x2069) || - code === 0x200e || - code === 0x200f - ) { - return true; - } - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) return true; - index += 1; - } else if (code >= 0xdc00 && code <= 0xdfff) { - return true; - } - } - return false; -} - -function looksLikeHtml(value: string): boolean { - return /<(?:\/?[a-z][^>]*|!--|!doctype\b|\?xml\b)/i.test(value); -} - -export function normalizeText( - value: unknown, - maximumBytes: number, - context: ValidationContext, - options: { multiline?: boolean; rejectHtml?: boolean } = {}, -): string | Invalid { - if (typeof value !== "string" || value.length === 0 || value.trim() !== value) return INVALID; - if (value.normalize("NFC") !== value || value.includes("\r")) return INVALID; - if (hasInvalidUnicode(value, options.multiline === true)) return INVALID; - if (!options.multiline && (value.includes("\n") || value.includes("\t"))) return INVALID; - if (options.rejectHtml && looksLikeHtml(value)) return INVALID; - - const bytes = utf8ByteLength(value); - if (bytes > maximumBytes || context.textBytes + bytes > MAX_TOTAL_TEXT_BYTES) return INVALID; - context.textBytes += bytes; - return value; -} - -export function optionalText( - record: UnknownRecord, - key: string, - maximumBytes: number, - context: ValidationContext, - options: { multiline?: boolean; rejectHtml?: boolean } = {}, -): string | undefined | Invalid { - if (!hasOwn(record, key)) return undefined; - return normalizeText(record[key], maximumBytes, context, options); -} - -export function normalizeIsoDate(value: unknown): string | Invalid { - if (typeof value !== "string") return INVALID; - const match = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|([+-])(\d{2}):(\d{2}))$/.exec( - value, - ); - if (!match) return INVALID; - - const [ - , - yearText, - monthText, - dayText, - hourText, - minuteText, - secondText, - , - , - , - offsetHourText, - offsetMinuteText, - ] = match; - const year = Number(yearText); - const month = Number(monthText); - const day = Number(dayText); - const hour = Number(hourText); - const minute = Number(minuteText); - const second = Number(secondText); - const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText); - const offsetMinute = offsetMinuteText === undefined ? 0 : Number(offsetMinuteText); - const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); - const daysInMonth = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][ - month - 1 - ]; - if ( - year === 0 || - month < 1 || - month > 12 || - day < 1 || - daysInMonth === undefined || - day > daysInMonth || - hour > 23 || - minute > 59 || - second > 59 || - offsetHour > 23 || - offsetMinute > 59 - ) { - return INVALID; - } - - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) return INVALID; - const canonical = new Date(timestamp).toISOString(); - // Offsets at the ISO-8601 boundary can move a four-digit input into year 0000 - // or the expanded +010000 form. Neither can be decoded by this schema again, - // so reject them instead of emitting a non-idempotent representation. - return /^(?!0000-)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(canonical) - ? canonical - : INVALID; -} - -export function optionalDate(record: UnknownRecord, key: string): string | undefined | Invalid { - if (!hasOwn(record, key)) return undefined; - return normalizeIsoDate(record[key]); -} - -export function optionalNonNegativeNumber( - record: UnknownRecord, - key: string, - integer: boolean, -): number | undefined | Invalid { - if (!hasOwn(record, key)) return undefined; - const value = record[key]; - if ( - typeof value !== "number" || - !Number.isFinite(value) || - value < 0 || - value > Number.MAX_SAFE_INTEGER || - (integer && !Number.isInteger(value)) - ) { - return INVALID; - } - return value; -} - -export function compareCodeUnits(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0; -} - -export function portableVaultPathOwnershipKey(value: string): string { - return value.normalize("NFC").toLowerCase(); -} - -export function normalizeVaultPath(value: unknown, context: ValidationContext): string | Invalid { - const path = normalizeText(value, MAX_VAULT_PATH_BYTES, context); - if (path === INVALID) return INVALID; - if ( - path.startsWith("/") || - path.startsWith("~") || - path.includes("\\") || - path.includes("//") || - path.endsWith("/") || - /^[a-z][a-z0-9+.-]*:/i.test(path) - ) { - return INVALID; - } - - const segments = path.split("/"); - if ( - RESERVED_VAULT_ROOTS.has(portableVaultPathOwnershipKey(segments[0] ?? "")) || - segments.some( - (segment) => - segment.length === 0 || - segment === "." || - segment === ".." || - segment.trim() !== segment || - segment.endsWith(".") || - WINDOWS_RESERVED_CHARACTERS.test(segment) || - WINDOWS_RESERVED_SEGMENT.test(segment), - ) - ) { - return INVALID; - } - return path; -} diff --git a/src/persistence/v3/validateLibrary.ts b/src/persistence/v3/validateLibrary.ts deleted file mode 100644 index c12a617..0000000 --- a/src/persistence/v3/validateLibrary.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { isEpisodeHandle, type EpisodeHandle } from "src/security/resourceHandles"; -import { - normalizeCustomPlaylists, - normalizeDownloads, - normalizeEpisodes, - normalizeExtensions, - normalizeFeeds, - normalizeLocalAssets, - normalizePodNotes, - normalizeProgress, -} from "./collections"; -import { normalizePlaylist } from "./entities"; -import { - LIBRARY_V3_SCHEMA_VERSION, - MAX_TOTAL_EPISODE_REFERENCES, - type LibraryV3, - type ValidationContext, -} from "./model"; -import { libraryReferencesAreValid } from "./referentialIntegrity"; -import { hasOwn, isStrictRecord, serializedLibraryFits } from "./scalars"; - -const ROOT_KEYS = new Set([ - "schemaVersion", - "feeds", - "episodes", - "queue", - "favorites", - "localFiles", - "playlists", - "currentEpisodeId", - "progress", - "podNotes", - "downloads", - "localAssets", - "extensions", -]); - -function normalizeLibrary(value: unknown): LibraryV3 | null { - if (!isStrictRecord(value, ROOT_KEYS) || value.schemaVersion !== LIBRARY_V3_SCHEMA_VERSION) { - return null; - } - const context: ValidationContext = { textBytes: 0, episodeReferences: 0 }; - const feeds = normalizeFeeds(value.feeds, context); - const episodes = normalizeEpisodes(value.episodes, context); - const queue = normalizePlaylist(value.queue, context); - const favorites = normalizePlaylist(value.favorites, context); - const localFiles = normalizePlaylist(value.localFiles, context); - const playlists = normalizeCustomPlaylists(value.playlists, context); - const progress = normalizeProgress(value.progress); - const podNotes = normalizePodNotes(value.podNotes, context); - const downloads = normalizeDownloads(value.downloads, context); - const localAssets = normalizeLocalAssets(value.localAssets, context); - const extensions = normalizeExtensions(value.extensions); - if ( - !feeds || - !episodes || - !queue || - !favorites || - !localFiles || - !playlists || - !progress || - !podNotes || - !downloads || - !localAssets || - !extensions - ) { - return null; - } - - let currentEpisodeId: EpisodeHandle | undefined; - if (hasOwn(value, "currentEpisodeId")) { - if (!isEpisodeHandle(value.currentEpisodeId)) return null; - currentEpisodeId = value.currentEpisodeId; - context.episodeReferences += 1; - if (context.episodeReferences > MAX_TOTAL_EPISODE_REFERENCES) return null; - } - - const model: LibraryV3 = { - schemaVersion: LIBRARY_V3_SCHEMA_VERSION, - feeds, - episodes, - queue, - favorites, - localFiles, - playlists, - ...(currentEpisodeId ? { currentEpisodeId } : {}), - progress, - podNotes, - downloads, - localAssets, - extensions, - }; - if (!libraryReferencesAreValid(model)) return null; - return serializedLibraryFits(model) ? model : null; -} - -export function validateLibraryV3(value: unknown): LibraryV3 | null { - try { - return normalizeLibrary(value); - } catch { - return null; - } -} diff --git a/src/security/base64Url.test.ts b/src/security/base64Url.test.ts deleted file mode 100644 index cbadc2a..0000000 --- a/src/security/base64Url.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { decodeBase64Url, encodeBase64Url } from "./base64Url"; - -describe("base64url", () => { - it.each([ - [[], ""], - [[0], "AA"], - [[0, 1], "AAE"], - [[0, 1, 2], "AAEC"], - [[251, 255, 239], "-__v"], - ] as const)("round-trips canonical unpadded bytes %#", (bytes, encoded) => { - const value = Uint8Array.from(bytes); - expect(encodeBase64Url(value)).toBe(encoded); - expect(decodeBase64Url(encoded, value.length, value.length)).toEqual(value); - }); - - it.each(["A", "AA=", "AA==", "AA+", "AA/", "AA\n", " A", "A A", "Ã¥"])( - "rejects noncanonical text %j", - (value) => { - expect(decodeBase64Url(value, 8)).toBeNull(); - }, - ); - - it("checks encoded and decoded bounds before returning bytes", () => { - expect(decodeBase64Url("AAE", 1)).toBeNull(); - expect(decodeBase64Url("AAE", 2, 1)).toBeNull(); - expect(decodeBase64Url("AAE", 2, 2)).toEqual(Uint8Array.from([0, 1])); - expect(decodeBase64Url("A".repeat(10_000), 32)).toBeNull(); - }); - - it("handles values large enough to require chunked browser encoding", () => { - const value = new Uint8Array(150_000); - for (let index = 0; index < value.length; index += 1) value[index] = index % 251; - const encoded = encodeBase64Url(value); - expect(decodeBase64Url(encoded, value.length, value.length)).toEqual(value); - }); -}); diff --git a/src/security/base64Url.ts b/src/security/base64Url.ts deleted file mode 100644 index 0c8732e..0000000 --- a/src/security/base64Url.ts +++ /dev/null @@ -1,50 +0,0 @@ -const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/; - -function maximumEncodedCharacters(maximumBytes: number): number { - return Math.ceil((maximumBytes * 4) / 3); -} - -export function encodeBase64Url(value: Uint8Array): string { - let binary = ""; - const chunkSize = 0x8000; - for (let offset = 0; offset < value.length; offset += chunkSize) { - binary += String.fromCharCode(...value.subarray(offset, offset + chunkSize)); - } - return btoa(binary).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, ""); -} - -/** Decode only canonical, unpadded base64url within an allocation bound. */ -export function decodeBase64Url( - value: unknown, - maximumBytes: number, - exactBytes?: number, -): Uint8Array | null { - if ( - typeof value !== "string" || - !Number.isSafeInteger(maximumBytes) || - maximumBytes < 0 || - value.length > maximumEncodedCharacters(maximumBytes) || - value.length % 4 === 1 || - !BASE64URL_PATTERN.test(value) - ) { - return null; - } - - try { - const padding = "=".repeat((4 - (value.length % 4)) % 4); - const binary = atob(value.replace(/-/gu, "+").replace(/_/gu, "/") + padding); - if ( - binary.length > maximumBytes || - (exactBytes !== undefined && binary.length !== exactBytes) - ) { - return null; - } - const decoded = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - decoded[index] = binary.charCodeAt(index); - } - return encodeBase64Url(decoded) === value ? decoded : null; - } catch { - return null; - } -} diff --git a/src/security/capabilityVaultKey.test.ts b/src/security/capabilityVaultKey.test.ts deleted file mode 100644 index 64c81ef..0000000 --- a/src/security/capabilityVaultKey.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - CAPABILITY_VAULT_ALGORITHM, - CAPABILITY_VAULT_KEY_SCHEMA_VERSION, - CapabilityVaultCryptoError, - capabilityVaultKeyFingerprint, - createCapabilityVaultKey, - decodeCapabilityVaultRecoveryCode, - decodeLocalCapabilityVaultKey, - encodeCapabilityVaultRecoveryCode, - encodeLocalCapabilityVaultKey, - importCapabilityVaultKey, - isImportedCapabilityVaultKey, - type CapabilityVaultRandomFill, -} from "./capabilityVaultKey"; - -const EXPECTED_LOCAL_KEY = { - schemaVersion: CAPABILITY_VAULT_KEY_SCHEMA_VERSION, - kind: "capability-vault-key", - algorithm: CAPABILITY_VAULT_ALGORITHM, - vaultId: "pncv-000102030405060708090a0b0c0d0e0f", - keyId: "pnck-2ff6e1446606feeacee35215d553a56f", - keyMaterial: "EBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8", -} as const; -const EXPECTED_RECOVERY_CODE = - "PNCV1.000102030405060708090a0b0c0d0e0f.2ff6e1446606feeacee35215d553a56f.EBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8.ea0da65e52509b65"; - -function sequentialFill(): CapabilityVaultRandomFill { - let next = 0; - return (bytes) => { - for (let index = 0; index < bytes.length; index += 1) bytes[index] = next++; - }; -} - -describe("capability vault keys", () => { - it("creates the fixed HKDF vector as a non-extractable AES-256-GCM key", async () => { - const created = await createCapabilityVaultKey(sequentialFill()); - - expect(created.localKey).toEqual(EXPECTED_LOCAL_KEY); - expect(created.recoveryCode).toBe(EXPECTED_RECOVERY_CODE); - expect(created.recoveryCode).toHaveLength(132); - expect(created.importedKey.cryptoKey.algorithm).toEqual({ name: "AES-GCM", length: 256 }); - expect(created.importedKey.cryptoKey.extractable).toBe(false); - expect(created.importedKey.cryptoKey.usages).toEqual(["encrypt", "decrypt"]); - expect(created.importedKey.keyCheckCryptoKey.algorithm).toEqual({ - name: "AES-GCM", - length: 256, - }); - expect(created.importedKey.keyCheckCryptoKey.extractable).toBe(false); - expect(created.importedKey.keyCheckCryptoKey).not.toBe(created.importedKey.cryptoKey); - expect(isImportedCapabilityVaultKey(created.importedKey)).toBe(true); - expect(JSON.stringify(created.importedKey)).not.toContain(EXPECTED_LOCAL_KEY.keyMaterial); - expect(capabilityVaultKeyFingerprint(created.importedKey.keyId)).toBe( - "2FF6E1446606FEEACEE3", - ); - }); - - it("round-trips only canonical local SecretStorage JSON", async () => { - const created = await createCapabilityVaultKey(sequentialFill()); - const serialized = encodeLocalCapabilityVaultKey(created.localKey)!; - - expect(decodeLocalCapabilityVaultKey(serialized)).toEqual(EXPECTED_LOCAL_KEY); - expect( - await importCapabilityVaultKey(decodeLocalCapabilityVaultKey(serialized)), - ).not.toBeNull(); - expect(decodeLocalCapabilityVaultKey(` ${serialized}`)).toBeNull(); - expect(decodeLocalCapabilityVaultKey(`${serialized}\n`)).toBeNull(); - expect( - decodeLocalCapabilityVaultKey( - JSON.stringify({ - keyMaterial: EXPECTED_LOCAL_KEY.keyMaterial, - schemaVersion: EXPECTED_LOCAL_KEY.schemaVersion, - kind: EXPECTED_LOCAL_KEY.kind, - algorithm: EXPECTED_LOCAL_KEY.algorithm, - vaultId: EXPECTED_LOCAL_KEY.vaultId, - keyId: EXPECTED_LOCAL_KEY.keyId, - }), - ), - ).toBeNull(); - }); - - it("round-trips the sensitive recovery code and rejects transcription changes", async () => { - const decoded = await decodeCapabilityVaultRecoveryCode(EXPECTED_RECOVERY_CODE); - expect(decoded?.localKey).toEqual(EXPECTED_LOCAL_KEY); - expect(decoded && isImportedCapabilityVaultKey(decoded.importedKey)).toBe(true); - expect(await encodeCapabilityVaultRecoveryCode(decoded?.localKey)).toBe( - EXPECTED_RECOVERY_CODE, - ); - - const changedChecksum = `${EXPECTED_RECOVERY_CODE.slice(0, -1)}4`; - const changedKey = EXPECTED_RECOVERY_CODE.replace( - EXPECTED_LOCAL_KEY.keyMaterial, - `A${EXPECTED_LOCAL_KEY.keyMaterial.slice(1)}`, - ); - expect(await decodeCapabilityVaultRecoveryCode(changedChecksum)).toBeNull(); - expect(await decodeCapabilityVaultRecoveryCode(changedKey)).toBeNull(); - expect( - await decodeCapabilityVaultRecoveryCode(EXPECTED_RECOVERY_CODE.toLowerCase()), - ).toBeNull(); - expect(await decodeCapabilityVaultRecoveryCode(` ${EXPECTED_RECOVERY_CODE}`)).toBeNull(); - }); - - it("rejects a structurally valid local envelope whose key ID does not derive from the key", async () => { - const mismatched = { - ...EXPECTED_LOCAL_KEY, - keyId: "pnck-00000000000000000000000000000000", - }; - expect(encodeLocalCapabilityVaultKey(mismatched)).not.toBeNull(); - expect(await importCapabilityVaultKey(mismatched)).toBeNull(); - expect(await encodeCapabilityVaultRecoveryCode(mismatched)).toBeNull(); - }); - - it.each([ - { ...EXPECTED_LOCAL_KEY, extra: true }, - { ...EXPECTED_LOCAL_KEY, schemaVersion: 2 }, - { ...EXPECTED_LOCAL_KEY, algorithm: "AES-GCM" }, - { ...EXPECTED_LOCAL_KEY, vaultId: EXPECTED_LOCAL_KEY.vaultId.toUpperCase() }, - { ...EXPECTED_LOCAL_KEY, keyId: "pnck-00" }, - { ...EXPECTED_LOCAL_KEY, keyMaterial: `${EXPECTED_LOCAL_KEY.keyMaterial}=` }, - ])("rejects a hostile local key envelope %#", async (value) => { - expect(encodeLocalCapabilityVaultKey(value)).toBeNull(); - expect(await importCapabilityVaultKey(value)).toBeNull(); - }); - - it("does not invoke accessors while validating local key material", async () => { - let accessed = false; - const value = Object.defineProperty({ ...EXPECTED_LOCAL_KEY }, "keyMaterial", { - enumerable: true, - get() { - accessed = true; - return EXPECTED_LOCAL_KEY.keyMaterial; - }, - }); - expect(encodeLocalCapabilityVaultKey(value)).toBeNull(); - expect(await importCapabilityVaultKey(value)).toBeNull(); - expect(accessed).toBe(false); - }); - - it("sanitizes random-source failures", async () => { - await expect( - createCapabilityVaultKey(() => { - throw new Error("sensitive random source detail"); - }), - ).rejects.toEqual(new CapabilityVaultCryptoError()); - }); - - it("creates independent vault and key identities", async () => { - const first = await createCapabilityVaultKey(sequentialFill()); - let value = 91; - const second = await createCapabilityVaultKey((bytes) => { - for (let index = 0; index < bytes.length; index += 1) bytes[index] = value++; - }); - expect(second.localKey.vaultId).not.toBe(first.localKey.vaultId); - expect(second.localKey.keyId).not.toBe(first.localKey.keyId); - expect(second.recoveryCode).not.toBe(first.recoveryCode); - }); -}); diff --git a/src/security/capabilityVaultKey.ts b/src/security/capabilityVaultKey.ts deleted file mode 100644 index 125538a..0000000 --- a/src/security/capabilityVaultKey.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { decodeBase64Url, encodeBase64Url } from "./base64Url"; -import { snapshotStrictDataRecord, utf8ByteLength } from "./strictData"; - -export const CAPABILITY_VAULT_KEY_SCHEMA_VERSION = 1; -export const CAPABILITY_VAULT_ALGORITHM = "A256GCM"; -export const CAPABILITY_VAULT_RECOVERY_FORMAT = "PNCV1"; -export const MAX_LOCAL_CAPABILITY_VAULT_KEY_BYTES = 512; - -const VAULT_ID_PATTERN = /^pncv-[0-9a-f]{32}$/; -const KEY_ID_PATTERN = /^pnck-[0-9a-f]{32}$/; -const RECOVERY_CODE_PATTERN = - /^PNCV1\.([0-9a-f]{32})\.([0-9a-f]{32})\.([A-Za-z0-9_-]{43})\.([0-9a-f]{16})$/; -const RECOVERY_CHECKSUM_DOMAIN = "podnotes-capability-vault-recovery-v1"; -const KEY_ID_INFO = "podnotes/capability-vault/v1/key-id"; -const ENCRYPTION_KEY_INFO = "podnotes/capability-vault/v1/feed-capabilities"; -const KEY_CHECK_KEY_INFO = "podnotes/capability-vault/v1/key-check"; -const VAULT_ID_BYTES = 16; -const ROOT_KEY_BYTES = 32; -const RECOVERY_CODE_LENGTH = 132; -const textEncoder = new TextEncoder(); -const authenticImportedKeys = new WeakSet(); - -declare const capabilityVaultIdBrand: unique symbol; -declare const capabilityVaultKeyIdBrand: unique symbol; - -export type CapabilityVaultId = string & { readonly [capabilityVaultIdBrand]: true }; -export type CapabilityVaultKeyId = string & { readonly [capabilityVaultKeyIdBrand]: true }; -export type CapabilityVaultRandomFill = (bytes: Uint8Array) => void; - -export interface LocalCapabilityVaultKeyV1 { - readonly schemaVersion: typeof CAPABILITY_VAULT_KEY_SCHEMA_VERSION; - readonly kind: "capability-vault-key"; - readonly algorithm: typeof CAPABILITY_VAULT_ALGORITHM; - readonly vaultId: CapabilityVaultId; - readonly keyId: CapabilityVaultKeyId; - readonly keyMaterial: string; -} - -export interface ImportedCapabilityVaultKey { - readonly vaultId: CapabilityVaultId; - readonly keyId: CapabilityVaultKeyId; - readonly cryptoKey: CryptoKey; - readonly keyCheckCryptoKey: CryptoKey; -} - -export interface CreatedCapabilityVaultKey { - readonly localKey: LocalCapabilityVaultKeyV1; - readonly importedKey: ImportedCapabilityVaultKey; - /** Sensitive copy/paste recovery material. Never persist or log this string. */ - readonly recoveryCode: string; -} - -export class CapabilityVaultCryptoError extends Error { - constructor() { - super("Capability vault cryptography is unavailable."); - this.name = "CapabilityVaultCryptoError"; - } -} - -function fillWithWebCrypto(bytes: Uint8Array): void { - // oxlint-disable-next-line obsidianmd/no-global-this -- Web Crypto is the shared desktop/mobile runtime primitive. - const crypto = globalThis.crypto; - if (!crypto || typeof crypto.getRandomValues !== "function") - throw new CapabilityVaultCryptoError(); - crypto.getRandomValues(bytes as Uint8Array); -} - -function getSubtle(): SubtleCrypto { - // oxlint-disable-next-line obsidianmd/no-global-this -- Web Crypto is the shared desktop/mobile runtime primitive. - const subtle = globalThis.crypto?.subtle; - if (!subtle) throw new CapabilityVaultCryptoError(); - return subtle; -} - -function encodeHex(bytes: Uint8Array): string { - let result = ""; - for (const byte of bytes) result += byte.toString(16).padStart(2, "0"); - return result; -} - -function decodeHex(value: string, expectedBytes: number): Uint8Array | null { - if (value.length !== expectedBytes * 2 || !/^[0-9a-f]+$/.test(value)) return null; - const bytes = new Uint8Array(expectedBytes); - for (let index = 0; index < expectedBytes; index += 1) { - bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); - } - return bytes; -} - -function vaultIdBytes(vaultId: CapabilityVaultId): Uint8Array | null { - return decodeHex(vaultId.slice("pncv-".length), VAULT_ID_BYTES); -} - -function normalizeLocalCapabilityVaultKey(value: unknown): LocalCapabilityVaultKeyV1 | null { - const record = snapshotStrictDataRecord(value, [ - "schemaVersion", - "kind", - "algorithm", - "vaultId", - "keyId", - "keyMaterial", - ]); - if ( - !record || - record.schemaVersion !== CAPABILITY_VAULT_KEY_SCHEMA_VERSION || - record.kind !== "capability-vault-key" || - record.algorithm !== CAPABILITY_VAULT_ALGORITHM || - typeof record.vaultId !== "string" || - !VAULT_ID_PATTERN.test(record.vaultId) || - typeof record.keyId !== "string" || - !KEY_ID_PATTERN.test(record.keyId) || - typeof record.keyMaterial !== "string" - ) { - return null; - } - const decodedKeyMaterial = decodeBase64Url(record.keyMaterial, ROOT_KEY_BYTES, ROOT_KEY_BYTES); - if (!decodedKeyMaterial) return null; - decodedKeyMaterial.fill(0); - - return { - schemaVersion: CAPABILITY_VAULT_KEY_SCHEMA_VERSION, - kind: "capability-vault-key", - algorithm: CAPABILITY_VAULT_ALGORITHM, - vaultId: record.vaultId as CapabilityVaultId, - keyId: record.keyId as CapabilityVaultKeyId, - keyMaterial: record.keyMaterial, - }; -} - -async function deriveImportedKey( - localKey: LocalCapabilityVaultKeyV1, -): Promise { - const rootKey = decodeBase64Url(localKey.keyMaterial, ROOT_KEY_BYTES, ROOT_KEY_BYTES); - const salt = vaultIdBytes(localKey.vaultId); - if (!rootKey || !salt) return null; - - try { - const subtle = getSubtle(); - const hkdfKey = await subtle.importKey("raw", rootKey, "HKDF", false, [ - "deriveBits", - "deriveKey", - ]); - const keyIdBits = await subtle.deriveBits( - { - name: "HKDF", - hash: "SHA-256", - salt, - info: textEncoder.encode(KEY_ID_INFO), - }, - hkdfKey, - 128, - ); - const derivedKeyId = `pnck-${encodeHex(new Uint8Array(keyIdBits))}`; - if (derivedKeyId !== localKey.keyId) return null; - - const cryptoKey = await subtle.deriveKey( - { - name: "HKDF", - hash: "SHA-256", - salt, - info: textEncoder.encode(ENCRYPTION_KEY_INFO), - }, - hkdfKey, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"], - ); - const keyCheckCryptoKey = await subtle.deriveKey( - { - name: "HKDF", - hash: "SHA-256", - salt, - info: textEncoder.encode(KEY_CHECK_KEY_INFO), - }, - hkdfKey, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"], - ); - const imported = Object.freeze({ - vaultId: localKey.vaultId, - keyId: localKey.keyId, - cryptoKey, - keyCheckCryptoKey, - }); - authenticImportedKeys.add(imported); - return imported; - } catch (error) { - if (error instanceof CapabilityVaultCryptoError) throw error; - throw new CapabilityVaultCryptoError(); - } finally { - rootKey.fill(0); - } -} - -async function recoveryChecksum( - vaultPayload: string, - keyPayload: string, - keyMaterial: string, -): Promise { - const message = `${RECOVERY_CHECKSUM_DOMAIN}\0${vaultPayload}\0${keyPayload}\0${keyMaterial}`; - try { - const digest = await getSubtle().digest("SHA-256", textEncoder.encode(message)); - return encodeHex(new Uint8Array(digest).subarray(0, 8)); - } catch (error) { - if (error instanceof CapabilityVaultCryptoError) throw error; - throw new CapabilityVaultCryptoError(); - } -} - -function constantTimeTextEqual(left: string, right: string): boolean { - if (left.length !== right.length) return false; - let difference = 0; - for (let index = 0; index < left.length; index += 1) { - difference |= left.charCodeAt(index) ^ right.charCodeAt(index); - } - return difference === 0; -} - -export function isCapabilityVaultId(value: unknown): value is CapabilityVaultId { - return typeof value === "string" && VAULT_ID_PATTERN.test(value); -} - -export function isCapabilityVaultKeyId(value: unknown): value is CapabilityVaultKeyId { - return typeof value === "string" && KEY_ID_PATTERN.test(value); -} - -export function capabilityVaultKeyFingerprint(keyId: CapabilityVaultKeyId): string { - return keyId.slice("pnck-".length, "pnck-".length + 20).toUpperCase(); -} - -export function isImportedCapabilityVaultKey(value: unknown): value is ImportedCapabilityVaultKey { - return typeof value === "object" && value !== null && authenticImportedKeys.has(value); -} - -export function encodeLocalCapabilityVaultKey(value: unknown): string | null { - const normalized = normalizeLocalCapabilityVaultKey(value); - if (!normalized) return null; - const serialized = JSON.stringify(normalized); - return utf8ByteLength(serialized) <= MAX_LOCAL_CAPABILITY_VAULT_KEY_BYTES ? serialized : null; -} - -export function decodeLocalCapabilityVaultKey( - serialized: unknown, -): LocalCapabilityVaultKeyV1 | null { - if ( - typeof serialized !== "string" || - serialized.length === 0 || - serialized.length > MAX_LOCAL_CAPABILITY_VAULT_KEY_BYTES || - utf8ByteLength(serialized) > MAX_LOCAL_CAPABILITY_VAULT_KEY_BYTES - ) { - return null; - } - try { - const normalized = normalizeLocalCapabilityVaultKey(JSON.parse(serialized)); - return normalized && JSON.stringify(normalized) === serialized ? normalized : null; - } catch { - return null; - } -} - -export async function importCapabilityVaultKey( - value: unknown, -): Promise { - const localKey = normalizeLocalCapabilityVaultKey(value); - return localKey ? deriveImportedKey(localKey) : null; -} - -export async function encodeCapabilityVaultRecoveryCode(value: unknown): Promise { - const localKey = normalizeLocalCapabilityVaultKey(value); - if (!localKey || !(await deriveImportedKey(localKey))) return null; - const vaultPayload = localKey.vaultId.slice("pncv-".length); - const keyPayload = localKey.keyId.slice("pnck-".length); - const checksum = await recoveryChecksum(vaultPayload, keyPayload, localKey.keyMaterial); - return `${CAPABILITY_VAULT_RECOVERY_FORMAT}.${vaultPayload}.${keyPayload}.${localKey.keyMaterial}.${checksum}`; -} - -export async function decodeCapabilityVaultRecoveryCode(value: unknown): Promise<{ - localKey: LocalCapabilityVaultKeyV1; - importedKey: ImportedCapabilityVaultKey; -} | null> { - if (typeof value !== "string" || value.length !== RECOVERY_CODE_LENGTH) return null; - const match = RECOVERY_CODE_PATTERN.exec(value); - if (!match) return null; - const [, vaultPayload, keyPayload, keyMaterial, suppliedChecksum] = match; - const expectedChecksum = await recoveryChecksum(vaultPayload, keyPayload, keyMaterial); - if (!constantTimeTextEqual(suppliedChecksum, expectedChecksum)) return null; - - const localKey = normalizeLocalCapabilityVaultKey({ - schemaVersion: CAPABILITY_VAULT_KEY_SCHEMA_VERSION, - kind: "capability-vault-key", - algorithm: CAPABILITY_VAULT_ALGORITHM, - vaultId: `pncv-${vaultPayload}`, - keyId: `pnck-${keyPayload}`, - keyMaterial, - }); - if (!localKey) return null; - const importedKey = await deriveImportedKey(localKey); - return importedKey ? { localKey, importedKey } : null; -} - -export async function createCapabilityVaultKey( - fillRandom: CapabilityVaultRandomFill = fillWithWebCrypto, -): Promise { - const rootBytes = new Uint8Array(ROOT_KEY_BYTES); - try { - const vaultBytes = new Uint8Array(VAULT_ID_BYTES); - fillRandom(vaultBytes); - fillRandom(rootBytes); - const vaultId = `pncv-${encodeHex(vaultBytes)}` as CapabilityVaultId; - const provisional: LocalCapabilityVaultKeyV1 = { - schemaVersion: CAPABILITY_VAULT_KEY_SCHEMA_VERSION, - kind: "capability-vault-key", - algorithm: CAPABILITY_VAULT_ALGORITHM, - vaultId, - keyId: "pnck-00000000000000000000000000000000" as CapabilityVaultKeyId, - keyMaterial: encodeBase64Url(rootBytes), - }; - - const subtle = getSubtle(); - const hkdfKey = await subtle.importKey("raw", rootBytes, "HKDF", false, ["deriveBits"]); - const keyIdBits = await subtle.deriveBits( - { - name: "HKDF", - hash: "SHA-256", - salt: vaultBytes, - info: textEncoder.encode(KEY_ID_INFO), - }, - hkdfKey, - 128, - ); - const localKey: LocalCapabilityVaultKeyV1 = Object.freeze({ - ...provisional, - keyId: `pnck-${encodeHex(new Uint8Array(keyIdBits))}` as CapabilityVaultKeyId, - }); - const importedKey = await deriveImportedKey(localKey); - if (!importedKey) throw new CapabilityVaultCryptoError(); - const recoveryCode = await encodeCapabilityVaultRecoveryCode(localKey); - if (!recoveryCode) throw new CapabilityVaultCryptoError(); - return Object.freeze({ localKey, importedKey, recoveryCode }); - } catch (error) { - if (error instanceof CapabilityVaultCryptoError) throw error; - throw new CapabilityVaultCryptoError(); - } finally { - rootBytes.fill(0); - } -} diff --git a/src/security/capabilityVaultMetadata.test.ts b/src/security/capabilityVaultMetadata.test.ts deleted file mode 100644 index 568273d..0000000 --- a/src/security/capabilityVaultMetadata.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createCapabilityVaultKey } from "./capabilityVaultKey"; -import { - CAPABILITY_VAULT_METADATA_SCHEMA_VERSION, - decodeCapabilityVaultMetadata, - encodeCapabilityVaultMetadata, - validateCapabilityVaultMetadata, -} from "./capabilityVaultMetadata"; -import { createCapabilityVaultKeyCheck } from "./sealedCapabilityRecord"; - -async function fixture(start = 0) { - let next = start; - const key = await createCapabilityVaultKey((bytes) => { - for (let index = 0; index < bytes.length; index += 1) bytes[index] = next++; - }); - const keyCheck = await createCapabilityVaultKeyCheck(key.importedKey, (bytes) => { - for (let index = 0; index < bytes.length; index += 1) bytes[index] = next++; - }); - return { - schemaVersion: CAPABILITY_VAULT_METADATA_SCHEMA_VERSION, - kind: "capability-vault-metadata" as const, - algorithm: "A256GCM" as const, - recoveryFormat: "PNCV1" as const, - vaultId: key.importedKey.vaultId, - keyId: key.importedKey.keyId, - keyCheck, - }; -} - -describe("capability vault metadata", () => { - it("round-trips a target-free canonical key-check manifest", async () => { - const value = await fixture(); - const serialized = encodeCapabilityVaultMetadata(value)!; - - expect(decodeCapabilityVaultMetadata(serialized)).toEqual(value); - expect(serialized).not.toMatch(/https?:|token|guid|hostname/i); - }); - - it("rejects mismatched key-check authority", async () => { - const value = await fixture(); - const other = await fixture(90); - expect(validateCapabilityVaultMetadata({ ...value, keyCheck: other.keyCheck })).toBeNull(); - }); - - it.each([ - { extra: true }, - { schemaVersion: 2 }, - { kind: "synced-capability-vault" }, - { algorithm: "AES-GCM" }, - { recoveryFormat: "PNCV2" }, - ])("rejects an invalid metadata mutation %#", async (mutation) => { - const value = await fixture(); - expect(validateCapabilityVaultMetadata({ ...value, ...mutation })).toBeNull(); - }); - - it("rejects noncanonical or oversized serialized input", async () => { - const value = await fixture(); - const serialized = encodeCapabilityVaultMetadata(value)!; - expect(decodeCapabilityVaultMetadata(` ${serialized}`)).toBeNull(); - expect(decodeCapabilityVaultMetadata(`${serialized}\n`)).toBeNull(); - expect(decodeCapabilityVaultMetadata(JSON.stringify({ ...value, extra: true }))).toBeNull(); - expect(decodeCapabilityVaultMetadata("A".repeat(2049))).toBeNull(); - }); -}); diff --git a/src/security/capabilityVaultMetadata.ts b/src/security/capabilityVaultMetadata.ts deleted file mode 100644 index ffaeef3..0000000 --- a/src/security/capabilityVaultMetadata.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - CAPABILITY_VAULT_ALGORITHM, - CAPABILITY_VAULT_RECOVERY_FORMAT, - isCapabilityVaultId, - isCapabilityVaultKeyId, - type CapabilityVaultId, - type CapabilityVaultKeyId, -} from "./capabilityVaultKey"; -import { - validateCapabilityVaultKeyCheck, - type CapabilityVaultKeyCheckV1, -} from "./sealedCapabilityRecord"; -import { snapshotStrictDataRecord, utf8ByteLength } from "./strictData"; - -export const CAPABILITY_VAULT_METADATA_SCHEMA_VERSION = 1; -export const MAX_CAPABILITY_VAULT_METADATA_BYTES = 2 * 1024; - -/** Target-free key identity only. Immutable feed records live outside this metadata. */ -export interface CapabilityVaultMetadataV1 { - readonly schemaVersion: typeof CAPABILITY_VAULT_METADATA_SCHEMA_VERSION; - readonly kind: "capability-vault-metadata"; - readonly algorithm: typeof CAPABILITY_VAULT_ALGORITHM; - readonly recoveryFormat: typeof CAPABILITY_VAULT_RECOVERY_FORMAT; - readonly vaultId: CapabilityVaultId; - readonly keyId: CapabilityVaultKeyId; - readonly keyCheck: CapabilityVaultKeyCheckV1; -} - -export function validateCapabilityVaultMetadata(value: unknown): CapabilityVaultMetadataV1 | null { - const record = snapshotStrictDataRecord(value, [ - "schemaVersion", - "kind", - "algorithm", - "recoveryFormat", - "vaultId", - "keyId", - "keyCheck", - ]); - if ( - !record || - record.schemaVersion !== CAPABILITY_VAULT_METADATA_SCHEMA_VERSION || - record.kind !== "capability-vault-metadata" || - record.algorithm !== CAPABILITY_VAULT_ALGORITHM || - record.recoveryFormat !== CAPABILITY_VAULT_RECOVERY_FORMAT || - !isCapabilityVaultId(record.vaultId) || - !isCapabilityVaultKeyId(record.keyId) - ) { - return null; - } - const keyCheck = validateCapabilityVaultKeyCheck(record.keyCheck); - if (!keyCheck || keyCheck.vaultId !== record.vaultId || keyCheck.keyId !== record.keyId) { - return null; - } - const normalized: CapabilityVaultMetadataV1 = { - schemaVersion: CAPABILITY_VAULT_METADATA_SCHEMA_VERSION, - kind: "capability-vault-metadata", - algorithm: CAPABILITY_VAULT_ALGORITHM, - recoveryFormat: CAPABILITY_VAULT_RECOVERY_FORMAT, - vaultId: record.vaultId, - keyId: record.keyId, - keyCheck, - }; - return utf8ByteLength(JSON.stringify(normalized)) <= MAX_CAPABILITY_VAULT_METADATA_BYTES - ? normalized - : null; -} - -export function encodeCapabilityVaultMetadata(value: unknown): string | null { - const normalized = validateCapabilityVaultMetadata(value); - return normalized ? JSON.stringify(normalized) : null; -} - -export function decodeCapabilityVaultMetadata( - serialized: unknown, -): CapabilityVaultMetadataV1 | null { - if ( - typeof serialized !== "string" || - serialized.length === 0 || - serialized.length > MAX_CAPABILITY_VAULT_METADATA_BYTES || - utf8ByteLength(serialized) > MAX_CAPABILITY_VAULT_METADATA_BYTES - ) { - return null; - } - try { - const normalized = validateCapabilityVaultMetadata(JSON.parse(serialized)); - return normalized && JSON.stringify(normalized) === serialized ? normalized : null; - } catch { - return null; - } -} diff --git a/src/security/feedCapabilityReferences.test.ts b/src/security/feedCapabilityReferences.test.ts deleted file mode 100644 index 95125a9..0000000 --- a/src/security/feedCapabilityReferences.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { FeedHandle } from "./resourceHandles"; -import { - feedCapabilityReferenceForAttempt, - getFeedCapabilityReferenceBase, - getFeedCapabilityManifestStorageId, - getFeedCapabilityPageStorageId, - isFeedCapabilityReferenceFor, -} from "./feedCapabilityReferences"; - -const feedId = `podnotes-feed-${"11".repeat(32)}` as FeedHandle; -const otherFeedId = `podnotes-feed-${"22".repeat(32)}` as FeedHandle; -const base = `pnfc-${BigInt(`0x${"11".repeat(32)}`) - .toString(36) - .padStart(50, "0")}`; - -describe("feed capability references", () => { - it("derives the feed-bound base without using a target", () => { - expect(getFeedCapabilityReferenceBase(feedId)).toBe(base); - expect( - getFeedCapabilityReferenceBase(`podnotes-feed-${"0".repeat(64)}` as FeedHandle), - ).toBe(`pnfc-${"0".repeat(50)}`); - expect( - getFeedCapabilityReferenceBase(`podnotes-feed-${"0".repeat(63)}1` as FeedHandle), - ).toBe(`pnfc-${"0".repeat(49)}1`); - expect(getFeedCapabilityReferenceBase("https://secret.example/feed.xml")).toBeUndefined(); - }); - - it("allocates the bounded base and collision suffix grammar", () => { - expect(feedCapabilityReferenceForAttempt(feedId, 1)).toBe(base); - expect(feedCapabilityReferenceForAttempt(feedId, 2)).toBe(`${base}-2`); - expect(feedCapabilityReferenceForAttempt(feedId, 999)).toBe(`${base}-999`); - expect(feedCapabilityReferenceForAttempt(feedId, 999)).toHaveLength(59); - expect(feedCapabilityReferenceForAttempt(feedId, 0)).toBeNull(); - expect(feedCapabilityReferenceForAttempt(feedId, 1000)).toBeNull(); - }); - - it.each([ - `${base}-1`, - `${base}-02`, - `${base}-1000`, - `${base}-2-extra`, - `${base.toUpperCase()}`, - ])("rejects a malformed reference: %s", (candidate) => { - expect(isFeedCapabilityReferenceFor(feedId, candidate)).toBe(false); - }); - - it("rejects a valid reference when it is bound to another feed", () => { - expect(isFeedCapabilityReferenceFor(feedId, base)).toBe(true); - expect(isFeedCapabilityReferenceFor(feedId, `${base}-2`)).toBe(true); - expect(isFeedCapabilityReferenceFor(otherFeedId, base)).toBe(false); - }); - - it("builds only grammar-valid physical IDs within the 64-character limit", () => { - const maximumReference = feedCapabilityReferenceForAttempt(feedId, 999)!; - - expect(getFeedCapabilityManifestStorageId(maximumReference, "b")).toHaveLength(62); - expect(getFeedCapabilityPageStorageId(maximumReference, "b", "zzz")).toHaveLength(64); - expect(getFeedCapabilityPageStorageId(maximumReference, "b", "zzzz")).toBeNull(); - expect(getFeedCapabilityManifestStorageId(`${maximumReference}9`, "a")).toBeNull(); - expect(getFeedCapabilityPageStorageId(`${maximumReference}9`, "a", "000")).toBeNull(); - }); -}); diff --git a/src/security/feedCapabilityReferences.ts b/src/security/feedCapabilityReferences.ts deleted file mode 100644 index 8f5365d..0000000 --- a/src/security/feedCapabilityReferences.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { getFeedHandleHex } from "./resourceHandles"; - -const FEED_CAPABILITY_REFERENCE_PREFIX = "pnfc-"; -const FEED_CAPABILITY_TOKEN_LENGTH = 50; -const FEED_CAPABILITY_REFERENCE_PATTERN = /^pnfc-[0-9a-z]{50}(?:-(?:[2-9]|[1-9]\d|[1-9]\d{2}))?$/; -const PHYSICAL_PAGE_INDEX_PATTERN = /^[0-9a-z]{3}$/; -export const MAX_FEED_CAPABILITY_REFERENCE_ATTEMPTS = 999; -export const MAX_SECRET_STORAGE_ID_CHARACTERS = 64; - -declare const feedCapabilityReferenceBrand: unique symbol; - -export type FeedCapabilityReference = string & { - readonly [feedCapabilityReferenceBrand]: true; -}; - -export function getFeedCapabilityReferenceBase(feedId: unknown): string | undefined { - const feedHex = getFeedHandleHex(feedId); - if (!feedHex) return undefined; - const token = BigInt(`0x${feedHex}`).toString(36).padStart(FEED_CAPABILITY_TOKEN_LENGTH, "0"); - return token.length === FEED_CAPABILITY_TOKEN_LENGTH - ? `${FEED_CAPABILITY_REFERENCE_PREFIX}${token}` - : undefined; -} - -export function feedCapabilityReferenceForAttempt( - feedId: unknown, - attempt: number, -): FeedCapabilityReference | null { - const base = getFeedCapabilityReferenceBase(feedId); - if ( - !base || - !Number.isSafeInteger(attempt) || - attempt < 1 || - attempt > MAX_FEED_CAPABILITY_REFERENCE_ATTEMPTS - ) { - return null; - } - return (attempt === 1 ? base : `${base}-${attempt}`) as FeedCapabilityReference; -} - -export function isFeedCapabilityReferenceFor( - feedId: unknown, - value: unknown, -): value is FeedCapabilityReference { - const base = getFeedCapabilityReferenceBase(feedId); - if (!base || typeof value !== "string") return false; - if (value === base) return true; - if (!value.startsWith(`${base}-`)) return false; - const suffix = value.slice(base.length + 1); - if (!/^[1-9]\d*$/.test(suffix)) return false; - const number = Number(suffix); - return ( - Number.isSafeInteger(number) && - number >= 2 && - number <= MAX_FEED_CAPABILITY_REFERENCE_ATTEMPTS - ); -} - -export function getFeedCapabilityManifestStorageId( - reference: FeedCapabilityReference | string, - slot: "a" | "b", -): string | null { - if (!FEED_CAPABILITY_REFERENCE_PATTERN.test(reference)) return null; - const id = `${reference}-${slot}m`; - return id.length <= MAX_SECRET_STORAGE_ID_CHARACTERS ? id : null; -} - -export function getFeedCapabilityPageStorageId( - reference: FeedCapabilityReference | string, - slot: "a" | "b", - index: string, -): string | null { - if ( - !FEED_CAPABILITY_REFERENCE_PATTERN.test(reference) || - !PHYSICAL_PAGE_INDEX_PATTERN.test(index) - ) { - return null; - } - const id = `${reference}-${slot}${index}`; - return id.length <= MAX_SECRET_STORAGE_ID_CHARACTERS ? id : null; -} diff --git a/src/security/feedCapabilityStorage.ts b/src/security/feedCapabilityStorage.ts deleted file mode 100644 index 587ebbb..0000000 --- a/src/security/feedCapabilityStorage.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { - MAX_EPISODE_RESOURCES_PER_FEED, - TARGET_ENVELOPE_SCHEMA_VERSION, - validateEpisodeResourcesEnvelope, - validateFeedCapabilityEnvelope, - type EpisodeResourcesEnvelope, - type FeedCapabilityEnvelope, - type PrivateTargetGrants, -} from "./targetEnvelopes"; -import { - getEpisodeHandleHex, - isEpisodeHandle, - isFeedHandle, - type EpisodeHandle, - type FeedHandle, -} from "./resourceHandles"; - -export const MAX_SECRET_STORAGE_ITEM_BYTES = 128 * 1024; -export const FEED_CAPABILITY_STORAGE_SCHEMA_VERSION = 1; -export const MAX_PHYSICAL_PAGE_INDEX = 36 ** 3 - 1; - -export type PhysicalSlot = "a" | "b"; - -export interface FeedCapabilityNamespaceMarker { - schemaVersion: typeof FEED_CAPABILITY_STORAGE_SCHEMA_VERSION; - kind: "feed-capability-namespace"; - feedId: FeedHandle; -} - -export interface FeedCapabilityPageDescriptor { - index: string; - bucket: string; - slot: PhysicalSlot; - digest: string; - byteLength: number; - episodeCount: number; -} - -export interface FeedCapabilityManifest { - schemaVersion: typeof FEED_CAPABILITY_STORAGE_SCHEMA_VERSION; - kind: "feed-capability-manifest"; - feedId: FeedHandle; - generation: number; - contentDigest: string; - subscriptionUrl: string; - artworkUrl?: string; - siteUrl?: string; - guid?: string; - privateGrants?: PrivateTargetGrants; - pages: FeedCapabilityPageDescriptor[]; -} - -export interface FeedCapabilityPage { - schemaVersion: typeof FEED_CAPABILITY_STORAGE_SCHEMA_VERSION; - kind: "feed-capability-page"; - feedId: FeedHandle; - generation: number; - index: string; - bucket: string; - episodeResources: Readonly>>; -} - -export interface PreparedFeedCapabilityPage { - page: FeedCapabilityPage; - serialized: string; - digest: string; - byteLength: number; - episodeCount: number; -} - -const textEncoder = new TextEncoder(); -const DIGEST_PATTERN = /^[0-9a-f]{64}$/; -const BUCKET_PATTERN = /^[0-9a-f]{1,64}$/; -const PAGE_INDEX_PATTERN = /^[0-9a-z]{3}$/; -const MARKER_KEYS = new Set(["schemaVersion", "kind", "feedId"]); -const MANIFEST_KEYS = new Set([ - "schemaVersion", - "kind", - "feedId", - "generation", - "contentDigest", - "subscriptionUrl", - "artworkUrl", - "siteUrl", - "guid", - "privateGrants", - "pages", -]); -const PAGE_DESCRIPTOR_KEYS = new Set([ - "index", - "bucket", - "slot", - "digest", - "byteLength", - "episodeCount", -]); -const PAGE_KEYS = new Set([ - "schemaVersion", - "kind", - "feedId", - "generation", - "index", - "bucket", - "episodeResources", -]); - -type UnknownRecord = Record; - -function isPlainDataRecord(value: unknown): value is UnknownRecord { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) return false; - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== "string") return false; - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor?.enumerable || !("value" in descriptor)) return false; - } - return true; -} - -function isStrictRecord(value: unknown, allowedKeys: ReadonlySet): value is UnknownRecord { - return isPlainDataRecord(value) && Object.keys(value).every((key) => allowedKeys.has(key)); -} - -function isGeneration(value: unknown): value is number { - return Number.isSafeInteger(value) && typeof value === "number" && value >= 1; -} - -function isBoundedCount(value: unknown, maximum: number): value is number { - return ( - Number.isSafeInteger(value) && typeof value === "number" && value >= 0 && value <= maximum - ); -} - -function isSortedUnique(values: readonly string[]): boolean { - for (let index = 1; index < values.length; index += 1) { - if (values[index - 1] >= values[index]) return false; - } - return true; -} - -function compareStrings(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0; -} - -function metadataEnvelope(value: UnknownRecord): FeedCapabilityEnvelope | null { - return validateFeedCapabilityEnvelope({ - schemaVersion: TARGET_ENVELOPE_SCHEMA_VERSION, - kind: "feed-capability-bundle", - feedId: value.feedId, - subscriptionUrl: value.subscriptionUrl, - ...(Object.prototype.hasOwnProperty.call(value, "artworkUrl") - ? { artworkUrl: value.artworkUrl } - : {}), - ...(Object.prototype.hasOwnProperty.call(value, "siteUrl") - ? { siteUrl: value.siteUrl } - : {}), - ...(Object.prototype.hasOwnProperty.call(value, "guid") ? { guid: value.guid } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "privateGrants") - ? { privateGrants: value.privateGrants } - : {}), - episodeResources: {}, - }); -} - -function normalizedMetadata(value: FeedCapabilityEnvelope) { - return { - subscriptionUrl: value.subscriptionUrl, - ...(value.artworkUrl ? { artworkUrl: value.artworkUrl } : {}), - ...(value.siteUrl ? { siteUrl: value.siteUrl } : {}), - ...(value.guid ? { guid: value.guid } : {}), - ...(value.privateGrants ? { privateGrants: value.privateGrants } : {}), - }; -} - -export function serializedByteLength(value: string): number { - return textEncoder.encode(value).byteLength; -} - -export function encodeFeedCapabilityPageIndex(index: number): string | null { - if (!Number.isSafeInteger(index) || index < 0 || index > MAX_PHYSICAL_PAGE_INDEX) return null; - return index.toString(36).padStart(3, "0"); -} - -export function serializePhysicalItem(value: unknown): string | null { - try { - const serialized = JSON.stringify(value); - return typeof serialized === "string" && - serializedByteLength(serialized) <= MAX_SECRET_STORAGE_ITEM_BYTES - ? serialized - : null; - } catch { - return null; - } -} - -export async function sha256Hex(value: string): Promise { - // oxlint-disable-next-line obsidianmd/no-global-this -- Web Crypto is runtime-global in browser and Node verification environments. - const crypto = globalThis.crypto; - if (!crypto?.subtle) throw new Error("Web Crypto is unavailable"); - const digest = await crypto.subtle.digest( - "SHA-256", - textEncoder.encode(value) as Uint8Array, - ); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -export function validateNamespaceMarker( - value: unknown, - expectedFeedId?: unknown, -): FeedCapabilityNamespaceMarker | null { - try { - if (!isStrictRecord(value, MARKER_KEYS)) return null; - if ( - value.schemaVersion !== FEED_CAPABILITY_STORAGE_SCHEMA_VERSION || - value.kind !== "feed-capability-namespace" || - !isFeedHandle(value.feedId) || - (expectedFeedId !== undefined && value.feedId !== expectedFeedId) - ) { - return null; - } - return { - schemaVersion: FEED_CAPABILITY_STORAGE_SCHEMA_VERSION, - kind: "feed-capability-namespace", - feedId: value.feedId, - }; - } catch { - return null; - } -} - -function validatePageDescriptor(value: unknown): FeedCapabilityPageDescriptor | null { - if (!isStrictRecord(value, PAGE_DESCRIPTOR_KEYS)) return null; - if ( - typeof value.index !== "string" || - !PAGE_INDEX_PATTERN.test(value.index) || - typeof value.bucket !== "string" || - !BUCKET_PATTERN.test(value.bucket) || - (value.slot !== "a" && value.slot !== "b") || - typeof value.digest !== "string" || - !DIGEST_PATTERN.test(value.digest) || - !isBoundedCount(value.byteLength, MAX_SECRET_STORAGE_ITEM_BYTES) || - value.byteLength === 0 || - !isBoundedCount(value.episodeCount, MAX_EPISODE_RESOURCES_PER_FEED) || - value.episodeCount === 0 - ) { - return null; - } - return { - index: value.index, - bucket: value.bucket, - slot: value.slot, - digest: value.digest, - byteLength: value.byteLength, - episodeCount: value.episodeCount, - }; -} - -export function validateManifest( - value: unknown, - expectedFeedId?: unknown, -): FeedCapabilityManifest | null { - try { - if (!isStrictRecord(value, MANIFEST_KEYS)) return null; - if ( - value.schemaVersion !== FEED_CAPABILITY_STORAGE_SCHEMA_VERSION || - value.kind !== "feed-capability-manifest" || - !isFeedHandle(value.feedId) || - (expectedFeedId !== undefined && value.feedId !== expectedFeedId) || - !isGeneration(value.generation) || - typeof value.contentDigest !== "string" || - !DIGEST_PATTERN.test(value.contentDigest) || - !Array.isArray(value.pages) || - value.pages.length > MAX_EPISODE_RESOURCES_PER_FEED - ) { - return null; - } - - const metadata = metadataEnvelope(value); - if (!metadata) return null; - const pages: FeedCapabilityPageDescriptor[] = []; - let totalEpisodeCount = 0; - for (let index = 0; index < value.pages.length; index += 1) { - const candidate = value.pages[index]; - const descriptor = validatePageDescriptor(candidate); - if (!descriptor || descriptor.index !== encodeFeedCapabilityPageIndex(index)) - return null; - totalEpisodeCount += descriptor.episodeCount; - if (totalEpisodeCount > MAX_EPISODE_RESOURCES_PER_FEED) return null; - pages.push(descriptor); - } - const buckets = pages.map((page) => page.bucket); - if (!isSortedUnique(buckets)) return null; - for (let index = 1; index < buckets.length; index += 1) { - if (buckets[index].startsWith(buckets[index - 1])) return null; - } - - const normalized: FeedCapabilityManifest = { - schemaVersion: FEED_CAPABILITY_STORAGE_SCHEMA_VERSION, - kind: "feed-capability-manifest", - feedId: value.feedId, - generation: value.generation, - contentDigest: value.contentDigest, - ...normalizedMetadata(metadata), - pages, - }; - return serializePhysicalItem(normalized) ? normalized : null; - } catch { - return null; - } -} - -export function validatePage( - value: unknown, - expectedFeedId?: unknown, - expectedGeneration?: unknown, - expectedIndex?: unknown, - expectedBucket?: unknown, -): FeedCapabilityPage | null { - try { - if (!isStrictRecord(value, PAGE_KEYS)) return null; - if ( - value.schemaVersion !== FEED_CAPABILITY_STORAGE_SCHEMA_VERSION || - value.kind !== "feed-capability-page" || - !isFeedHandle(value.feedId) || - (expectedFeedId !== undefined && value.feedId !== expectedFeedId) || - !isGeneration(value.generation) || - (expectedGeneration !== undefined && value.generation !== expectedGeneration) || - typeof value.index !== "string" || - !PAGE_INDEX_PATTERN.test(value.index) || - (expectedIndex !== undefined && value.index !== expectedIndex) || - typeof value.bucket !== "string" || - !BUCKET_PATTERN.test(value.bucket) || - (expectedBucket !== undefined && value.bucket !== expectedBucket) || - !isPlainDataRecord(value.episodeResources) - ) { - return null; - } - - const episodeIds = Object.keys(value.episodeResources); - if ( - episodeIds.length === 0 || - episodeIds.length > MAX_EPISODE_RESOURCES_PER_FEED || - !isSortedUnique(episodeIds) - ) { - return null; - } - const episodeResources: Partial> = {}; - for (const episodeId of episodeIds) { - const hex = getEpisodeHandleHex(episodeId); - if (!hex || !hex.startsWith(value.bucket) || !isEpisodeHandle(episodeId)) return null; - const entry = validateEpisodeResourcesEnvelope( - value.episodeResources[episodeId], - value.feedId, - episodeId, - ); - if (!entry) return null; - episodeResources[episodeId] = entry; - } - - const normalized: FeedCapabilityPage = { - schemaVersion: FEED_CAPABILITY_STORAGE_SCHEMA_VERSION, - kind: "feed-capability-page", - feedId: value.feedId, - generation: value.generation, - index: value.index, - bucket: value.bucket, - episodeResources, - }; - return serializePhysicalItem(normalized) ? normalized : null; - } catch { - return null; - } -} - -function makePage( - envelope: FeedCapabilityEnvelope, - generation: number, - index: string, - bucket: string, - episodeIds: readonly EpisodeHandle[], -): FeedCapabilityPage { - const episodeResources: Partial> = {}; - for (const episodeId of episodeIds) { - const entry = envelope.episodeResources[episodeId]; - if (entry) episodeResources[episodeId] = entry; - } - return { - schemaVersion: FEED_CAPABILITY_STORAGE_SCHEMA_VERSION, - kind: "feed-capability-page", - feedId: envelope.feedId, - generation, - index, - bucket, - episodeResources, - }; -} - -interface PagePartition { - bucket: string; - episodeIds: readonly EpisodeHandle[]; -} - -function partitionBucket( - envelope: FeedCapabilityEnvelope, - generation: number, - bucket: string, - episodeIds: readonly EpisodeHandle[], -): PagePartition[] { - const page = makePage(envelope, generation, "000", bucket, episodeIds); - const serialized = serializePhysicalItem(page); - if (serialized) return [{ bucket, episodeIds }]; - if (bucket.length >= 64) throw new Error("Episode resource cannot fit a physical page"); - - const groups = new Map(); - for (const episodeId of episodeIds) { - const hex = getEpisodeHandleHex(episodeId); - if (!hex) throw new Error("Invalid episode handle"); - const childBucket = hex.slice(0, bucket.length + 1); - const group = groups.get(childBucket) ?? []; - group.push(episodeId); - groups.set(childBucket, group); - } - - return [...groups.entries()] - .sort(([left], [right]) => compareStrings(left, right)) - .flatMap(([childBucket, childIds]) => - partitionBucket(envelope, generation, childBucket, childIds), - ); -} - -export async function preparePages( - envelope: FeedCapabilityEnvelope, - generation: number, -): Promise { - const groups = new Map(); - for (const rawEpisodeId of Object.keys(envelope.episodeResources).sort()) { - if (!isEpisodeHandle(rawEpisodeId)) throw new Error("Invalid episode handle"); - const hex = getEpisodeHandleHex(rawEpisodeId); - if (!hex) throw new Error("Invalid episode handle"); - const bucket = hex.slice(0, 1); - const group = groups.get(bucket) ?? []; - group.push(rawEpisodeId); - groups.set(bucket, group); - } - - const partitions = [...groups.entries()] - .sort(([left], [right]) => compareStrings(left, right)) - .flatMap(([bucket, episodeIds]) => - partitionBucket(envelope, generation, bucket, episodeIds), - ); - return Promise.all( - partitions.map(async ({ bucket, episodeIds }, ordinal) => { - const index = encodeFeedCapabilityPageIndex(ordinal); - if (!index) throw new Error("Physical page index is exhausted"); - const page = makePage(envelope, generation, index, bucket, episodeIds); - const serialized = serializePhysicalItem(page); - if (!serialized) throw new Error("Episode resources cannot fit a physical page"); - return { - page, - serialized, - digest: await sha256Hex(serialized), - byteLength: serializedByteLength(serialized), - episodeCount: Object.keys(page.episodeResources).length, - }; - }), - ); -} - -export function makeManifest( - envelope: FeedCapabilityEnvelope, - generation: number, - contentDigest: string, - pages: FeedCapabilityPageDescriptor[], -): FeedCapabilityManifest { - return { - schemaVersion: FEED_CAPABILITY_STORAGE_SCHEMA_VERSION, - kind: "feed-capability-manifest", - feedId: envelope.feedId, - generation, - contentDigest, - ...normalizedMetadata(envelope), - pages: [...pages].sort((left, right) => compareStrings(left.bucket, right.bucket)), - }; -} - -export function reconstructEnvelope( - manifest: FeedCapabilityManifest, - pages: readonly FeedCapabilityPage[], -): FeedCapabilityEnvelope | null { - const episodeResources: Partial> = {}; - for (const page of pages) { - for (const rawEpisodeId of Object.keys(page.episodeResources)) { - if (!isEpisodeHandle(rawEpisodeId) || episodeResources[rawEpisodeId]) return null; - const entry = page.episodeResources[rawEpisodeId]; - if (!entry) return null; - episodeResources[rawEpisodeId] = entry; - } - } - return validateFeedCapabilityEnvelope({ - schemaVersion: TARGET_ENVELOPE_SCHEMA_VERSION, - kind: "feed-capability-bundle", - feedId: manifest.feedId, - subscriptionUrl: manifest.subscriptionUrl, - ...(manifest.artworkUrl ? { artworkUrl: manifest.artworkUrl } : {}), - ...(manifest.siteUrl ? { siteUrl: manifest.siteUrl } : {}), - ...(manifest.guid ? { guid: manifest.guid } : {}), - ...(manifest.privateGrants ? { privateGrants: manifest.privateGrants } : {}), - episodeResources, - }); -} diff --git a/src/security/resourceHandles.test.ts b/src/security/resourceHandles.test.ts deleted file mode 100644 index c536a77..0000000 --- a/src/security/resourceHandles.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - HANDLE_ALLOCATION_ATTEMPTS, - HandleAllocationError, - allocateEpisodeHandle, - allocateFeedHandle, - isEpisodeHandle, - isFeedHandle, -} from "./resourceHandles"; - -describe("opaque remote resource handles", () => { - it("allocates kind-specific handles from 256 bits of injected entropy", () => { - let feedBytes = 0; - let episodeBytes = 0; - const feed = allocateFeedHandle({ - fillRandom: (bytes) => { - feedBytes = bytes.byteLength; - bytes.fill(0x2a); - }, - }); - const episode = allocateEpisodeHandle({ - fillRandom: (bytes) => { - episodeBytes = bytes.byteLength; - bytes.fill(0x7f); - }, - }); - - expect(feed).toBe(`podnotes-feed-${"2a".repeat(32)}`); - expect(episode).toBe(`podnotes-episode-${"7f".repeat(32)}`); - expect(feedBytes).toBe(32); - expect(episodeBytes).toBe(32); - expect(isFeedHandle(feed)).toBe(true); - expect(isEpisodeHandle(episode)).toBe(true); - expect(isEpisodeHandle(feed)).toBe(false); - expect(isFeedHandle(episode)).toBe(false); - }); - - it.each([ - "", - "podnotes-feed-", - `podnotes-feed-${"a".repeat(63)}`, - `podnotes-feed-${"a".repeat(65)}`, - `podnotes-feed-${"A".repeat(64)}`, - `podnotes-feed-${"g".repeat(64)}`, - `podnotes-feed-${"a".repeat(64)}-2`, - ])("rejects an invalid feed handle: %s", (candidate) => { - expect(isFeedHandle(candidate)).toBe(false); - }); - - it("retries a collision and returns the next available handle", () => { - const unavailable = new Set([`podnotes-feed-${"11".repeat(32)}`]); - let attempts = 0; - const handle = allocateFeedHandle({ - unavailable, - fillRandom: (bytes) => { - bytes.fill(attempts++ === 0 ? 0x11 : 0x22); - }, - }); - - expect(handle).toBe(`podnotes-feed-${"22".repeat(32)}`); - expect(attempts).toBe(2); - }); - - it("fails closed after the bounded collision schedule", () => { - const candidate = `podnotes-episode-${"33".repeat(32)}`; - let attempts = 0; - - expect(() => - allocateEpisodeHandle({ - unavailable: new Set([candidate]), - fillRandom: (bytes) => { - attempts += 1; - bytes.fill(0x33); - }, - }), - ).toThrow(HandleAllocationError); - expect(attempts).toBe(HANDLE_ALLOCATION_ATTEMPTS); - }); - - it("does not expose an entropy provider failure as a partially allocated handle", () => { - expect(() => - allocateFeedHandle({ - fillRandom: () => { - throw new Error("entropy source details"); - }, - }), - ).toThrow(HandleAllocationError); - }); -}); diff --git a/src/security/resourceHandles.ts b/src/security/resourceHandles.ts deleted file mode 100644 index 97c4ee9..0000000 --- a/src/security/resourceHandles.ts +++ /dev/null @@ -1,92 +0,0 @@ -const HANDLE_ENTROPY_BYTES = 32; -const HANDLE_HEX_LENGTH = HANDLE_ENTROPY_BYTES * 2; -const FEED_HANDLE_PREFIX = "podnotes-feed-"; -const EPISODE_HANDLE_PREFIX = "podnotes-episode-"; -const FEED_HANDLE_PATTERN = new RegExp(`^${FEED_HANDLE_PREFIX}[0-9a-f]{${HANDLE_HEX_LENGTH}}$`); -const EPISODE_HANDLE_PATTERN = new RegExp( - `^${EPISODE_HANDLE_PREFIX}[0-9a-f]{${HANDLE_HEX_LENGTH}}$`, -); - -export const HANDLE_ALLOCATION_ATTEMPTS = 16; - -declare const feedHandleBrand: unique symbol; -declare const episodeHandleBrand: unique symbol; - -export type FeedHandle = string & { readonly [feedHandleBrand]: true }; -export type EpisodeHandle = string & { readonly [episodeHandleBrand]: true }; -export type FillRandomValues = (bytes: Uint8Array) => void; - -export interface HandleAllocationOptions { - /** Test seam. Production always uses Web Crypto. */ - fillRandom?: FillRandomValues; - /** Handles already reserved in the destination identity map. */ - unavailable?: ReadonlySet; -} - -export class HandleAllocationError extends Error { - constructor(kind: "feed" | "episode") { - super(`PodNotes could not allocate a secure ${kind} handle.`); - this.name = "HandleAllocationError"; - } -} - -function fillWithWebCrypto(bytes: Uint8Array): void { - // oxlint-disable-next-line obsidianmd/no-global-this -- Handles also allocate in non-window migration and test runtimes. - const crypto = globalThis.crypto; - if (!crypto || typeof crypto.getRandomValues !== "function") { - throw new Error("Web Crypto is unavailable"); - } - crypto.getRandomValues(bytes as Uint8Array); -} - -function encodeLowercaseHex(bytes: Uint8Array): string { - let encoded = ""; - for (const byte of bytes) encoded += byte.toString(16).padStart(2, "0"); - return encoded; -} - -function allocateHandle( - kind: "feed" | "episode", - prefix: typeof FEED_HANDLE_PREFIX | typeof EPISODE_HANDLE_PREFIX, - options: HandleAllocationOptions, -): string { - const fillRandom = options.fillRandom ?? fillWithWebCrypto; - - for (let attempt = 0; attempt < HANDLE_ALLOCATION_ATTEMPTS; attempt += 1) { - const bytes = new Uint8Array(HANDLE_ENTROPY_BYTES); - try { - fillRandom(bytes); - } catch { - throw new HandleAllocationError(kind); - } - - const candidate = `${prefix}${encodeLowercaseHex(bytes)}`; - if (!options.unavailable?.has(candidate)) return candidate; - } - - throw new HandleAllocationError(kind); -} - -export function allocateFeedHandle(options: HandleAllocationOptions = {}): FeedHandle { - return allocateHandle("feed", FEED_HANDLE_PREFIX, options) as FeedHandle; -} - -export function allocateEpisodeHandle(options: HandleAllocationOptions = {}): EpisodeHandle { - return allocateHandle("episode", EPISODE_HANDLE_PREFIX, options) as EpisodeHandle; -} - -export function isFeedHandle(value: unknown): value is FeedHandle { - return typeof value === "string" && FEED_HANDLE_PATTERN.test(value); -} - -export function isEpisodeHandle(value: unknown): value is EpisodeHandle { - return typeof value === "string" && EPISODE_HANDLE_PATTERN.test(value); -} - -export function getFeedHandleHex(value: unknown): string | undefined { - return isFeedHandle(value) ? value.slice(FEED_HANDLE_PREFIX.length) : undefined; -} - -export function getEpisodeHandleHex(value: unknown): string | undefined { - return isEpisodeHandle(value) ? value.slice(EPISODE_HANDLE_PREFIX.length) : undefined; -} diff --git a/src/security/sealedCapabilityRecord.test.ts b/src/security/sealedCapabilityRecord.test.ts deleted file mode 100644 index fade4d9..0000000 --- a/src/security/sealedCapabilityRecord.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createCapabilityVaultKey, type CapabilityVaultRandomFill } from "./capabilityVaultKey"; -import { feedCapabilityReferenceForAttempt } from "./feedCapabilityReferences"; -import type { EpisodeHandle, FeedHandle } from "./resourceHandles"; -import { - FeedCapabilitySealingError, - MAX_SEALED_CAPABILITY_RECORD_BYTES, - createCapabilityVaultKeyCheck, - decodeCapabilityVaultKeyCheck, - decodeSealedFeedCapabilityRecord, - encodeCapabilityVaultKeyCheck, - encodeSealedFeedCapabilityRecord, - openSealedFeedCapabilityEnvelope, - sealFeedCapabilityEnvelope, - verifyCapabilityVaultKey, -} from "./sealedCapabilityRecord"; -import type { FeedCapabilityEnvelope } from "./targetEnvelopes"; - -const feedId = `podnotes-feed-${"11".repeat(32)}` as FeedHandle; -const otherFeedId = `podnotes-feed-${"22".repeat(32)}` as FeedHandle; -const episodeId = `podnotes-episode-${"33".repeat(32)}` as EpisodeHandle; -const capabilityRef = feedCapabilityReferenceForAttempt(feedId, 1)!; -const otherCapabilityRef = feedCapabilityReferenceForAttempt(otherFeedId, 1)!; -const bundle: FeedCapabilityEnvelope = { - schemaVersion: 1, - kind: "feed-capability-bundle", - feedId, - subscriptionUrl: "https://secret.example/feed%2fmain.xml?token=signed-value", - artworkUrl: "https://secret.example/private-artwork.jpg?token=artwork-value", - guid: "private-feed-guid", - privateGrants: { - subscription: ["https://secret.example"], - }, - episodeResources: { - [episodeId]: { - schemaVersion: 1, - kind: "episode-resources", - feedId, - episodeId, - streamUrl: "https://media.example/private.mp3?signature=episode-value", - guid: "private-episode-guid", - }, - }, -}; - -function sequentialFill(start = 0): CapabilityVaultRandomFill { - let next = start; - return (bytes) => { - for (let index = 0; index < bytes.length; index += 1) bytes[index] = next++ % 256; - }; -} - -async function fixture() { - const key = await createCapabilityVaultKey(sequentialFill()); - const record = await sealFeedCapabilityEnvelope(bundle, { - key: key.importedKey, - feedId, - capabilityRef, - fillRandom: sequentialFill(64), - }); - return { key, record }; -} - -describe("sealed feed capability records", () => { - it("seals and opens a fixed immutable record without clear targets", async () => { - const { key, record } = await fixture(); - const serialized = encodeSealedFeedCapabilityRecord(record)!; - - expect(record.recordId).toBe("pncr-404142434445464748494a4b4c4d4e4f"); - expect(record.nonce).toBe("UFFSU1RVVldYWVpb"); - expect(decodeSealedFeedCapabilityRecord(serialized)).toEqual(record); - expect(await openSealedFeedCapabilityEnvelope(record, key.importedKey)).toEqual({ - status: "available", - value: bundle, - }); - for (const targetFragment of [ - "secret.example", - "media.example", - "signed-value", - "episode-value", - "private-feed-guid", - ]) { - expect(serialized).not.toContain(targetFragment); - } - }); - - it("randomizes record identity, nonce, and ciphertext for the same plaintext", async () => { - const key = await createCapabilityVaultKey(sequentialFill()); - const first = await sealFeedCapabilityEnvelope(bundle, { - key: key.importedKey, - feedId, - capabilityRef, - fillRandom: sequentialFill(64), - }); - const second = await sealFeedCapabilityEnvelope(bundle, { - key: key.importedKey, - feedId, - capabilityRef, - fillRandom: sequentialFill(96), - }); - - expect(second.recordId).not.toBe(first.recordId); - expect(second.nonce).not.toBe(first.nonce); - expect(second.ciphertext).not.toBe(first.ciphertext); - }); - - it("seals a stable descriptor snapshot without rereading hostile proxy getters", async () => { - const key = await createCapabilityVaultKey(sequentialFill()); - let guidReads = 0; - const changing = new Proxy(bundle, { - get(target, property, receiver) { - if (property === "guid") { - guidReads += 1; - return "x".repeat(9_000); - } - return Reflect.get(target, property, receiver); - }, - }); - - const record = await sealFeedCapabilityEnvelope(changing, { - key: key.importedKey, - feedId, - capabilityRef, - fillRandom: sequentialFill(64), - }); - expect(guidReads).toBe(0); - expect(await openSealedFeedCapabilityEnvelope(record, key.importedKey)).toEqual({ - status: "available", - value: bundle, - }); - }); - - it("authenticates every mutable clear binding", async () => { - const { key, record } = await fixture(); - const otherRecordId = `pncr-${"aa".repeat(16)}`; - const parentRecordId = `pncr-${"bb".repeat(16)}`; - const suffixReference = feedCapabilityReferenceForAttempt(feedId, 2)!; - const changedNonce = record.nonce.startsWith("A") - ? `B${record.nonce.slice(1)}` - : `A${record.nonce.slice(1)}`; - const variants = [ - { ...record, recordId: otherRecordId }, - { ...record, capabilityRef: suffixReference }, - { ...record, feedId: otherFeedId, capabilityRef: otherCapabilityRef }, - { ...record, generation: 2, parentRecordId }, - { ...record, nonce: changedNonce }, - ] as const; - - for (const variant of variants) { - expect(await openSealedFeedCapabilityEnvelope(variant, key.importedKey)).toEqual({ - status: "invalid", - }); - } - }); - - it("rejects wrong keys, ciphertext swaps, bit changes, truncation, and extension", async () => { - const { key, record } = await fixture(); - const wrongKey = await createCapabilityVaultKey(sequentialFill(150)); - const other = await sealFeedCapabilityEnvelope(bundle, { - key: key.importedKey, - feedId, - capabilityRef, - fillRandom: sequentialFill(180), - }); - const changed = `${record.ciphertext.startsWith("A") ? "B" : "A"}${record.ciphertext.slice(1)}`; - const variants = [ - { ...record, ciphertext: other.ciphertext }, - { ...record, ciphertext: changed }, - { ...record, ciphertext: record.ciphertext.slice(0, -1) }, - { ...record, ciphertext: `${record.ciphertext}A` }, - ]; - - expect(await openSealedFeedCapabilityEnvelope(record, wrongKey.importedKey)).toEqual({ - status: "invalid", - }); - for (const variant of variants) { - expect(await openSealedFeedCapabilityEnvelope(variant, key.importedKey)).toEqual({ - status: "invalid", - }); - } - }); - - it("requires a parent exactly when the immutable generation advances", async () => { - const { key, record } = await fixture(); - const second = await sealFeedCapabilityEnvelope(bundle, { - key: key.importedKey, - feedId, - capabilityRef, - parentRecord: record, - fillRandom: sequentialFill(96), - }); - expect(second.parentRecordId).toBe(record.recordId); - expect(second.generation).toBe(2); - expect(await openSealedFeedCapabilityEnvelope(second, key.importedKey, record)).toEqual({ - status: "available", - value: bundle, - }); - expect(await openSealedFeedCapabilityEnvelope(second, key.importedKey)).toEqual({ - status: "invalid", - }); - - const otherFeedParent = await sealFeedCapabilityEnvelope( - { - schemaVersion: 1, - kind: "feed-capability-bundle", - feedId: otherFeedId, - subscriptionUrl: "https://other.example/feed.xml", - episodeResources: {}, - }, - { - key: key.importedKey, - feedId: otherFeedId, - capabilityRef: otherCapabilityRef, - fillRandom: sequentialFill(120), - }, - ); - const otherKey = await createCapabilityVaultKey(sequentialFill(150)); - const otherKeyParent = await sealFeedCapabilityEnvelope(bundle, { - key: otherKey.importedKey, - feedId, - capabilityRef, - fillRandom: sequentialFill(190), - }); - for (const parentRecord of [otherFeedParent, otherKeyParent]) { - await expect( - sealFeedCapabilityEnvelope(bundle, { - key: key.importedKey, - feedId, - capabilityRef, - parentRecord, - fillRandom: sequentialFill(220), - }), - ).rejects.toEqual(new FeedCapabilitySealingError()); - } - }); - - it("uses an authenticated key check even when the vault has no feed records", async () => { - const key = await createCapabilityVaultKey(sequentialFill()); - const check = await createCapabilityVaultKeyCheck(key.importedKey, sequentialFill(200)); - const serialized = encodeCapabilityVaultKeyCheck(check)!; - - expect(check.ciphertext).toHaveLength(72); - expect(decodeCapabilityVaultKeyCheck(serialized)).toEqual(check); - expect(await verifyCapabilityVaultKey(check, key.importedKey)).toEqual({ - status: "available", - }); - - const wrongKey = await createCapabilityVaultKey(sequentialFill(30)); - expect(await verifyCapabilityVaultKey(check, wrongKey.importedKey)).toEqual({ - status: "invalid", - }); - const changedCiphertext = `${check.ciphertext.startsWith("A") ? "B" : "A"}${check.ciphertext.slice(1)}`; - expect( - await verifyCapabilityVaultKey( - { ...check, ciphertext: changedCiphertext }, - key.importedKey, - ), - ).toEqual({ status: "invalid" }); - }); - - it("accepts only strict canonical record and key-check JSON", async () => { - const { key, record } = await fixture(); - const check = await createCapabilityVaultKeyCheck(key.importedKey, sequentialFill(200)); - const recordJson = encodeSealedFeedCapabilityRecord(record)!; - const checkJson = encodeCapabilityVaultKeyCheck(check)!; - - expect(decodeSealedFeedCapabilityRecord(` ${recordJson}`)).toBeNull(); - expect(decodeSealedFeedCapabilityRecord(`${recordJson}\n`)).toBeNull(); - expect( - decodeSealedFeedCapabilityRecord(JSON.stringify({ ...record, extra: true })), - ).toBeNull(); - expect(decodeCapabilityVaultKeyCheck(` ${checkJson}`)).toBeNull(); - expect(decodeCapabilityVaultKeyCheck(JSON.stringify({ ...check, extra: true }))).toBeNull(); - expect( - decodeSealedFeedCapabilityRecord("A".repeat(MAX_SEALED_CAPABILITY_RECORD_BYTES + 1)), - ).toBeNull(); - }); - - it("does not invoke ciphertext accessors and sanitizes invalid envelope errors", async () => { - const { key, record } = await fixture(); - let accessed = false; - const hostile = Object.defineProperty({ ...record }, "ciphertext", { - enumerable: true, - get() { - accessed = true; - return record.ciphertext; - }, - }); - expect(encodeSealedFeedCapabilityRecord(hostile)).toBeNull(); - expect(accessed).toBe(false); - - await expect( - sealFeedCapabilityEnvelope( - { ...bundle, subscriptionUrl: "https://sensitive.invalid/\n" }, - { - key: key.importedKey, - feedId, - capabilityRef, - fillRandom: sequentialFill(), - }, - ), - ).rejects.toEqual(new FeedCapabilitySealingError()); - }); -}); diff --git a/src/security/sealedCapabilityRecord.ts b/src/security/sealedCapabilityRecord.ts deleted file mode 100644 index e617525..0000000 --- a/src/security/sealedCapabilityRecord.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { - CAPABILITY_VAULT_ALGORITHM, - CapabilityVaultCryptoError, - isCapabilityVaultId, - isCapabilityVaultKeyId, - isImportedCapabilityVaultKey, - type CapabilityVaultId, - type CapabilityVaultKeyId, - type CapabilityVaultRandomFill, - type ImportedCapabilityVaultKey, -} from "./capabilityVaultKey"; -import { decodeBase64Url, encodeBase64Url } from "./base64Url"; -import { - isFeedCapabilityReferenceFor, - type FeedCapabilityReference, -} from "./feedCapabilityReferences"; -import { isFeedHandle, type FeedHandle } from "./resourceHandles"; -import { snapshotStrictDataRecord, utf8ByteLength } from "./strictData"; -import { - MAX_FEED_CAPABILITY_ENVELOPE_BYTES, - TARGET_ENVELOPE_SCHEMA_VERSION, - validateFeedCapabilityEnvelope, - type FeedCapabilityEnvelope, -} from "./targetEnvelopes"; - -/** - * Pure, storage-agnostic recovery primitives. Persist each sealed record as an - * immutable revision and retain divergent heads. A mutable last-writer-wins map - * is not a safe storage implementation for this format. - */ - -export const SEALED_CAPABILITY_RECORD_SCHEMA_VERSION = 1; -export const CAPABILITY_VAULT_KEY_CHECK_SCHEMA_VERSION = 1; -export const MAX_SEALED_CAPABILITY_RECORD_BYTES = 2 * 1024 * 1024; -export const MAX_SEALED_CAPABILITY_CIPHERTEXT_BYTES = MAX_FEED_CAPABILITY_ENVELOPE_BYTES + 16; -export const MAX_CAPABILITY_RECORD_GENERATION = Number.MAX_SAFE_INTEGER; - -const RECORD_ID_PATTERN = /^pncr-[0-9a-f]{32}$/; -const NONCE_BYTES = 12; -const RECORD_ID_BYTES = 16; -const GCM_TAG_BYTES = 16; -const KEY_CHECK_PLAINTEXT = "podnotes-capability-vault-key-check-v1"; -const KEY_CHECK_PLAINTEXT_BYTES = 38; -const KEY_CHECK_CIPHERTEXT_BYTES = KEY_CHECK_PLAINTEXT_BYTES + GCM_TAG_BYTES; -const RECORD_AAD_DOMAIN = "podnotes/feed-capability"; -const KEY_CHECK_AAD_DOMAIN = "podnotes/capability-vault/key-check"; -const textEncoder = new TextEncoder(); -const fatalTextDecoder = new TextDecoder("utf-8", { fatal: true }); - -declare const sealedCapabilityRecordIdBrand: unique symbol; -export type SealedCapabilityRecordId = string & { - readonly [sealedCapabilityRecordIdBrand]: true; -}; - -export interface SealedFeedCapabilityRecordV1 { - readonly schemaVersion: typeof SEALED_CAPABILITY_RECORD_SCHEMA_VERSION; - readonly kind: "sealed-feed-capability"; - readonly algorithm: typeof CAPABILITY_VAULT_ALGORITHM; - readonly vaultId: CapabilityVaultId; - readonly keyId: CapabilityVaultKeyId; - readonly recordId: SealedCapabilityRecordId; - readonly feedId: FeedHandle; - readonly capabilityRef: FeedCapabilityReference; - readonly envelopeSchemaVersion: typeof TARGET_ENVELOPE_SCHEMA_VERSION; - readonly generation: number; - readonly parentRecordId?: SealedCapabilityRecordId; - readonly nonce: string; - readonly ciphertext: string; -} - -export interface CapabilityVaultKeyCheckV1 { - readonly schemaVersion: typeof CAPABILITY_VAULT_KEY_CHECK_SCHEMA_VERSION; - readonly kind: "capability-vault-key-check"; - readonly algorithm: typeof CAPABILITY_VAULT_ALGORITHM; - readonly vaultId: CapabilityVaultId; - readonly keyId: CapabilityVaultKeyId; - readonly nonce: string; - readonly ciphertext: string; -} - -export interface SealFeedCapabilityOptions { - readonly key: ImportedCapabilityVaultKey; - readonly feedId: FeedHandle | string; - readonly capabilityRef: unknown; - readonly parentRecord?: unknown; - readonly fillRandom?: CapabilityVaultRandomFill; -} - -export type OpenSealedFeedCapabilityResult = - | { readonly status: "available"; readonly value: FeedCapabilityEnvelope } - | { readonly status: "invalid" | "unavailable" }; - -export type VerifyCapabilityVaultKeyResult = - | { readonly status: "available" } - | { readonly status: "invalid" | "unavailable" }; - -export class FeedCapabilitySealingError extends Error { - constructor() { - super("Feed capabilities could not be sealed."); - this.name = "FeedCapabilitySealingError"; - } -} - -function fillWithWebCrypto(bytes: Uint8Array): void { - // oxlint-disable-next-line obsidianmd/no-global-this -- Web Crypto is the shared desktop/mobile runtime primitive. - const crypto = globalThis.crypto; - if (!crypto || typeof crypto.getRandomValues !== "function") - throw new CapabilityVaultCryptoError(); - crypto.getRandomValues(bytes as Uint8Array); -} - -function getSubtle(): SubtleCrypto { - // oxlint-disable-next-line obsidianmd/no-global-this -- Web Crypto is the shared desktop/mobile runtime primitive. - const subtle = globalThis.crypto?.subtle; - if (!subtle) throw new CapabilityVaultCryptoError(); - return subtle; -} - -function encodeHex(bytes: Uint8Array): string { - let result = ""; - for (const byte of bytes) result += byte.toString(16).padStart(2, "0"); - return result; -} - -function allocateRecordId(fillRandom: CapabilityVaultRandomFill): SealedCapabilityRecordId { - const bytes = new Uint8Array(RECORD_ID_BYTES); - fillRandom(bytes); - return `pncr-${encodeHex(bytes)}` as SealedCapabilityRecordId; -} - -function isGeneration(value: unknown): value is number { - return ( - typeof value === "number" && - Number.isSafeInteger(value) && - value >= 1 && - value <= MAX_CAPABILITY_RECORD_GENERATION - ); -} - -export function isSealedCapabilityRecordId(value: unknown): value is SealedCapabilityRecordId { - return typeof value === "string" && RECORD_ID_PATTERN.test(value); -} - -function normalizeSealedRecord(value: unknown): SealedFeedCapabilityRecordV1 | null { - const baseKeys = [ - "schemaVersion", - "kind", - "algorithm", - "vaultId", - "keyId", - "recordId", - "feedId", - "capabilityRef", - "envelopeSchemaVersion", - "generation", - "nonce", - "ciphertext", - ] as const; - const withParent = snapshotStrictDataRecord(value, [...baseKeys, "parentRecordId"]); - const record = withParent ?? snapshotStrictDataRecord(value, baseKeys); - if ( - !record || - record.schemaVersion !== SEALED_CAPABILITY_RECORD_SCHEMA_VERSION || - record.kind !== "sealed-feed-capability" || - record.algorithm !== CAPABILITY_VAULT_ALGORITHM || - !isCapabilityVaultId(record.vaultId) || - !isCapabilityVaultKeyId(record.keyId) || - !isSealedCapabilityRecordId(record.recordId) || - !isFeedHandle(record.feedId) || - !isFeedCapabilityReferenceFor(record.feedId, record.capabilityRef) || - record.envelopeSchemaVersion !== TARGET_ENVELOPE_SCHEMA_VERSION || - !isGeneration(record.generation) || - typeof record.nonce !== "string" || - !decodeBase64Url(record.nonce, NONCE_BYTES, NONCE_BYTES) || - typeof record.ciphertext !== "string" - ) { - return null; - } - - const ciphertext = decodeBase64Url(record.ciphertext, MAX_SEALED_CAPABILITY_CIPHERTEXT_BYTES); - if (!ciphertext || ciphertext.byteLength <= GCM_TAG_BYTES) return null; - - let parentRecordId: SealedCapabilityRecordId | undefined; - if (withParent) { - if ( - !isSealedCapabilityRecordId(record.parentRecordId) || - record.parentRecordId === record.recordId || - record.generation === 1 - ) { - return null; - } - parentRecordId = record.parentRecordId; - } else if (record.generation !== 1) { - return null; - } - - const normalized: SealedFeedCapabilityRecordV1 = { - schemaVersion: SEALED_CAPABILITY_RECORD_SCHEMA_VERSION, - kind: "sealed-feed-capability", - algorithm: CAPABILITY_VAULT_ALGORITHM, - vaultId: record.vaultId, - keyId: record.keyId, - recordId: record.recordId, - feedId: record.feedId, - capabilityRef: record.capabilityRef, - envelopeSchemaVersion: TARGET_ENVELOPE_SCHEMA_VERSION, - generation: record.generation, - ...(parentRecordId ? { parentRecordId } : {}), - nonce: record.nonce, - ciphertext: record.ciphertext, - }; - return utf8ByteLength(JSON.stringify(normalized)) <= MAX_SEALED_CAPABILITY_RECORD_BYTES - ? normalized - : null; -} - -function recordAad( - record: Omit, -): Uint8Array { - return textEncoder.encode( - JSON.stringify({ - domain: RECORD_AAD_DOMAIN, - version: SEALED_CAPABILITY_RECORD_SCHEMA_VERSION, - algorithm: record.algorithm, - vaultId: record.vaultId, - keyId: record.keyId, - recordId: record.recordId, - feedId: record.feedId, - capabilityRef: record.capabilityRef, - envelopeSchemaVersion: record.envelopeSchemaVersion, - generation: record.generation, - parentRecordId: record.parentRecordId ?? null, - nonce: record.nonce, - }), - ) as Uint8Array; -} - -function normalizeKeyCheck(value: unknown): CapabilityVaultKeyCheckV1 | null { - const record = snapshotStrictDataRecord(value, [ - "schemaVersion", - "kind", - "algorithm", - "vaultId", - "keyId", - "nonce", - "ciphertext", - ]); - if ( - !record || - record.schemaVersion !== CAPABILITY_VAULT_KEY_CHECK_SCHEMA_VERSION || - record.kind !== "capability-vault-key-check" || - record.algorithm !== CAPABILITY_VAULT_ALGORITHM || - !isCapabilityVaultId(record.vaultId) || - !isCapabilityVaultKeyId(record.keyId) || - typeof record.nonce !== "string" || - !decodeBase64Url(record.nonce, NONCE_BYTES, NONCE_BYTES) || - typeof record.ciphertext !== "string" || - !decodeBase64Url(record.ciphertext, KEY_CHECK_CIPHERTEXT_BYTES, KEY_CHECK_CIPHERTEXT_BYTES) - ) { - return null; - } - return { - schemaVersion: CAPABILITY_VAULT_KEY_CHECK_SCHEMA_VERSION, - kind: "capability-vault-key-check", - algorithm: CAPABILITY_VAULT_ALGORITHM, - vaultId: record.vaultId, - keyId: record.keyId, - nonce: record.nonce, - ciphertext: record.ciphertext, - }; -} - -function keyCheckAad( - value: Pick, -): Uint8Array { - return textEncoder.encode( - JSON.stringify({ - domain: KEY_CHECK_AAD_DOMAIN, - version: CAPABILITY_VAULT_KEY_CHECK_SCHEMA_VERSION, - algorithm: CAPABILITY_VAULT_ALGORITHM, - vaultId: value.vaultId, - keyId: value.keyId, - nonce: value.nonce, - }), - ) as Uint8Array; -} - -function directParentMatches( - record: SealedFeedCapabilityRecordV1, - parent: SealedFeedCapabilityRecordV1, -): boolean { - return ( - record.generation === parent.generation + 1 && - record.parentRecordId === parent.recordId && - record.vaultId === parent.vaultId && - record.keyId === parent.keyId && - record.feedId === parent.feedId && - record.capabilityRef === parent.capabilityRef - ); -} - -async function decryptNormalizedRecord( - record: SealedFeedCapabilityRecordV1, - key: ImportedCapabilityVaultKey, -): Promise { - const nonce = decodeBase64Url(record.nonce, NONCE_BYTES, NONCE_BYTES); - const ciphertext = decodeBase64Url(record.ciphertext, MAX_SEALED_CAPABILITY_CIPHERTEXT_BYTES); - if (!nonce || !ciphertext) return { status: "invalid" }; - - let plaintextBytes: ArrayBuffer; - try { - plaintextBytes = await getSubtle().decrypt( - { - name: "AES-GCM", - iv: nonce, - additionalData: recordAad(record), - tagLength: 128, - }, - key.cryptoKey, - ciphertext, - ); - } catch (error) { - return error instanceof CapabilityVaultCryptoError - ? { status: "unavailable" } - : { status: "invalid" }; - } - - try { - const plaintext = fatalTextDecoder.decode(plaintextBytes); - if (utf8ByteLength(plaintext) > MAX_FEED_CAPABILITY_ENVELOPE_BYTES) { - return { status: "invalid" }; - } - const envelope = validateFeedCapabilityEnvelope(JSON.parse(plaintext), record.feedId); - return envelope && JSON.stringify(envelope) === plaintext - ? { status: "available", value: envelope } - : { status: "invalid" }; - } catch { - return { status: "invalid" }; - } -} - -export function encodeSealedFeedCapabilityRecord(value: unknown): string | null { - const normalized = normalizeSealedRecord(value); - return normalized ? JSON.stringify(normalized) : null; -} - -export function validateSealedFeedCapabilityRecord( - value: unknown, -): SealedFeedCapabilityRecordV1 | null { - return normalizeSealedRecord(value); -} - -export function decodeSealedFeedCapabilityRecord( - serialized: unknown, -): SealedFeedCapabilityRecordV1 | null { - if ( - typeof serialized !== "string" || - serialized.length === 0 || - serialized.length > MAX_SEALED_CAPABILITY_RECORD_BYTES || - utf8ByteLength(serialized) > MAX_SEALED_CAPABILITY_RECORD_BYTES - ) { - return null; - } - try { - const normalized = normalizeSealedRecord(JSON.parse(serialized)); - return normalized && JSON.stringify(normalized) === serialized ? normalized : null; - } catch { - return null; - } -} - -export function encodeCapabilityVaultKeyCheck(value: unknown): string | null { - const normalized = normalizeKeyCheck(value); - return normalized ? JSON.stringify(normalized) : null; -} - -export function validateCapabilityVaultKeyCheck(value: unknown): CapabilityVaultKeyCheckV1 | null { - return normalizeKeyCheck(value); -} - -export function decodeCapabilityVaultKeyCheck( - serialized: unknown, -): CapabilityVaultKeyCheckV1 | null { - if (typeof serialized !== "string" || serialized.length === 0 || serialized.length > 1024) { - return null; - } - try { - const normalized = normalizeKeyCheck(JSON.parse(serialized)); - return normalized && JSON.stringify(normalized) === serialized ? normalized : null; - } catch { - return null; - } -} - -export async function sealFeedCapabilityEnvelope( - value: unknown, - options: SealFeedCapabilityOptions, -): Promise { - try { - const key = options.key; - const feedId = options.feedId; - const capabilityRef = options.capabilityRef; - const parentRecordValue = options.parentRecord; - const configuredFillRandom = options.fillRandom; - if ( - !isImportedCapabilityVaultKey(key) || - !isFeedHandle(feedId) || - !isFeedCapabilityReferenceFor(feedId, capabilityRef) - ) { - throw new FeedCapabilitySealingError(); - } - const envelope = validateFeedCapabilityEnvelope(value, feedId); - if (!envelope) throw new FeedCapabilitySealingError(); - const parentRecord = - parentRecordValue === undefined ? undefined : normalizeSealedRecord(parentRecordValue); - if ( - parentRecordValue !== undefined && - (!parentRecord || - parentRecord.vaultId !== key.vaultId || - parentRecord.keyId !== key.keyId || - parentRecord.feedId !== feedId || - parentRecord.capabilityRef !== capabilityRef || - parentRecord.generation >= MAX_CAPABILITY_RECORD_GENERATION) - ) { - throw new FeedCapabilitySealingError(); - } - if (parentRecord) { - const openedParent = await decryptNormalizedRecord(parentRecord, key); - if (openedParent.status !== "available") throw new FeedCapabilitySealingError(); - } - const generation = parentRecord ? parentRecord.generation + 1 : 1; - - const fillRandom = configuredFillRandom ?? fillWithWebCrypto; - const nonceBytes = new Uint8Array(NONCE_BYTES); - const recordId = allocateRecordId(fillRandom); - fillRandom(nonceBytes); - const clearRecord: Omit = { - schemaVersion: SEALED_CAPABILITY_RECORD_SCHEMA_VERSION, - kind: "sealed-feed-capability" as const, - algorithm: CAPABILITY_VAULT_ALGORITHM, - vaultId: key.vaultId, - keyId: key.keyId, - recordId, - feedId, - capabilityRef, - envelopeSchemaVersion: TARGET_ENVELOPE_SCHEMA_VERSION, - generation, - ...(parentRecord ? { parentRecordId: parentRecord.recordId } : {}), - nonce: encodeBase64Url(nonceBytes), - }; - const plaintext = textEncoder.encode(JSON.stringify(envelope)); - const ciphertext = await getSubtle().encrypt( - { - name: "AES-GCM", - iv: nonceBytes, - additionalData: recordAad(clearRecord), - tagLength: 128, - }, - key.cryptoKey, - plaintext, - ); - const record = normalizeSealedRecord({ - ...clearRecord, - ciphertext: encodeBase64Url(new Uint8Array(ciphertext)), - }); - if (!record) throw new FeedCapabilitySealingError(); - return record; - } catch (error) { - if (error instanceof FeedCapabilitySealingError) throw error; - throw new FeedCapabilitySealingError(); - } -} - -export async function openSealedFeedCapabilityEnvelope( - value: unknown, - key: ImportedCapabilityVaultKey, - parentValue?: unknown, -): Promise { - const record = normalizeSealedRecord(value); - if ( - !record || - !isImportedCapabilityVaultKey(key) || - record.vaultId !== key.vaultId || - record.keyId !== key.keyId - ) { - return { status: "invalid" }; - } - - if (record.generation === 1) { - if (parentValue !== undefined) return { status: "invalid" }; - } else { - const parent = normalizeSealedRecord(parentValue); - if (!parent || !directParentMatches(record, parent)) return { status: "invalid" }; - const openedParent = await decryptNormalizedRecord(parent, key); - if (openedParent.status !== "available") return openedParent; - } - - return decryptNormalizedRecord(record, key); -} - -export async function createCapabilityVaultKeyCheck( - key: ImportedCapabilityVaultKey, - fillRandom: CapabilityVaultRandomFill = fillWithWebCrypto, -): Promise { - try { - if (!isImportedCapabilityVaultKey(key)) throw new CapabilityVaultCryptoError(); - const nonceBytes = new Uint8Array(NONCE_BYTES); - fillRandom(nonceBytes); - const clear = { - vaultId: key.vaultId, - keyId: key.keyId, - nonce: encodeBase64Url(nonceBytes), - }; - const ciphertext = await getSubtle().encrypt( - { - name: "AES-GCM", - iv: nonceBytes, - additionalData: keyCheckAad(clear), - tagLength: 128, - }, - key.keyCheckCryptoKey, - textEncoder.encode(KEY_CHECK_PLAINTEXT), - ); - const result = normalizeKeyCheck({ - schemaVersion: CAPABILITY_VAULT_KEY_CHECK_SCHEMA_VERSION, - kind: "capability-vault-key-check", - algorithm: CAPABILITY_VAULT_ALGORITHM, - ...clear, - ciphertext: encodeBase64Url(new Uint8Array(ciphertext)), - }); - if (!result) throw new CapabilityVaultCryptoError(); - return result; - } catch { - throw new CapabilityVaultCryptoError(); - } -} - -export async function verifyCapabilityVaultKey( - value: unknown, - key: ImportedCapabilityVaultKey, -): Promise { - const check = normalizeKeyCheck(value); - if ( - !check || - !isImportedCapabilityVaultKey(key) || - check.vaultId !== key.vaultId || - check.keyId !== key.keyId - ) { - return { status: "invalid" }; - } - const nonce = decodeBase64Url(check.nonce, NONCE_BYTES, NONCE_BYTES); - const ciphertext = decodeBase64Url( - check.ciphertext, - KEY_CHECK_CIPHERTEXT_BYTES, - KEY_CHECK_CIPHERTEXT_BYTES, - ); - if (!nonce || !ciphertext) return { status: "invalid" }; - - try { - const plaintext = await getSubtle().decrypt( - { - name: "AES-GCM", - iv: nonce, - additionalData: keyCheckAad(check), - tagLength: 128, - }, - key.keyCheckCryptoKey, - ciphertext, - ); - return fatalTextDecoder.decode(plaintext) === KEY_CHECK_PLAINTEXT - ? { status: "available" } - : { status: "invalid" }; - } catch (error) { - return error instanceof CapabilityVaultCryptoError - ? { status: "unavailable" } - : { status: "invalid" }; - } -} diff --git a/src/security/strictData.test.ts b/src/security/strictData.test.ts deleted file mode 100644 index 2acce25..0000000 --- a/src/security/strictData.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - snapshotAllowedDataRecord, - snapshotDenseDataArray, - snapshotPlainDataRecord, - snapshotStrictDataRecord, -} from "./strictData"; - -describe("snapshotStrictDataRecord", () => { - it("copies exact Object and null-prototype data records", () => { - const value = Object.assign(Object.create(null), { alpha: 1, beta: "two" }); - const result = snapshotStrictDataRecord(value, ["alpha", "beta"]); - expect(result).toEqual({ alpha: 1, beta: "two" }); - expect(Object.getPrototypeOf(result!)).toBeNull(); - }); - - it.each([ - null, - [], - new Date(), - { alpha: 1 }, - { alpha: 1, beta: 2, extra: 3 }, - Object.assign(Object.create(null), { __proto__: "value", beta: 2 }), - ])("rejects a non-exact record %#", (value) => { - expect(snapshotStrictDataRecord(value, ["alpha", "beta"])).toBeNull(); - }); - - it("rejects accessors and symbols without invoking them", () => { - let accessed = false; - const value = Object.defineProperty({ beta: 2 }, "alpha", { - enumerable: true, - get() { - accessed = true; - return 1; - }, - }); - Object.defineProperty(value, Symbol("hidden"), { enumerable: true, value: 3 }); - - expect(snapshotStrictDataRecord(value, ["alpha", "beta"])).toBeNull(); - expect(accessed).toBe(false); - }); - - it("fails closed when a proxy trap throws", () => { - const value = new Proxy( - {}, - { - ownKeys() { - throw new Error("hostile proxy"); - }, - }, - ); - expect(snapshotStrictDataRecord(value, [])).toBeNull(); - }); - - it("snapshots bounded and allowlisted records without rereading proxy getters", () => { - let reads = 0; - const value = new Proxy( - { alpha: 1, beta: 2 }, - { - get(target, key, receiver) { - reads += 1; - return Reflect.get(target, key, receiver); - }, - }, - ); - expect(snapshotPlainDataRecord(value, 2)).toEqual({ alpha: 1, beta: 2 }); - expect(snapshotAllowedDataRecord(value, new Set(["alpha", "beta"]))).toEqual({ - alpha: 1, - beta: 2, - }); - expect(snapshotPlainDataRecord(value, 1)).toBeNull(); - expect(snapshotAllowedDataRecord(value, new Set(["alpha"]))).toBeNull(); - expect(reads).toBe(0); - }); - - it("rejects dangerous data keys explicitly", () => { - const value = Object.create(null) as Record; - Object.defineProperty(value, "__proto__", { enumerable: true, value: "poison" }); - expect(snapshotPlainDataRecord(value)).toBeNull(); - }); - - it("snapshots only dense data arrays", () => { - const value = ["one", "two"]; - expect(snapshotDenseDataArray(value, 2)).toEqual(value); - expect(snapshotDenseDataArray(value, 1)).toBeNull(); - const sparse: string[] = []; - sparse.length = 1; - expect(snapshotDenseDataArray(sparse, 2)).toBeNull(); - const extra = ["one"] as string[] & { extra?: boolean }; - extra.extra = true; - expect(snapshotDenseDataArray(extra, 2)).toBeNull(); - }); - - it("does not invoke dense-array accessors", () => { - let accessed = false; - const value = ["one"]; - Object.defineProperty(value, "0", { - enumerable: true, - get() { - accessed = true; - return "one"; - }, - }); - expect(snapshotDenseDataArray(value, 1)).toBeNull(); - expect(accessed).toBe(false); - }); -}); diff --git a/src/security/strictData.ts b/src/security/strictData.ts deleted file mode 100644 index b01463f..0000000 --- a/src/security/strictData.ts +++ /dev/null @@ -1,117 +0,0 @@ -const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); -const textEncoder = new TextEncoder(); - -export type StrictDataRecord = Record; - -function snapshotDataRecordInternal( - value: unknown, - allowedKeys?: ReadonlySet, - maximumKeys = Number.MAX_SAFE_INTEGER, -): StrictDataRecord | null { - try { - if ( - typeof value !== "object" || - value === null || - Array.isArray(value) || - !Number.isSafeInteger(maximumKeys) || - maximumKeys < 0 - ) { - return null; - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) return null; - - const ownKeys = Reflect.ownKeys(value); - if (ownKeys.length > maximumKeys) return null; - const snapshot = Object.create(null) as StrictDataRecord; - for (const key of ownKeys) { - if ( - typeof key !== "string" || - DANGEROUS_KEYS.has(key) || - (allowedKeys && !allowedKeys.has(key)) - ) { - return null; - } - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor?.enumerable || !("value" in descriptor)) return null; - snapshot[key] = descriptor.value; - } - return snapshot; - } catch { - return null; - } -} - -/** Snapshot any bounded plain-data record once, including proxy descriptors. */ -export function snapshotPlainDataRecord( - value: unknown, - maximumKeys = Number.MAX_SAFE_INTEGER, -): StrictDataRecord | null { - return snapshotDataRecordInternal(value, undefined, maximumKeys); -} - -/** Snapshot a plain-data record whose keys must be drawn from the allowlist. */ -export function snapshotAllowedDataRecord( - value: unknown, - allowedKeys: ReadonlySet, -): StrictDataRecord | null { - return snapshotDataRecordInternal(value, allowedKeys, allowedKeys.size); -} - -/** - * Copy an exact plain-data object without invoking accessors or retaining a - * hostile proxy/object reference. Every expected key must exist exactly once. - */ -export function snapshotStrictDataRecord( - value: unknown, - expectedKeys: readonly string[], -): StrictDataRecord | null { - const expected = new Set(expectedKeys); - if (expected.size !== expectedKeys.length) return null; - const snapshot = snapshotDataRecordInternal(value, expected, expected.size); - return snapshot && - expectedKeys.every((key) => Object.prototype.hasOwnProperty.call(snapshot, key)) - ? snapshot - : null; -} - -/** Snapshot a dense data-only array without retaining a proxy or invoking getters. */ -export function snapshotDenseDataArray(value: unknown, maximumLength: number): unknown[] | null { - try { - if (!Array.isArray(value) || !Number.isSafeInteger(maximumLength) || maximumLength < 0) { - return null; - } - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); - if (!lengthDescriptor || !("value" in lengthDescriptor)) return null; - const length = lengthDescriptor.value; - if (!Number.isSafeInteger(length) || length < 0 || length > maximumLength) return null; - const ownKeys = Reflect.ownKeys(value); - const ownKeySet = new Set(ownKeys); - if (ownKeySet.size !== length + 1 || !ownKeySet.has("length")) return null; - - const snapshot: unknown[] = []; - for (let index = 0; index < length; index += 1) { - const key = String(index); - if (!ownKeySet.has(key)) return null; - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor?.enumerable || !("value" in descriptor)) return null; - snapshot.push(descriptor.value); - } - return snapshot; - } catch { - return null; - } -} - -export function utf8ByteLength(value: string): number { - return textEncoder.encode(value).byteLength; -} - -export function serializedValueFits(value: unknown, maximumBytes: number): boolean { - try { - const serialized = JSON.stringify(value); - return typeof serialized === "string" && utf8ByteLength(serialized) <= maximumBytes; - } catch { - return false; - } -} diff --git a/src/security/targetEnvelopes.test.ts b/src/security/targetEnvelopes.test.ts deleted file mode 100644 index 619b9fc..0000000 --- a/src/security/targetEnvelopes.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { EpisodeHandle, FeedHandle } from "./resourceHandles"; -import { - MAX_EPISODE_RESOURCES_PER_FEED, - MAX_FEED_CAPABILITY_METADATA_BYTES, - MAX_FEED_CAPABILITY_ENVELOPE_BYTES, - MAX_PRIVATE_GRANTS_PER_KIND, - MAX_TARGET_URL_BYTES, - validateEpisodeResourcesEnvelope, - validateFeedCapabilityEnvelope, -} from "./targetEnvelopes"; - -const feedId = `podnotes-feed-${"11".repeat(32)}` as FeedHandle; -const otherFeedId = `podnotes-feed-${"22".repeat(32)}` as FeedHandle; -const episodeId = `podnotes-episode-${"33".repeat(32)}` as EpisodeHandle; -const otherEpisodeId = `podnotes-episode-${"44".repeat(32)}` as EpisodeHandle; - -function episodeResources(id: EpisodeHandle = episodeId) { - return { - schemaVersion: 1 as const, - kind: "episode-resources" as const, - feedId, - episodeId: id, - streamUrl: "https://media.example.com/audio.mp3?signature=value", - }; -} - -function feedBundle() { - return { - schemaVersion: 1 as const, - kind: "feed-capability-bundle" as const, - feedId, - subscriptionUrl: "https://listener:secret@example.com/feed.xml?token=value", - episodeResources: { [episodeId]: episodeResources() }, - }; -} - -describe("feed capability bundles", () => { - it("validates a complete bundle without rewriting exact target bytes", () => { - const subscriptionUrl = - "HTTPS://listener:secret@Example.com:443/feed%2fraw.xml?X-Amz-Signature=AbC%2f123"; - const result = validateFeedCapabilityEnvelope( - { - ...feedBundle(), - subscriptionUrl, - artworkUrl: "https://cdn.example.com/image.jpg", - siteUrl: "https://example.com/show", - guid: "podcast:channel:opaque-guid", - privateGrants: { - "episode-stream": ["http://192.168.1.4:8080"], - "episode-artwork": ["https://private.example.com"], - }, - }, - feedId, - ); - - expect(result).toEqual({ - schemaVersion: 1, - kind: "feed-capability-bundle", - feedId, - subscriptionUrl, - artworkUrl: "https://cdn.example.com/image.jpg", - siteUrl: "https://example.com/show", - guid: "podcast:channel:opaque-guid", - privateGrants: { - "episode-stream": ["http://192.168.1.4:8080"], - "episode-artwork": ["https://private.example.com"], - }, - episodeResources: { [episodeId]: episodeResources() }, - }); - }); - - it.each(["ftp://example.com/feed", "file:///tmp/feed.xml", "data:text/plain,feed", "nope"])( - "rejects a non-http target: %s", - (subscriptionUrl) => { - expect(validateFeedCapabilityEnvelope({ ...feedBundle(), subscriptionUrl })).toBeNull(); - }, - ); - - it("rejects wrong-object binding, extra fields, and malformed grants", () => { - const base = feedBundle(); - - expect(validateFeedCapabilityEnvelope(base, otherFeedId)).toBeNull(); - expect(validateFeedCapabilityEnvelope({ ...base, unexpected: true })).toBeNull(); - expect( - validateFeedCapabilityEnvelope({ - ...base, - privateGrants: { unknown: ["https://example.com"] }, - }), - ).toBeNull(); - expect( - validateFeedCapabilityEnvelope({ - ...base, - privateGrants: { "episode-stream": ["https://example.com/private/path"] }, - }), - ).toBeNull(); - const sparse: string[] = []; - sparse.length = 1; - expect( - validateFeedCapabilityEnvelope({ - ...base, - privateGrants: { "episode-stream": sparse }, - }), - ).toBeNull(); - }); - - it("fails closed for unsupported versions and hostile objects", () => { - const hostile = new Proxy(feedBundle(), { - ownKeys() { - throw new Error("hostile ownKeys trap"); - }, - }); - - expect(validateFeedCapabilityEnvelope({ ...feedBundle(), schemaVersion: 2 })).toBeNull(); - expect(validateFeedCapabilityEnvelope(hostile)).toBeNull(); - }); - - it("snapshots proxy data descriptors once instead of rereading changing getters", () => { - let rootGuidReads = 0; - let episodeGuidReads = 0; - const episode = new Proxy( - { ...episodeResources(), guid: "stable-episode-guid" }, - { - get(target, key, receiver) { - if (key === "guid") { - episodeGuidReads += 1; - return "x".repeat(9_000); - } - return Reflect.get(target, key, receiver); - }, - }, - ); - const value = new Proxy( - { - ...feedBundle(), - guid: "stable-feed-guid", - episodeResources: { [episodeId]: episode }, - }, - { - get(target, key, receiver) { - if (key === "guid") { - rootGuidReads += 1; - return "x".repeat(9_000); - } - return Reflect.get(target, key, receiver); - }, - }, - ); - - expect(validateFeedCapabilityEnvelope(value)).toEqual({ - ...feedBundle(), - guid: "stable-feed-guid", - episodeResources: { - [episodeId]: { ...episodeResources(), guid: "stable-episode-guid" }, - }, - }); - expect(rootGuidReads).toBe(0); - expect(episodeGuidReads).toBe(0); - }); - - it("preserves bounded channel GUID evidence without imposing uniqueness", () => { - const guid = "podcast:channel:opaque%2fGUID"; - - expect(validateFeedCapabilityEnvelope({ ...feedBundle(), guid })?.guid).toBe(guid); - expect(validateFeedCapabilityEnvelope({ ...feedBundle(), guid: "" })).toBeNull(); - }); - - it("requires every episode record key and value to bind to the same feed and episode", () => { - const base = feedBundle(); - - expect( - validateFeedCapabilityEnvelope({ - ...base, - episodeResources: { [otherEpisodeId]: episodeResources() }, - }), - ).toBeNull(); - expect( - validateFeedCapabilityEnvelope({ - ...base, - episodeResources: { - [episodeId]: { ...episodeResources(), feedId: otherFeedId }, - }, - }), - ).toBeNull(); - }); - - it("bounds URLs, private-grant arrays, episode count, and serialized bundle size", () => { - const base = feedBundle(); - const oversizedUrl = `https://example.com/${"a".repeat(MAX_TARGET_URL_BYTES)}`; - const tooManyGrants = Array.from( - { length: MAX_PRIVATE_GRANTS_PER_KIND + 1 }, - (_, index) => `https://private-${index}.example.com`, - ); - const tooManyEpisodes = Object.fromEntries( - Array.from({ length: MAX_EPISODE_RESOURCES_PER_FEED + 1 }, (_, index) => { - const id = - `podnotes-episode-${index.toString(16).padStart(64, "0")}` as EpisodeHandle; - return [id, episodeResources(id)]; - }), - ); - const oversizedBundleEpisodes = Object.fromEntries( - Array.from({ length: 72 }, (_, index) => { - const id = - `podnotes-episode-${(index + 1000).toString(16).padStart(64, "0")}` as EpisodeHandle; - return [ - id, - { - ...episodeResources(id), - streamUrl: `https://media.example.com/${"a".repeat(15_000)}${index}`, - }, - ]; - }), - ); - - expect(validateFeedCapabilityEnvelope({ ...base, artworkUrl: oversizedUrl })).toBeNull(); - expect( - validateFeedCapabilityEnvelope({ - ...base, - privateGrants: { "episode-stream": tooManyGrants }, - }), - ).toBeNull(); - expect( - validateFeedCapabilityEnvelope({ ...base, episodeResources: tooManyEpisodes }), - ).toBeNull(); - expect( - validateFeedCapabilityEnvelope({ ...base, episodeResources: oversizedBundleEpisodes }), - ).toBeNull(); - expect(MAX_FEED_CAPABILITY_ENVELOPE_BYTES).toBe(1024 * 1024); - }); - - it("independently bounds feed-level capability metadata", () => { - const longTarget = `https://example.com/${"a".repeat(15_000)}`; - const origins = Array.from({ length: 16 }, (_, index) => { - const label = `${index.toString(36)}${"a".repeat(60)}`; - return `https://${label}.${"b".repeat(63)}.${"c".repeat(63)}.example`; - }); - const privateGrants = Object.fromEntries( - [ - "subscription", - "feed-artwork", - "site", - "episode-stream", - "episode-chapters", - "episode-artwork", - "episode-item-link", - ].map((kind) => [kind, origins]), - ); - - expect( - validateFeedCapabilityEnvelope({ - ...feedBundle(), - subscriptionUrl: longTarget, - artworkUrl: longTarget, - siteUrl: longTarget, - guid: "g".repeat(7000), - privateGrants, - }), - ).toBeNull(); - expect(MAX_FEED_CAPABILITY_METADATA_BYTES).toBe(64 * 1024); - }); -}); - -describe("episode resource envelopes", () => { - it("validates a complete v1 entry and exact object binding", () => { - const streamUrl = "HTTPS://Media.Example.com:443/audio%2fraw.mp3?X-Amz-Signature=AbC%2f123"; - expect( - validateEpisodeResourcesEnvelope( - { - ...episodeResources(), - streamUrl, - chaptersUrl: "https://example.com/chapters.json", - artworkUrl: "https://example.com/art.jpg", - itemLink: "https://example.com/episode", - guid: "opaque-guid-value", - }, - feedId, - episodeId, - ), - ).toEqual({ - schemaVersion: 1, - kind: "episode-resources", - feedId, - episodeId, - streamUrl, - chaptersUrl: "https://example.com/chapters.json", - artworkUrl: "https://example.com/art.jpg", - itemLink: "https://example.com/episode", - guid: "opaque-guid-value", - }); - }); - - it("rejects cross-feed, cross-episode, non-http, empty GUID, and extra fields", () => { - const base = episodeResources(); - - expect(validateEpisodeResourcesEnvelope(base, otherFeedId, episodeId)).toBeNull(); - expect(validateEpisodeResourcesEnvelope(base, feedId, otherEpisodeId)).toBeNull(); - expect(validateEpisodeResourcesEnvelope({ ...base, streamUrl: "blob:value" })).toBeNull(); - expect(validateEpisodeResourcesEnvelope({ ...base, guid: "" })).toBeNull(); - expect( - validateEpisodeResourcesEnvelope({ ...base, url: "https://legacy.example" }), - ).toBeNull(); - }); - - it("preserves durable nonplayable episodes with evidence but no stream URL", () => { - const base = episodeResources(); - const { streamUrl, ...withoutStream } = base; - - expect(streamUrl).toBe(base.streamUrl); - expect(validateEpisodeResourcesEnvelope({ ...withoutStream, guid: "opaque-guid" })).toEqual( - { ...withoutStream, guid: "opaque-guid" }, - ); - expect(validateEpisodeResourcesEnvelope(withoutStream)).toBeNull(); - }); - - it("fails closed for unsupported versions and hostile objects", () => { - const hostile = new Proxy(episodeResources(), { - getOwnPropertyDescriptor() { - throw new Error("hostile descriptor trap"); - }, - }); - - expect( - validateEpisodeResourcesEnvelope({ ...episodeResources(), schemaVersion: 2 }), - ).toBeNull(); - expect(validateEpisodeResourcesEnvelope(hostile)).toBeNull(); - }); -}); diff --git a/src/security/targetEnvelopes.ts b/src/security/targetEnvelopes.ts deleted file mode 100644 index 2312bca..0000000 --- a/src/security/targetEnvelopes.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { - isEpisodeHandle, - isFeedHandle, - type EpisodeHandle, - type FeedHandle, -} from "./resourceHandles"; -import { - snapshotAllowedDataRecord, - snapshotDenseDataArray, - snapshotPlainDataRecord, - type StrictDataRecord, -} from "./strictData"; - -export const TARGET_ENVELOPE_SCHEMA_VERSION = 1; -export const MAX_TARGET_URL_BYTES = 16 * 1024; -export const MAX_GUID_BYTES = 8 * 1024; -export const MAX_EPISODE_RESOURCES_ENVELOPE_BYTES = 64 * 1024; -export const MAX_FEED_CAPABILITY_ENVELOPE_BYTES = 1024 * 1024; -export const MAX_FEED_CAPABILITY_METADATA_BYTES = 64 * 1024; -export const MAX_EPISODE_RESOURCES_PER_FEED = 4096; -export const MAX_PRIVATE_GRANTS_PER_KIND = 16; - -export const PRIVATE_TARGET_GRANT_KINDS = [ - "subscription", - "feed-artwork", - "site", - "episode-stream", - "episode-chapters", - "episode-artwork", - "episode-item-link", -] as const; - -export type PrivateTargetGrantKind = (typeof PRIVATE_TARGET_GRANT_KINDS)[number]; -export type PrivateTargetGrants = Partial>; - -export interface EpisodeResourcesEnvelope { - schemaVersion: typeof TARGET_ENVELOPE_SCHEMA_VERSION; - kind: "episode-resources"; - feedId: FeedHandle; - episodeId: EpisodeHandle; - streamUrl?: string; - chaptersUrl?: string; - artworkUrl?: string; - itemLink?: string; - guid?: string; -} - -export type EpisodeResourcesById = Readonly< - Partial> ->; - -export interface FeedCapabilityEnvelope { - schemaVersion: typeof TARGET_ENVELOPE_SCHEMA_VERSION; - kind: "feed-capability-bundle"; - feedId: FeedHandle; - subscriptionUrl: string; - artworkUrl?: string; - siteUrl?: string; - guid?: string; - privateGrants?: PrivateTargetGrants; - episodeResources: EpisodeResourcesById; -} - -const textEncoder = new TextEncoder(); -const FEED_CAPABILITY_KEYS = new Set([ - "schemaVersion", - "kind", - "feedId", - "subscriptionUrl", - "artworkUrl", - "siteUrl", - "guid", - "privateGrants", - "episodeResources", -]); -const EPISODE_KEYS = new Set([ - "schemaVersion", - "kind", - "feedId", - "episodeId", - "streamUrl", - "chaptersUrl", - "artworkUrl", - "itemLink", - "guid", -]); -const PRIVATE_GRANT_KEYS = new Set(PRIVATE_TARGET_GRANT_KINDS); - -type UnknownRecord = StrictDataRecord; - -function hasUnsafeUnicode(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if (code <= 0x1f || code === 0x7f) return true; - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) return true; - index += 1; - } else if (code >= 0xdc00 && code <= 0xdfff) { - return true; - } - } - return false; -} - -function isBoundedString(value: unknown, maximumBytes: number): value is string { - return ( - typeof value === "string" && - value.length > 0 && - value.trim() === value && - !hasUnsafeUnicode(value) && - textEncoder.encode(value).byteLength <= maximumBytes - ); -} - -function normalizeHttpTarget(value: unknown): string | undefined { - if (!isBoundedString(value, MAX_TARGET_URL_BYTES)) return undefined; - try { - const url = new URL(value); - if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; - // Validate with URL, but preserve the exact configured bytes. Normalizing a - // signed URL can change its host casing, default port, or encoded path and - // invalidate a signature even when the resulting URL is otherwise equivalent. - return value; - } catch { - return undefined; - } -} - -function normalizePrivateOrigin(value: unknown): string | undefined { - const normalized = normalizeHttpTarget(value); - if (!normalized) return undefined; - const url = new URL(normalized); - if ( - url.username || - url.password || - url.pathname !== "/" || - url.search || - url.hash || - value !== url.origin - ) { - return undefined; - } - return url.origin; -} - -function normalizePrivateGrants(value: unknown): PrivateTargetGrants | null | undefined { - if (value === undefined) return undefined; - const record = snapshotAllowedDataRecord(value, PRIVATE_GRANT_KEYS); - if (!record) return null; - - const normalized: PrivateTargetGrants = {}; - for (const kind of PRIVATE_TARGET_GRANT_KINDS) { - if (!Object.prototype.hasOwnProperty.call(record, kind)) continue; - const grants = snapshotDenseDataArray(record[kind], MAX_PRIVATE_GRANTS_PER_KIND); - if (!grants || grants.length === 0) return null; - const origins = grants.map(normalizePrivateOrigin); - if (origins.some((origin) => origin === undefined)) return null; - const concrete = origins as string[]; - if (new Set(concrete).size !== concrete.length) return null; - normalized[kind] = concrete; - } - return normalized; -} - -function readOptionalTarget(record: UnknownRecord, key: string): string | null | undefined { - if (!Object.prototype.hasOwnProperty.call(record, key)) return undefined; - return normalizeHttpTarget(record[key]) ?? null; -} - -function serializedFits(value: unknown, maximumBytes: number): boolean { - try { - return textEncoder.encode(JSON.stringify(value)).byteLength <= maximumBytes; - } catch { - return false; - } -} - -function validateEpisodeResourcesEnvelopeUnchecked( - value: unknown, - expectedFeedId?: unknown, - expectedEpisodeId?: unknown, -): EpisodeResourcesEnvelope | null { - const record = snapshotAllowedDataRecord(value, EPISODE_KEYS); - if (!record) return null; - if ( - record.schemaVersion !== TARGET_ENVELOPE_SCHEMA_VERSION || - record.kind !== "episode-resources" || - !isFeedHandle(record.feedId) || - !isEpisodeHandle(record.episodeId) || - (expectedFeedId !== undefined && record.feedId !== expectedFeedId) || - (expectedEpisodeId !== undefined && record.episodeId !== expectedEpisodeId) - ) { - return null; - } - - const streamUrl = readOptionalTarget(record, "streamUrl"); - const chaptersUrl = readOptionalTarget(record, "chaptersUrl"); - const artworkUrl = readOptionalTarget(record, "artworkUrl"); - const itemLink = readOptionalTarget(record, "itemLink"); - if (streamUrl === null || chaptersUrl === null || artworkUrl === null || itemLink === null) { - return null; - } - if ( - Object.prototype.hasOwnProperty.call(record, "guid") && - !isBoundedString(record.guid, MAX_GUID_BYTES) - ) { - return null; - } - if (!streamUrl && !chaptersUrl && !artworkUrl && !itemLink && typeof record.guid !== "string") { - return null; - } - - const normalized: EpisodeResourcesEnvelope = { - schemaVersion: TARGET_ENVELOPE_SCHEMA_VERSION, - kind: "episode-resources", - feedId: record.feedId, - episodeId: record.episodeId, - ...(streamUrl ? { streamUrl } : {}), - ...(chaptersUrl ? { chaptersUrl } : {}), - ...(artworkUrl ? { artworkUrl } : {}), - ...(itemLink ? { itemLink } : {}), - ...(typeof record.guid === "string" ? { guid: record.guid } : {}), - }; - return serializedFits(normalized, MAX_EPISODE_RESOURCES_ENVELOPE_BYTES) ? normalized : null; -} - -export function validateEpisodeResourcesEnvelope( - value: unknown, - expectedFeedId?: unknown, - expectedEpisodeId?: unknown, -): EpisodeResourcesEnvelope | null { - try { - return validateEpisodeResourcesEnvelopeUnchecked(value, expectedFeedId, expectedEpisodeId); - } catch { - return null; - } -} - -function validateFeedCapabilityEnvelopeUnchecked( - value: unknown, - expectedFeedId?: unknown, -): FeedCapabilityEnvelope | null { - const record = snapshotAllowedDataRecord(value, FEED_CAPABILITY_KEYS); - if (!record) return null; - const episodeResourcesRecord = snapshotPlainDataRecord( - record.episodeResources, - MAX_EPISODE_RESOURCES_PER_FEED, - ); - if ( - record.schemaVersion !== TARGET_ENVELOPE_SCHEMA_VERSION || - record.kind !== "feed-capability-bundle" || - !isFeedHandle(record.feedId) || - (expectedFeedId !== undefined && record.feedId !== expectedFeedId) || - !episodeResourcesRecord - ) { - return null; - } - - const episodeIds = Object.keys(episodeResourcesRecord); - if (episodeIds.length > MAX_EPISODE_RESOURCES_PER_FEED) return null; - - const subscriptionUrl = normalizeHttpTarget(record.subscriptionUrl); - const artworkUrl = readOptionalTarget(record, "artworkUrl"); - const siteUrl = readOptionalTarget(record, "siteUrl"); - const privateGrants = normalizePrivateGrants(record.privateGrants); - if (!subscriptionUrl || artworkUrl === null || siteUrl === null || privateGrants === null) { - return null; - } - if ( - Object.prototype.hasOwnProperty.call(record, "guid") && - !isBoundedString(record.guid, MAX_GUID_BYTES) - ) { - return null; - } - const normalizedMetadata: Omit = { - schemaVersion: TARGET_ENVELOPE_SCHEMA_VERSION, - kind: "feed-capability-bundle" as const, - feedId: record.feedId, - subscriptionUrl, - ...(artworkUrl ? { artworkUrl } : {}), - ...(siteUrl ? { siteUrl } : {}), - ...(typeof record.guid === "string" ? { guid: record.guid } : {}), - ...(privateGrants ? { privateGrants } : {}), - }; - if (!serializedFits(normalizedMetadata, MAX_FEED_CAPABILITY_METADATA_BYTES)) return null; - - const episodeResources: Partial> = {}; - for (const episodeId of episodeIds.sort()) { - if (!isEpisodeHandle(episodeId)) return null; - const entry = validateEpisodeResourcesEnvelope( - episodeResourcesRecord[episodeId], - record.feedId, - episodeId, - ); - if (!entry) return null; - episodeResources[episodeId] = entry; - } - - const normalized: FeedCapabilityEnvelope = { - ...normalizedMetadata, - episodeResources, - }; - return serializedFits(normalized, MAX_FEED_CAPABILITY_ENVELOPE_BYTES) ? normalized : null; -} - -export function validateFeedCapabilityEnvelope( - value: unknown, - expectedFeedId?: unknown, -): FeedCapabilityEnvelope | null { - try { - return validateFeedCapabilityEnvelopeUnchecked(value, expectedFeedId); - } catch { - return null; - } -} diff --git a/src/services/FeedCapabilityBroker.test.ts b/src/services/FeedCapabilityBroker.test.ts deleted file mode 100644 index d4e3817..0000000 --- a/src/services/FeedCapabilityBroker.test.ts +++ /dev/null @@ -1,664 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { PinnedNetworkHopRequest } from "src/network/CapabilityScopedTransport"; -import { - NetworkCapabilityError, - type EpisodeNetworkResourceKind, - type FeedNetworkResourceKind, - type NetworkCapabilityScope, -} from "src/network/NetworkCapability"; -import { NetworkScheduler } from "src/network/NetworkScheduler"; -import { feedCapabilityReferenceForAttempt } from "src/security/feedCapabilityReferences"; -import type { EpisodeHandle, FeedHandle } from "src/security/resourceHandles"; -import { - TARGET_ENVELOPE_SCHEMA_VERSION, - type EpisodeResourcesEnvelope, - type FeedCapabilityEnvelope, -} from "src/security/targetEnvelopes"; - -import { - createFeedCapabilityNetworkRuntime, - type CapabilityUseContext, - type FeedCapabilityNetworkRuntime, - type FeedCapabilityReader, -} from "./FeedCapabilityBroker"; - -const feedId = `podnotes-feed-${"1a".repeat(32)}` as FeedHandle; -const otherFeedId = `podnotes-feed-${"2b".repeat(32)}` as FeedHandle; -const episodeId = `podnotes-episode-${"3c".repeat(32)}` as EpisodeHandle; -const otherEpisodeId = `podnotes-episode-${"4d".repeat(32)}` as EpisodeHandle; -const reference = feedCapabilityReferenceForAttempt(feedId, 1)!; -const foreignReference = feedCapabilityReferenceForAttempt(otherFeedId, 1)!; -const encoder = new TextEncoder(); - -const targets = Object.freeze({ - subscription: "HTTPS://Feeds.Example.COM:443/feed%2fmain.xml?Signature=A%2BB", - "feed-artwork": "https://cdn.example.com/feed.jpg?signature=feed", - site: "https://www.example.com/podcast", - "episode-stream": "https://media.example.com/audio%2fepisode.mp3?Signature=C%2BD", - "episode-chapters": "https://media.example.com/chapters.json?signature=chapters", - "episode-artwork": "https://cdn.example.com/episode.jpg?signature=episode", - "episode-item-link": "https://www.example.com/episodes/one?signature=item", -}); - -const grants = Object.freeze({ - subscription: ["http://10.0.0.1:8001"], - "feed-artwork": ["http://10.0.0.2:8002"], - site: ["http://10.0.0.3:8003"], - "episode-stream": ["http://10.0.0.4:8004"], - "episode-chapters": ["http://10.0.0.5:8005"], - "episode-artwork": ["http://10.0.0.6:8006"], - "episode-item-link": ["http://10.0.0.7:8007"], -}); - -function episodeResources(id: EpisodeHandle = episodeId): EpisodeResourcesEnvelope { - return { - schemaVersion: TARGET_ENVELOPE_SCHEMA_VERSION, - kind: "episode-resources" as const, - feedId, - episodeId: id, - streamUrl: targets["episode-stream"], - chaptersUrl: targets["episode-chapters"], - artworkUrl: targets["episode-artwork"], - itemLink: targets["episode-item-link"], - guid: `guid-${id}`, - }; -} - -function episodeResourceMap( - ...resources: EpisodeResourcesEnvelope[] -): FeedCapabilityEnvelope["episodeResources"] { - const result: Partial> = {}; - for (const resource of resources) result[resource.episodeId] = resource; - return result; -} - -function envelope(overrides: Partial = {}): FeedCapabilityEnvelope { - return { - schemaVersion: TARGET_ENVELOPE_SCHEMA_VERSION, - kind: "feed-capability-bundle", - feedId, - subscriptionUrl: targets.subscription, - artworkUrl: targets["feed-artwork"], - siteUrl: targets.site, - privateGrants: grants, - episodeResources: episodeResourceMap(episodeResources()), - ...overrides, - }; -} - -function readerWith(result: unknown): FeedCapabilityReader & { - readFeedCapabilities: ReturnType; -} { - return { readFeedCapabilities: vi.fn().mockResolvedValue(result) }; -} - -function availableReader(value: FeedCapabilityEnvelope = envelope()) { - return readerWith({ status: "available", value }); -} - -function deferred(): { - readonly promise: Promise; - readonly resolve: (value: Value) => void; -} { - let resolve!: (value: Value) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -async function* bytesBody(value = "ok"): AsyncIterable { - yield encoder.encode(value); -} - -function makeRuntime( - reader: FeedCapabilityReader = availableReader(), - adapterImplementation?: (request: PinnedNetworkHopRequest) => unknown, -): { - runtime: FeedCapabilityNetworkRuntime; - requests: PinnedNetworkHopRequest[]; -} { - const requests: PinnedNetworkHopRequest[] = []; - const runtime = createFeedCapabilityNetworkRuntime( - reader, - () => ["93.184.216.34"], - (request) => { - requests.push(request); - if (adapterImplementation) return adapterImplementation(request) as never; - return { - status: 200, - body: bytesBody(), - connectedAddress: request.connectAddress, - close: () => undefined, - }; - }, - new NetworkScheduler(), - ); - return { runtime, requests }; -} - -async function useFeedBytes( - runtime: FeedCapabilityNetworkRuntime, - resourceKind: FeedNetworkResourceKind, -) { - return runtime.broker.withFeedResource( - feedId, - reference, - resourceKind, - ({ capability, scope }) => runtime.transport.getBytes(capability, scope), - ); -} - -async function useEpisodeBytes( - runtime: FeedCapabilityNetworkRuntime, - resourceKind: Exclude, - episode = episodeId, -) { - return runtime.broker.withEpisodeResource( - feedId, - episode, - reference, - resourceKind, - ({ capability, scope }) => runtime.transport.getBytes(capability, scope), - ); -} - -describe("FeedCapabilityBroker", () => { - it.each([ - ["subscription", "feeds.example.com", "/feed%2fmain.xml?Signature=A%2BB"], - ["feed-artwork", "cdn.example.com", "/feed.jpg?signature=feed"], - ["site", "www.example.com", "/podcast"], - ] as const)( - "executes only the %s feed target through the trapped transport", - async (resourceKind, hostHeader, requestTarget) => { - const reader = availableReader(); - const { runtime, requests } = makeRuntime(reader); - - const result = await useFeedBytes(runtime, resourceKind); - - expect(result).toMatchObject({ status: "available" }); - expect(requests).toHaveLength(1); - expect(requests[0]).toMatchObject({ hostHeader, requestTarget }); - expect(reader.readFeedCapabilities).toHaveBeenCalledOnce(); - expect(reader.readFeedCapabilities).toHaveBeenCalledWith(feedId, reference); - runtime.dispose(); - }, - ); - - it.each([ - ["episode-chapters", "media.example.com", "/chapters.json?signature=chapters"], - ["episode-artwork", "cdn.example.com", "/episode.jpg?signature=episode"], - ["episode-item-link", "www.example.com", "/episodes/one?signature=item"], - ] as const)( - "executes only the %s episode target through the trapped transport", - async (resourceKind, hostHeader, requestTarget) => { - const reader = availableReader(); - const { runtime, requests } = makeRuntime(reader); - - const result = await useEpisodeBytes(runtime, resourceKind); - - expect(result).toMatchObject({ status: "available" }); - expect(requests).toHaveLength(1); - expect(requests[0]).toMatchObject({ hostHeader, requestTarget }); - expect(reader.readFeedCapabilities).toHaveBeenCalledOnce(); - runtime.dispose(); - }, - ); - - it("selects the episode-stream field but never buffers it", async () => { - const unsafeStream = "https://user:password@secret.example/audio.mp3?token=raw"; - const streamEnvelope = envelope({ - episodeResources: episodeResourceMap({ - ...episodeResources(), - streamUrl: unsafeStream, - }), - }); - const operation = vi.fn(); - const { runtime, requests } = makeRuntime(availableReader(streamEnvelope)); - - const error = await runtime.broker - .withEpisodeResource(feedId, episodeId, reference, "episode-stream", operation) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(NetworkCapabilityError); - expect(String(error)).not.toContain(unsafeStream); - expect(operation).not.toHaveBeenCalled(); - expect(requests).toEqual([]); - runtime.dispose(); - }); - - it("applies private grants only to their matching resource kind", async () => { - const privateTarget = "http://192.168.1.10:8080/resource"; - const privateEnvelope = envelope({ - subscriptionUrl: privateTarget, - artworkUrl: privateTarget, - privateGrants: { subscription: ["http://192.168.1.10:8080"] }, - }); - const { runtime, requests } = makeRuntime(availableReader(privateEnvelope)); - - await expect(useFeedBytes(runtime, "subscription")).resolves.toMatchObject({ - status: "available", - }); - await expect(useFeedBytes(runtime, "feed-artwork")).rejects.toMatchObject({ - code: "NETWORK_TARGET_REJECTED", - }); - expect(requests).toHaveLength(1); - expect(requests[0]).toMatchObject({ - connectAddress: "192.168.1.10", - hostHeader: "192.168.1.10:8080", - }); - runtime.dispose(); - }); - - it("traps issuer and resolver authority behind a factory-only runtime", async () => { - const module = await import("./FeedCapabilityBroker"); - const { runtime } = makeRuntime(); - - expect(module).not.toHaveProperty("FeedCapabilityBroker"); - expect(Object.keys(runtime).sort()).toEqual(["broker", "dispose", "transport"]); - expect(runtime).not.toHaveProperty("issuer"); - expect(runtime).not.toHaveProperty("resolver"); - expect(Object.keys(runtime.broker)).toEqual([]); - expect(Object.getPrototypeOf(runtime.broker)).toBeNull(); - expect(Reflect.get(runtime.broker, "constructor")).toBeUndefined(); - expect(Reflect.get(runtime.broker, "dispose")).toBeUndefined(); - expect(Object.getPrototypeOf(runtime.transport)).toBeNull(); - expect(Reflect.get(runtime.transport, "dispose")).toBeUndefined(); - expect(Object.isFrozen(runtime)).toBe(true); - expect(Object.isFrozen(runtime.broker)).toBe(true); - expect(Object.isFrozen(runtime.transport)).toBe(true); - runtime.dispose(); - }); - - it("cancels a feed lease while its repository read is still pending", async () => { - const read = deferred(); - const reader: FeedCapabilityReader & { - readFeedCapabilities: ReturnType; - } = { readFeedCapabilities: vi.fn().mockReturnValue(read.promise) }; - const operation = vi.fn(); - const { runtime, requests } = makeRuntime(reader); - - const pending = runtime.broker.withFeedResource( - feedId, - reference, - "subscription", - operation, - ); - await vi.waitFor(() => expect(reader.readFeedCapabilities).toHaveBeenCalledOnce()); - - expect(runtime.broker.invalidateFeed(feedId)).toBe(0); - await expect(pending).resolves.toEqual({ status: "unavailable" }); - read.resolve({ status: "available", value: envelope() }); - await Promise.resolve(); - expect(operation).not.toHaveBeenCalled(); - expect(requests).toEqual([]); - runtime.dispose(); - }); - - it("cancels an episode lease while its repository read is still pending", async () => { - const read = deferred(); - const reader: FeedCapabilityReader & { - readFeedCapabilities: ReturnType; - } = { readFeedCapabilities: vi.fn().mockReturnValue(read.promise) }; - const operation = vi.fn(); - const { runtime, requests } = makeRuntime(reader); - - const pending = runtime.broker.withEpisodeResource( - feedId, - episodeId, - reference, - "episode-chapters", - operation, - ); - await vi.waitFor(() => expect(reader.readFeedCapabilities).toHaveBeenCalledOnce()); - - expect(runtime.broker.invalidateEpisode(feedId, episodeId)).toBe(0); - await expect(pending).resolves.toEqual({ status: "unavailable" }); - read.resolve({ status: "available", value: envelope() }); - await Promise.resolve(); - expect(operation).not.toHaveBeenCalled(); - expect(requests).toEqual([]); - runtime.dispose(); - }); - - it("revokes a retained capability as soon as its operation settles", async () => { - const { runtime } = makeRuntime(); - let retained: CapabilityUseContext | undefined; - - const result = await runtime.broker.withFeedResource( - feedId, - reference, - "subscription", - (context) => { - retained = context as CapabilityUseContext; - expect(Object.isFrozen(context)).toBe(true); - expect(Object.isFrozen(context.scope)).toBe(true); - expect(JSON.stringify(context)).not.toContain(targets.subscription); - return "complete"; - }, - ); - - expect(result).toEqual({ status: "available", value: "complete" }); - expect(retained).toBeDefined(); - await expect( - runtime.transport.getBytes(retained!.capability, retained!.scope), - ).rejects.toBeInstanceOf(NetworkCapabilityError); - runtime.dispose(); - }); - - it("revokes a retained capability when its operation throws", async () => { - const { runtime } = makeRuntime(); - let retained: CapabilityUseContext | undefined; - const sentinel = new Error("consumer failed"); - - await expect( - runtime.broker.withFeedResource(feedId, reference, "subscription", (context) => { - retained = context as CapabilityUseContext; - throw sentinel; - }), - ).rejects.toBe(sentinel); - await expect( - runtime.transport.getBytes(retained!.capability, retained!.scope), - ).rejects.toBeInstanceOf(NetworkCapabilityError); - runtime.dispose(); - }); - - it("invalidates active feed capabilities and suppresses their late result", async () => { - const { runtime } = makeRuntime(); - let resolveOperation!: () => void; - const operationGate = new Promise((resolve) => { - resolveOperation = resolve; - }); - let retained: CapabilityUseContext | undefined; - const pending = runtime.broker.withFeedResource( - feedId, - reference, - "subscription", - async (context) => { - retained = context as CapabilityUseContext; - await operationGate; - return "late"; - }, - ); - await vi.waitFor(() => expect(retained).toBeDefined()); - - expect(runtime.broker.invalidateFeed(feedId)).toBe(1); - resolveOperation(); - await expect(pending).resolves.toEqual({ status: "unavailable" }); - await expect( - runtime.transport.getBytes(retained!.capability, retained!.scope), - ).rejects.toBeInstanceOf(NetworkCapabilityError); - expect(runtime.broker.invalidateFeed(feedId)).toBe(0); - runtime.dispose(); - }); - - it("settles invalidated work even when its callback never cooperates", async () => { - const { runtime } = makeRuntime(); - let retained: CapabilityUseContext | undefined; - const never = new Promise(() => undefined); - const pending = runtime.broker.withFeedResource( - feedId, - reference, - "subscription", - (context) => { - retained = context as CapabilityUseContext; - return never; - }, - ); - await vi.waitFor(() => expect(retained).toBeDefined()); - - expect(runtime.broker.invalidateFeed(feedId)).toBe(1); - await expect(pending).resolves.toEqual({ status: "unavailable" }); - await expect( - runtime.transport.getBytes(retained!.capability, retained!.scope), - ).rejects.toBeInstanceOf(NetworkCapabilityError); - runtime.dispose(); - }); - - it("suppresses a real transport rejection caused by revocation", async () => { - const { runtime, requests } = makeRuntime(availableReader(), (request) => ({ - status: 200, - body: (async function* () { - yield encoder.encode("partial"); - await new Promise((resolve) => { - request.signal.addEventListener("abort", () => resolve(), { once: true }); - }); - throw new Error("adapter aborted"); - })(), - connectedAddress: request.connectAddress, - close: () => undefined, - })); - const pending = useFeedBytes(runtime, "subscription"); - await vi.waitFor(() => expect(requests).toHaveLength(1)); - - expect(runtime.broker.invalidateFeed(feedId)).toBe(1); - await expect(pending).resolves.toEqual({ status: "unavailable" }); - runtime.dispose(); - }); - - it("invalidates only the selected episode and leaves other work available", async () => { - const twoEpisodes = envelope({ - episodeResources: episodeResourceMap( - episodeResources(), - episodeResources(otherEpisodeId), - ), - }); - const { runtime } = makeRuntime(availableReader(twoEpisodes)); - let resolveFirst!: () => void; - let resolveSecond!: () => void; - let firstStarted = false; - let secondStarted = false; - const firstGate = new Promise((resolve) => (resolveFirst = resolve)); - const secondGate = new Promise((resolve) => (resolveSecond = resolve)); - const first = runtime.broker.withEpisodeResource( - feedId, - episodeId, - reference, - "episode-chapters", - async () => { - firstStarted = true; - await firstGate; - return "first"; - }, - ); - const second = runtime.broker.withEpisodeResource( - feedId, - otherEpisodeId, - reference, - "episode-chapters", - async () => { - secondStarted = true; - await secondGate; - return "second"; - }, - ); - await vi.waitFor(() => { - expect(firstStarted).toBe(true); - expect(secondStarted).toBe(true); - }); - expect(runtime.broker.invalidateEpisode(feedId, episodeId)).toBe(1); - - resolveFirst(); - resolveSecond(); - await expect(first).resolves.toEqual({ status: "unavailable" }); - await expect(second).resolves.toEqual({ status: "available", value: "second" }); - runtime.dispose(); - }); - - it("disposal revokes active work, suppresses late values, and rejects new reads", async () => { - const reader = availableReader(); - const { runtime } = makeRuntime(reader); - let resolveOperation!: () => void; - let operationStarted = false; - const operationGate = new Promise((resolve) => (resolveOperation = resolve)); - const pending = runtime.broker.withFeedResource( - feedId, - reference, - "subscription", - async () => { - operationStarted = true; - await operationGate; - return "late"; - }, - ); - await vi.waitFor(() => expect(operationStarted).toBe(true)); - - runtime.dispose(); - runtime.dispose(); - resolveOperation(); - await expect(pending).resolves.toEqual({ status: "unavailable" }); - expect(runtime.broker.isDisposed()).toBe(true); - const callsBefore = reader.readFeedCapabilities.mock.calls.length; - await expect( - runtime.broker.withFeedResource(feedId, reference, "subscription", () => "never"), - ).resolves.toEqual({ status: "unavailable" }); - expect(reader.readFeedCapabilities).toHaveBeenCalledTimes(callsBefore); - }); - - it("returns missing without starting an operation for absent resources", async () => { - const noOptionalTargets = envelope({ - episodeResources: episodeResourceMap({ - ...episodeResources(), - chaptersUrl: undefined, - }), - }); - delete noOptionalTargets.artworkUrl; - delete noOptionalTargets.episodeResources[episodeId]!.chaptersUrl; - const operation = vi.fn(); - const { runtime } = makeRuntime(availableReader(noOptionalTargets)); - - await expect( - runtime.broker.withFeedResource(feedId, reference, "feed-artwork", operation), - ).resolves.toEqual({ status: "missing" }); - await expect( - runtime.broker.withEpisodeResource( - feedId, - episodeId, - reference, - "episode-chapters", - operation, - ), - ).resolves.toEqual({ status: "missing" }); - await expect( - runtime.broker.withEpisodeResource( - feedId, - otherEpisodeId, - reference, - "episode-stream", - operation, - ), - ).resolves.toEqual({ status: "missing" }); - expect(operation).not.toHaveBeenCalled(); - runtime.dispose(); - }); - - it.each(["missing", "invalid", "unavailable"] as const)( - "preserves reader %s status without starting an operation", - async (status) => { - const operation = vi.fn(); - const { runtime } = makeRuntime(readerWith({ status })); - await expect( - runtime.broker.withFeedResource(feedId, reference, "subscription", operation), - ).resolves.toEqual({ status }); - expect(operation).not.toHaveBeenCalled(); - runtime.dispose(); - }, - ); - - it("rejects invalid handles, kinds, references, operations, and cross-feed data", async () => { - const reader = availableReader(); - const { runtime } = makeRuntime(reader); - const operation = vi.fn(); - await expect( - runtime.broker.withFeedResource("not-a-feed", reference, "subscription", operation), - ).resolves.toEqual({ status: "invalid" }); - await expect( - runtime.broker.withFeedResource(feedId, foreignReference, "subscription", operation), - ).resolves.toEqual({ status: "invalid" }); - await expect( - runtime.broker.withFeedResource( - feedId, - reference, - "episode-stream" as FeedNetworkResourceKind, - operation, - ), - ).resolves.toEqual({ status: "invalid" }); - await expect( - runtime.broker.withEpisodeResource( - feedId, - "not-an-episode", - reference, - "episode-stream", - operation, - ), - ).resolves.toEqual({ status: "invalid" }); - await expect( - runtime.broker.withEpisodeResource( - feedId, - episodeId, - reference, - "subscription" as EpisodeNetworkResourceKind, - operation, - ), - ).resolves.toEqual({ status: "invalid" }); - await expect( - runtime.broker.withFeedResource(feedId, reference, "subscription", null as never), - ).resolves.toEqual({ status: "invalid" }); - expect(reader.readFeedCapabilities).not.toHaveBeenCalled(); - runtime.dispose(); - - const crossFeedReader = availableReader(envelope({ feedId: otherFeedId })); - const crossFeed = makeRuntime(crossFeedReader).runtime; - await expect( - crossFeed.broker.withFeedResource(feedId, reference, "subscription", operation), - ).resolves.toEqual({ status: "invalid" }); - crossFeed.dispose(); - }); - - it("sanitizes reader throws, accessors, proxies, and malformed result shapes", async () => { - const sentinel = "https://secret.example/feed?token=do-not-log"; - const operation = vi.fn(); - const throwingReader: FeedCapabilityReader = { - readFeedCapabilities: vi.fn().mockRejectedValue(new Error(sentinel)), - }; - const throwing = makeRuntime(throwingReader).runtime; - await expect( - throwing.broker.withFeedResource(feedId, reference, "subscription", operation), - ).resolves.toEqual({ status: "unavailable" }); - throwing.dispose(); - - let getterRead = false; - const accessorResult = {}; - Object.defineProperty(accessorResult, "status", { - enumerable: true, - get: () => { - getterRead = true; - throw new Error(sentinel); - }, - }); - const accessor = makeRuntime(readerWith(accessorResult)).runtime; - await expect( - accessor.broker.withFeedResource(feedId, reference, "subscription", operation), - ).resolves.toEqual({ status: "invalid" }); - expect(getterRead).toBe(false); - accessor.dispose(); - - const proxy = makeRuntime( - readerWith( - new Proxy( - {}, - { - ownKeys: () => { - throw new Error(sentinel); - }, - }, - ), - ), - ).runtime; - await expect( - proxy.broker.withFeedResource(feedId, reference, "subscription", operation), - ).resolves.toEqual({ status: "invalid" }); - proxy.dispose(); - }); -}); diff --git a/src/services/FeedCapabilityBroker.ts b/src/services/FeedCapabilityBroker.ts deleted file mode 100644 index 714ae28..0000000 --- a/src/services/FeedCapabilityBroker.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { - createCapabilityScopedNetworkRuntime, - type CapabilityBytesResponse, - type NetworkNameResolver, - type PinnedNetworkHopAdapter, -} from "src/network/CapabilityScopedTransport"; -import { - EPISODE_NETWORK_RESOURCE_KINDS, - FEED_NETWORK_RESOURCE_KINDS, - type EpisodeNetworkResourceKind, - type FeedNetworkResourceKind, - type NetworkCapability, - type NetworkCapabilityScope, -} from "src/network/NetworkCapability"; -import type { NetworkScheduler } from "src/network/NetworkScheduler"; -import { - isFeedCapabilityReferenceFor, - type FeedCapabilityReference, -} from "src/security/feedCapabilityReferences"; -import { - isEpisodeHandle, - isFeedHandle, - type EpisodeHandle, - type FeedHandle, -} from "src/security/resourceHandles"; -import { - validateFeedCapabilityEnvelope, - type FeedCapabilityEnvelope, -} from "src/security/targetEnvelopes"; - -import type { CapabilityReadResult } from "./FeedCapabilityRepository"; - -export interface FeedCapabilityReader { - readFeedCapabilities( - feedId: FeedHandle | string, - reference: unknown, - ): Promise>; -} - -export type IssuedFeedNetworkScope = Readonly<{ - resourceKind: Kind; - feedId: FeedHandle; -}>; - -export type IssuedEpisodeNetworkScope = Readonly<{ - resourceKind: Kind; - feedId: FeedHandle; - episodeId: EpisodeHandle; -}>; - -export interface CapabilityUseContext { - readonly capability: NetworkCapability; - readonly scope: Readonly; -} - -export type CapabilityUseOperation = ( - context: CapabilityUseContext, -) => Value | PromiseLike; - -export type CapabilityUseResult = - | { readonly status: "available"; readonly value: Value } - | { readonly status: "missing" | "invalid" | "unavailable" }; - -type UnavailableStatus = Exclude["status"], "available">; - -export interface FeedCapabilityBroker { - withFeedResource( - feedId: FeedHandle | string, - reference: FeedCapabilityReference | string, - resourceKind: Kind, - operation: CapabilityUseOperation, Value>, - ): Promise>; - withEpisodeResource( - feedId: FeedHandle | string, - episodeId: EpisodeHandle | string, - reference: FeedCapabilityReference | string, - resourceKind: Kind, - operation: CapabilityUseOperation, Value>, - ): Promise>; - invalidateFeed(feedId: FeedHandle | string): number; - invalidateEpisode(feedId: FeedHandle | string, episodeId: EpisodeHandle | string): number; - isDisposed(): boolean; -} - -export interface FeedCapabilityTransport { - getBytes( - capability: NetworkCapability, - expectedScope: Scope, - ): Promise; -} - -export interface FeedCapabilityNetworkRuntime { - readonly broker: FeedCapabilityBroker; - readonly transport: FeedCapabilityTransport; - dispose(): void; -} - -const feedResourceKinds = new Set(FEED_NETWORK_RESOURCE_KINDS); -const episodeResourceKinds = new Set(EPISODE_NETWORK_RESOURCE_KINDS); - -const feedTargetFields = Object.freeze({ - subscription: "subscriptionUrl", - "feed-artwork": "artworkUrl", - site: "siteUrl", -} as const satisfies Record); - -const episodeTargetFields = Object.freeze({ - "episode-stream": "streamUrl", - "episode-chapters": "chaptersUrl", - "episode-artwork": "artworkUrl", - "episode-item-link": "itemLink", -} as const); - -const unavailableResults = Object.freeze({ - missing: Object.freeze({ status: "missing" as const }), - invalid: Object.freeze({ status: "invalid" as const }), - unavailable: Object.freeze({ status: "unavailable" as const }), -}); - -const missingProperty = Symbol("missing-property"); -const leaseCancelled = Symbol("lease-cancelled"); -const brokerConstructionToken = Object.freeze(Object.create(null) as object); - -interface BrokerOperationLease { - readonly scope: NetworkCapabilityScope; - readonly cancellation: Promise; - readonly resolveCancellation: () => void; - capability?: NetworkCapability; - cancelled: boolean; -} - -function unavailableResult(status: UnavailableStatus): CapabilityUseResult { - return unavailableResults[status]; -} - -function getOwnDataValue(record: object, property: string): unknown | typeof missingProperty { - const descriptor = Object.getOwnPropertyDescriptor(record, property); - return descriptor && "value" in descriptor ? descriptor.value : missingProperty; -} - -function snapshotReadResult( - value: unknown, - feedId: FeedHandle, -): CapabilityReadResult { - try { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return unavailableResults.invalid; - } - const keys = Reflect.ownKeys(value); - if (keys.some((key) => typeof key !== "string")) return unavailableResults.invalid; - const status = getOwnDataValue(value, "status"); - if (status === "available") { - if (keys.length !== 2 || !keys.includes("status") || !keys.includes("value")) { - return unavailableResults.invalid; - } - const envelope = validateFeedCapabilityEnvelope( - getOwnDataValue(value, "value"), - feedId, - ); - return envelope ? { status: "available", value: envelope } : unavailableResults.invalid; - } - if ( - (status === "missing" || status === "invalid" || status === "unavailable") && - keys.length === 1 && - keys[0] === "status" - ) { - return unavailableResults[status]; - } - return unavailableResults.invalid; - } catch { - return unavailableResults.invalid; - } -} - -class FeedCapabilityBrokerImplementation implements FeedCapabilityBroker { - readonly #reader: FeedCapabilityReader; - readonly #issuer: ReturnType["issuer"]; - readonly #leases = new Set(); - #disposed = false; - - constructor( - constructionToken: object, - reader: FeedCapabilityReader, - issuer: ReturnType["issuer"], - ) { - if (constructionToken !== brokerConstructionToken) { - throw new TypeError("Feed capability brokers require trusted composition."); - } - if (!reader || typeof reader.readFeedCapabilities !== "function") { - throw new TypeError("A feed capability reader is required."); - } - this.#reader = reader; - this.#issuer = issuer; - Object.freeze(this); - } - - async withFeedResource( - feedId: FeedHandle | string, - reference: FeedCapabilityReference | string, - resourceKind: Kind, - operation: CapabilityUseOperation, Value>, - ): Promise> { - if ( - this.#disposed || - !isFeedHandle(feedId) || - !isFeedCapabilityReferenceFor(feedId, reference) || - !feedResourceKinds.has(resourceKind) || - typeof operation !== "function" - ) { - return unavailableResult(this.#disposed ? "unavailable" : "invalid"); - } - - const scope = Object.freeze({ resourceKind, feedId }) as IssuedFeedNetworkScope; - const lease = this.#beginLease(scope); - if (!lease) return unavailableResult("unavailable"); - try { - const loaded = await this.#readFeedForLease(lease, feedId, reference); - if (loaded === leaseCancelled) return unavailableResult("unavailable"); - if (loaded.status !== "available") return unavailableResult(loaded.status); - const target = loaded.value[feedTargetFields[resourceKind]]; - if (typeof target !== "string") return unavailableResult("missing"); - - return await this.#withCapability( - lease, - target, - loaded.value.privateGrants?.[resourceKind], - operation, - ); - } finally { - this.#finishLease(lease); - } - } - - async withEpisodeResource( - feedId: FeedHandle | string, - episodeId: EpisodeHandle | string, - reference: FeedCapabilityReference | string, - resourceKind: Kind, - operation: CapabilityUseOperation, Value>, - ): Promise> { - if ( - this.#disposed || - !isFeedHandle(feedId) || - !isEpisodeHandle(episodeId) || - !isFeedCapabilityReferenceFor(feedId, reference) || - !episodeResourceKinds.has(resourceKind) || - typeof operation !== "function" - ) { - return unavailableResult(this.#disposed ? "unavailable" : "invalid"); - } - - const scope = Object.freeze({ - resourceKind, - feedId, - episodeId, - }) as IssuedEpisodeNetworkScope; - const lease = this.#beginLease(scope); - if (!lease) return unavailableResult("unavailable"); - try { - const loaded = await this.#readFeedForLease(lease, feedId, reference); - if (loaded === leaseCancelled) return unavailableResult("unavailable"); - if (loaded.status !== "available") return unavailableResult(loaded.status); - const episode = loaded.value.episodeResources[episodeId]; - if (!episode) return unavailableResult("missing"); - const target = episode[episodeTargetFields[resourceKind]]; - if (typeof target !== "string") return unavailableResult("missing"); - - return await this.#withCapability( - lease, - target, - loaded.value.privateGrants?.[resourceKind], - operation, - ); - } finally { - this.#finishLease(lease); - } - } - - invalidateFeed(feedId: FeedHandle | string): number { - return !this.#disposed && isFeedHandle(feedId) - ? this.#cancelMatching((scope) => scope.feedId === feedId) - : 0; - } - - invalidateEpisode(feedId: FeedHandle | string, episodeId: EpisodeHandle | string): number { - return !this.#disposed && isFeedHandle(feedId) && isEpisodeHandle(episodeId) - ? this.#cancelMatching( - (scope) => - scope.feedId === feedId && - "episodeId" in scope && - scope.episodeId === episodeId, - ) - : 0; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#cancelMatching(() => true); - } - - isDisposed(): boolean { - return this.#disposed; - } - - async #withCapability( - lease: BrokerOperationLease & { readonly scope: Scope }, - target: string, - privateOrigins: readonly string[] | undefined, - operation: CapabilityUseOperation, - ): Promise> { - if (this.#disposed || lease.cancelled) return unavailableResult("unavailable"); - const capability = this.#issuer.issue(lease.scope, target, privateOrigins); - lease.capability = capability; - try { - const outcome = await Promise.race([ - Promise.resolve().then(() => - operation(Object.freeze({ capability, scope: lease.scope })), - ), - lease.cancellation, - ]); - if (outcome === leaseCancelled) return unavailableResult("unavailable"); - return this.#disposed || lease.cancelled - ? unavailableResult("unavailable") - : Object.freeze({ status: "available", value: outcome }); - } catch (error) { - if (this.#disposed || lease.cancelled) return unavailableResult("unavailable"); - throw error; - } - } - - #beginLease( - scope: Scope, - ): (BrokerOperationLease & { readonly scope: Scope }) | null { - if (this.#disposed) return null; - let resolveCancellation!: () => void; - const cancellation = new Promise((resolve) => { - resolveCancellation = () => resolve(leaseCancelled); - }); - const lease = { - scope, - cancellation, - resolveCancellation, - cancelled: false, - } as BrokerOperationLease & { readonly scope: Scope }; - this.#leases.add(lease); - return lease; - } - - #finishLease(lease: BrokerOperationLease): void { - this.#leases.delete(lease); - if (lease.capability) this.#issuer.revoke(lease.capability); - } - - #cancelMatching(predicate: (scope: NetworkCapabilityScope) => boolean): number { - let revoked = 0; - for (const lease of this.#leases) { - if (lease.cancelled || !predicate(lease.scope)) continue; - lease.cancelled = true; - if (lease.capability && this.#issuer.revoke(lease.capability)) revoked += 1; - lease.resolveCancellation(); - } - return revoked; - } - - async #readFeedForLease( - lease: BrokerOperationLease, - feedId: FeedHandle, - reference: FeedCapabilityReference, - ): Promise | typeof leaseCancelled> { - return Promise.race([this.#readFeed(feedId, reference), lease.cancellation]); - } - - async #readFeed( - feedId: FeedHandle, - reference: FeedCapabilityReference, - ): Promise> { - try { - return snapshotReadResult( - await this.#reader.readFeedCapabilities(feedId, reference), - feedId, - ); - } catch { - return unavailableResults.unavailable; - } - } -} - -/** - * Trusted composition boundary. The low-level issuer and resolver never leave - * this factory; callers receive only one-shot broker operations and the opaque - * transport needed inside those operations. - */ -export function createFeedCapabilityNetworkRuntime( - reader: FeedCapabilityReader, - nameResolver: NetworkNameResolver, - adapter: PinnedNetworkHopAdapter, - scheduler: NetworkScheduler, -): FeedCapabilityNetworkRuntime { - const network = createCapabilityScopedNetworkRuntime(nameResolver, adapter, scheduler); - let broker: FeedCapabilityBrokerImplementation; - try { - broker = new FeedCapabilityBrokerImplementation( - brokerConstructionToken, - reader, - network.issuer, - ); - } catch (error) { - network.dispose(); - throw error; - } - const brokerFacade = Object.create(null) as FeedCapabilityBroker; - Object.defineProperties(brokerFacade, { - withFeedResource: { value: broker.withFeedResource.bind(broker) }, - withEpisodeResource: { value: broker.withEpisodeResource.bind(broker) }, - invalidateFeed: { value: broker.invalidateFeed.bind(broker) }, - invalidateEpisode: { value: broker.invalidateEpisode.bind(broker) }, - isDisposed: { value: broker.isDisposed.bind(broker) }, - }); - Object.freeze(brokerFacade); - - const transportFacade = Object.create(null) as FeedCapabilityTransport; - Object.defineProperty(transportFacade, "getBytes", { - value: network.transport.getBytes.bind(network.transport), - }); - Object.freeze(transportFacade); - - let disposed = false; - return Object.freeze({ - broker: brokerFacade, - transport: transportFacade, - dispose(): void { - if (disposed) return; - disposed = true; - broker.dispose(); - network.dispose(); - }, - }); -} diff --git a/src/services/FeedCapabilityRepository.test.ts b/src/services/FeedCapabilityRepository.test.ts deleted file mode 100644 index da9e5e2..0000000 --- a/src/services/FeedCapabilityRepository.test.ts +++ /dev/null @@ -1,605 +0,0 @@ -import type { SecretStorage } from "obsidian"; -import { describe, expect, it, vi } from "vitest"; -import type { - EpisodeResourcesEnvelope, - FeedCapabilityEnvelope, -} from "src/security/targetEnvelopes"; -import type { EpisodeHandle, FeedHandle } from "src/security/resourceHandles"; -import { - MAX_PHYSICAL_PAGE_INDEX, - encodeFeedCapabilityPageIndex, - serializePhysicalItem, -} from "src/security/feedCapabilityStorage"; -import { - feedCapabilityReferenceForAttempt, - getFeedCapabilityReferenceBase, -} from "src/security/feedCapabilityReferences"; -import { - FeedCapabilityRepository, - MAX_SECRET_STORAGE_ID_CHARACTERS, - MAX_SECRET_STORAGE_ITEM_BYTES, - isFeedCapabilityReferenceFor, -} from "./FeedCapabilityRepository"; - -const feedId = `podnotes-feed-${"11".repeat(32)}` as FeedHandle; -const otherFeedId = `podnotes-feed-${"22".repeat(32)}` as FeedHandle; -const episodeId = `podnotes-episode-${"33".repeat(32)}` as EpisodeHandle; -const otherEpisodeId = `podnotes-episode-${"44".repeat(32)}` as EpisodeHandle; -const referenceBase = getFeedCapabilityReferenceBase(feedId)!; - -const bundle: FeedCapabilityEnvelope = { - schemaVersion: 1, - kind: "feed-capability-bundle", - feedId, - subscriptionUrl: "https://listener:secret@example.com/feed.xml?token=value", - guid: "podcast:channel:opaque-guid", - episodeResources: { - [episodeId]: { - schemaVersion: 1, - kind: "episode-resources", - feedId, - episodeId, - streamUrl: "https://media.example.com/audio.mp3?signature=value", - }, - }, -}; - -type SetAction = "throw-before" | "throw-after" | undefined; - -function memoryStorage(initial: Record = {}) { - const values = new Map(Object.entries(initial)); - let getUnavailable = false; - let setInterceptor: ((id: string, value: string) => SetAction) | undefined; - const storage = { - getSecret: vi.fn((id: string) => { - if (getUnavailable) throw new Error("SecretStorage unavailable"); - return values.get(id) ?? null; - }), - setSecret: vi.fn((id: string, value: string) => { - const action = setInterceptor?.(id, value); - if (action === "throw-before") throw new Error("injected write failure"); - values.set(id, value); - if (action === "throw-after") throw new Error("injected write failure"); - }), - listSecrets: vi.fn(() => [...values.keys()]), - } as unknown as SecretStorage; - - return { - values, - storage, - setGetUnavailable(value: boolean) { - getUnavailable = value; - }, - setSetInterceptor(value?: (id: string, serialized: string) => SetAction) { - setInterceptor = value; - }, - }; -} - -function parseItem(values: Map, id: string): Record { - return JSON.parse(values.get(id)!) as Record; -} - -function manifestAt(values: Map, reference: string, slot: "a" | "b") { - const serialized = values.get(`${reference}-${slot}m`); - return serialized ? (JSON.parse(serialized) as any) : null; -} - -function highestManifest(values: Map, reference: string) { - return [manifestAt(values, reference, "a"), manifestAt(values, reference, "b")] - .filter(Boolean) - .sort((left, right) => right.generation - left.generation)[0]; -} - -function pageKey(reference: string, descriptor: { index: string; slot: "a" | "b" }) { - return `${reference}-${descriptor.slot}${descriptor.index}`; -} - -function physicalKind(value: string): string | undefined { - try { - const parsed = JSON.parse(value) as { kind?: unknown }; - return typeof parsed.kind === "string" ? parsed.kind : undefined; - } catch { - return undefined; - } -} - -function updatedBundle(): FeedCapabilityEnvelope { - return { ...bundle, siteUrl: "https://example.com/updated" }; -} - -function storeAt( - repository: FeedCapabilityRepository, - value: unknown, - reference: unknown = referenceBase, - expectedFeedId: FeedHandle | string = feedId, -): Promise { - return repository.storeFeedCapabilitiesAt(expectedFeedId, reference, value); -} - -function twoEpisodeBundle(): FeedCapabilityEnvelope { - return { - ...bundle, - episodeResources: { - ...bundle.episodeResources, - [otherEpisodeId]: { - schemaVersion: 1, - kind: "episode-resources", - feedId, - episodeId: otherEpisodeId, - guid: "durable-nonplayable-episode", - }, - }, - }; -} - -function largeSplitBundle(): FeedCapabilityEnvelope { - const episodeResources: Partial> = {}; - for (let index = 0; index < 16; index += 1) { - const hex = `a${index.toString(16)}${index.toString(16).padStart(62, "0")}`; - const id = `podnotes-episode-${hex}` as EpisodeHandle; - episodeResources[id] = { - schemaVersion: 1, - kind: "episode-resources", - feedId, - episodeId: id, - streamUrl: `https://media.example.com/${"x".repeat(14_000)}${index}`, - }; - } - return { ...bundle, episodeResources }; -} - -describe("FeedCapabilityRepository", () => { - it("rejects values that JSON cannot serialize into a physical item", () => { - expect(serializePhysicalItem(undefined)).toBeNull(); - expect(serializePhysicalItem(Symbol("unsupported"))).toBeNull(); - }); - - it("stores and reads a small feed bundle through one feed-bound namespace", async () => { - const { storage, values } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - - const reference = await storeAt(repository, bundle); - - expect(isFeedCapabilityReferenceFor(feedId, reference)).toBe(true); - expect(parseItem(values, reference).kind).toBe("feed-capability-namespace"); - expect(highestManifest(values, reference).generation).toBe(1); - expect(await repository.getFeedCapabilities(feedId, reference)).toEqual(bundle); - expect(await repository.getEpisodeResources(feedId, episodeId, reference)).toEqual( - bundle.episodeResources[episodeId], - ); - expect(await repository.feedCapabilitiesStatus(feedId, reference)).toBe("available"); - expect(await repository.episodeResourcesStatus(feedId, episodeId, reference)).toBe( - "available", - ); - }); - - it("provisions the same persisted suffix across independent SecretStorage instances", async () => { - const reference = feedCapabilityReferenceForAttempt(feedId, 2)!; - const first = memoryStorage(); - const second = memoryStorage(); - const firstRepository = new FeedCapabilityRepository(first.storage); - const secondRepository = new FeedCapabilityRepository(second.storage); - - expect(await storeAt(firstRepository, bundle, reference)).toBe(reference); - expect(await storeAt(secondRepository, bundle, reference)).toBe(reference); - expect(first.values.has(referenceBase)).toBe(false); - expect(second.values.has(referenceBase)).toBe(false); - expect(await firstRepository.getFeedCapabilities(feedId, reference)).toEqual(bundle); - expect(await secondRepository.getFeedCapabilities(feedId, reference)).toEqual(bundle); - }); - - it("rejects a mismatched feed or reference before SecretStorage access", async () => { - const { storage } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const otherReference = feedCapabilityReferenceForAttempt(otherFeedId, 1)!; - - await expect( - repository.storeFeedCapabilitiesAt(otherFeedId, otherReference, bundle), - ).rejects.toThrow("Invalid feed capability envelope"); - await expect( - repository.storeFeedCapabilitiesAt(feedId, otherReference, bundle), - ).rejects.toThrow("Invalid feed capability binding"); - expect(storage.getSecret).not.toHaveBeenCalled(); - expect(storage.setSecret).not.toHaveBeenCalled(); - }); - - it("retries a partially reserved exact namespace without reallocating", async () => { - const { storage, values } = memoryStorage(); - const failing = new FeedCapabilityRepository(storage, { - validatePhysicalManifest: () => null, - }); - - await expect(storeAt(failing, bundle)).rejects.toThrow( - "SecretStorage could not commit feed capabilities", - ); - expect(parseItem(values, referenceBase).kind).toBe("feed-capability-namespace"); - - const retry = new FeedCapabilityRepository(storage); - expect(await storeAt(retry, bundle)).toBe(referenceBase); - expect(await retry.getFeedCapabilities(feedId, referenceBase)).toEqual(bundle); - }); - - it("accepts an ambiguous exact namespace write when readback proves success", async () => { - const harness = memoryStorage(); - const repository = new FeedCapabilityRepository(harness.storage); - let injected = false; - harness.setSetInterceptor((id, value) => { - if ( - !injected && - id === referenceBase && - physicalKind(value) === "feed-capability-namespace" - ) { - injected = true; - return "throw-after"; - } - return undefined; - }); - - expect(await storeAt(repository, bundle)).toBe(referenceBase); - expect(await repository.getFeedCapabilities(feedId, referenceBase)).toEqual(bundle); - }); - - it("performs an exact-content no-op without rewriting any item", async () => { - const { storage } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const reference = await storeAt(repository, bundle); - vi.mocked(storage.setSecret).mockClear(); - - expect(await storeAt(repository, bundle)).toBe(reference); - expect(storage.setSecret).not.toHaveBeenCalled(); - }); - - it("rejects an impossible physical plan before writing any page or manifest", async () => { - const { storage, values } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage, { - validatePhysicalManifest: () => null, - }); - - await expect(storeAt(repository, bundle)).rejects.toThrow( - "SecretStorage could not commit feed capabilities", - ); - - expect(storage.setSecret).toHaveBeenCalledTimes(1); - expect([...values.values()].map(physicalKind)).toEqual(["feed-capability-namespace"]); - expect([...values.keys()].some((id) => /-[ab](?:m|[0-9a-z]{3})$/.test(id))).toBe(false); - }); - - it("serializes concurrent commits for the same feed", async () => { - const { storage, values } = memoryStorage(); - const firstRepository = new FeedCapabilityRepository(storage); - const secondRepository = new FeedCapabilityRepository(storage); - const reference = await storeAt(firstRepository, bundle); - const second = { ...bundle, siteUrl: "https://example.com/concurrent-second" }; - const third = { ...bundle, siteUrl: "https://example.com/concurrent-third" }; - - const references = await Promise.all([ - storeAt(firstRepository, second), - storeAt(secondRepository, third), - ]); - - expect(references).toEqual([reference, reference]); - expect(highestManifest(values, reference).generation).toBe(3); - expect(await firstRepository.getFeedCapabilities(feedId, reference)).toEqual(third); - }); - - it("reads a synchronous multi-page snapshot while two later generations commit", async () => { - const { storage } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const initial = largeSplitBundle(); - const reference = await storeAt(repository, initial); - - const read = repository.readFeedCapabilities(feedId, reference); - const commits = Promise.all([ - storeAt(repository, { ...initial, siteUrl: "https://example.com/second" }), - storeAt(repository, { ...initial, siteUrl: "https://example.com/third" }), - ]); - - expect(await read).toEqual({ status: "available", value: initial }); - await commits; - expect(await repository.getFeedCapabilities(feedId, reference)).toEqual({ - ...initial, - siteUrl: "https://example.com/third", - }); - }); - - it("alternates slots while retaining the previous complete generation", async () => { - const { storage, values } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const reference = await storeAt(repository, bundle); - const first = highestManifest(values, reference); - - await storeAt(repository, updatedBundle()); - const second = highestManifest(values, reference); - - expect(first.generation).toBe(1); - expect(second.generation).toBe(2); - expect(first.pages[0].slot).toBe("a"); - expect(second.pages[0].slot).toBe("b"); - expect(values.get(pageKey(reference, first.pages[0]))).not.toBe(""); - expect(values.get(pageKey(reference, second.pages[0]))).not.toBe(""); - expect(await repository.getFeedCapabilities(feedId, reference)).toEqual(updatedBundle()); - }); - - it("recursively splits an oversized first-nibble bucket", async () => { - const { storage, values } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const value = largeSplitBundle(); - - const reference = await storeAt(repository, value); - const manifest = highestManifest(values, reference); - - expect(manifest.pages).toHaveLength(16); - expect(manifest.pages.every((page: any) => /^a[0-9a-f]$/.test(page.bucket))).toBe(true); - expect(manifest.pages.map((page: any) => page.index)).toEqual( - Array.from({ length: 16 }, (_, index) => index.toString(36).padStart(3, "0")), - ); - expect(await repository.getFeedCapabilities(feedId, reference)).toEqual(value); - }); - - it.each(["page", "manifest"] as const)( - "preserves the prior generation after an injected %s write failure", - async (kind) => { - const harness = memoryStorage(); - const repository = new FeedCapabilityRepository(harness.storage); - const reference = await storeAt(repository, bundle); - let injected = false; - harness.setSetInterceptor((id, value) => { - if ( - !injected && - id.length <= MAX_SECRET_STORAGE_ID_CHARACTERS && - physicalKind(value) === `feed-capability-${kind}` - ) { - injected = true; - return "throw-before"; - } - return undefined; - }); - - await expect(storeAt(repository, updatedBundle())).rejects.toThrow( - "SecretStorage could not commit feed capabilities", - ); - harness.setSetInterceptor(); - - expect(await repository.getFeedCapabilities(feedId, reference)).toEqual(bundle); - expect(highestManifest(harness.values, reference).generation).toBe(1); - }, - ); - - it.each(["page", "manifest"] as const)( - "accepts an ambiguous %s throw when exact readback proves the write succeeded", - async (kind) => { - const harness = memoryStorage(); - const repository = new FeedCapabilityRepository(harness.storage); - const reference = await storeAt(repository, bundle); - let injected = false; - harness.setSetInterceptor((id, value) => { - if ( - !injected && - id.length <= MAX_SECRET_STORAGE_ID_CHARACTERS && - physicalKind(value) === `feed-capability-${kind}` - ) { - injected = true; - return "throw-after"; - } - return undefined; - }); - - expect(await storeAt(repository, updatedBundle())).toBe(reference); - harness.setSetInterceptor(); - expect(await repository.getFeedCapabilities(feedId, reference)).toEqual( - updatedBundle(), - ); - expect(highestManifest(harness.values, reference).generation).toBe(2); - }, - ); - - it.each(["page", "manifest"] as const)( - "keeps generation two readable when a generation-three %s write fails", - async (kind) => { - const harness = memoryStorage(); - const repository = new FeedCapabilityRepository(harness.storage); - const firstBundle = twoEpisodeBundle(); - await storeAt(repository, firstBundle); - const secondBundle = { - ...firstBundle, - siteUrl: "https://example.com/second", - }; - const reference = await storeAt(repository, secondBundle); - let injected = false; - harness.setSetInterceptor((id, value) => { - if ( - !injected && - id.length <= MAX_SECRET_STORAGE_ID_CHARACTERS && - physicalKind(value) === `feed-capability-${kind}` - ) { - injected = true; - return "throw-before"; - } - return undefined; - }); - - await expect( - storeAt(repository, { - ...bundle, - siteUrl: "https://example.com/third", - }), - ).rejects.toThrow("SecretStorage could not commit feed capabilities"); - harness.setSetInterceptor(); - expect(await repository.getFeedCapabilities(feedId, reference)).toEqual(secondBundle); - expect(highestManifest(harness.values, reference).generation).toBe(2); - }, - ); - - it("rejects page digest corruption", async () => { - const { storage, values } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const reference = await storeAt(repository, bundle); - const manifest = highestManifest(values, reference); - const key = pageKey(reference, manifest.pages[0]); - const page = parseItem(values, key) as any; - page.episodeResources[episodeId].streamUrl = "https://tampered.example/audio.mp3"; - values.set(key, JSON.stringify(page)); - - expect(await repository.getFeedCapabilities(feedId, reference)).toBeNull(); - expect(await repository.feedCapabilitiesStatus(feedId, reference)).toBe("invalid"); - }); - - it("does not silently roll back from a higher incomplete generation", async () => { - const { storage, values } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const reference = await storeAt(repository, bundle); - const first = highestManifest(values, reference); - values.set( - `${reference}-bm`, - JSON.stringify({ ...first, generation: first.generation + 1 }), - ); - - expect(await repository.getFeedCapabilities(feedId, reference)).toBeNull(); - expect(await repository.feedCapabilitiesStatus(feedId, reference)).toBe("invalid"); - }); - - it("rejects divergent manifests at the same generation", async () => { - const { storage, values } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const reference = await storeAt(repository, bundle); - const first = highestManifest(values, reference); - values.set(`${reference}-bm`, JSON.stringify({ ...first, contentDigest: "f".repeat(64) })); - - expect(await repository.feedCapabilitiesStatus(feedId, reference)).toBe("invalid"); - }); - - it("distinguishes missing, unavailable, and invalid physical state", async () => { - const harness = memoryStorage(); - const repository = new FeedCapabilityRepository(harness.storage); - - expect(await repository.readFeedCapabilities(feedId, referenceBase)).toEqual({ - status: "missing", - }); - harness.values.set(referenceBase, "not-json"); - expect(await repository.readFeedCapabilities(feedId, referenceBase)).toEqual({ - status: "invalid", - }); - harness.setGetUnavailable(true); - expect(await repository.readFeedCapabilities(feedId, referenceBase)).toEqual({ - status: "unavailable", - }); - }); - - it("rejects cross-feed and foreign references before SecretStorage access", async () => { - const { storage } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const reference = await storeAt(repository, bundle); - vi.mocked(storage.getSecret).mockClear(); - vi.mocked(storage.getSecret).mockImplementation(() => { - throw new Error("must not read"); - }); - - expect(await repository.getFeedCapabilities(otherFeedId, reference)).toBeNull(); - expect(await repository.getFeedCapabilities(feedId, "foreign")).toBeNull(); - expect(await repository.getEpisodeResources(feedId, "malformed", reference)).toBeNull(); - expect(storage.getSecret).not.toHaveBeenCalled(); - }); - - it("bounds every nonblank physical SecretStorage item", async () => { - const { storage, values } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - - await storeAt(repository, largeSplitBundle()); - - for (const [id, serialized] of values) { - expect(id).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); - expect(id.length).toBeLessThanOrEqual(MAX_SECRET_STORAGE_ID_CHARACTERS); - if (!serialized) continue; - expect(new TextEncoder().encode(serialized).byteLength).toBeLessThanOrEqual( - MAX_SECRET_STORAGE_ITEM_BYTES, - ); - } - expect(MAX_SECRET_STORAGE_ITEM_BYTES).toBe(128 * 1024); - }); - - it("keeps maximum suffix and page-index IDs within the live 64-character limit", () => { - const reference = feedCapabilityReferenceForAttempt(feedId, 999)!; - const index = encodeFeedCapabilityPageIndex(MAX_PHYSICAL_PAGE_INDEX)!; - const manifestId = `${reference}-am`; - const pageId = `${reference}-a${index}`; - - expect(reference).toHaveLength(59); - expect(index).toBe("zzz"); - expect(encodeFeedCapabilityPageIndex(MAX_PHYSICAL_PAGE_INDEX + 1)).toBeNull(); - expect(manifestId).toHaveLength(62); - expect(pageId).toHaveLength(64); - expect([manifestId, pageId].every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe( - true, - ); - }); - - it("reuses inactive slots and blanks only stale pages from the overwritten generation", async () => { - const { storage, values } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const firstBundle = twoEpisodeBundle(); - const reference = await storeAt(repository, firstBundle); - const first = highestManifest(values, reference); - await storeAt(repository, { - ...firstBundle, - siteUrl: "https://example.com/second", - }); - const second = highestManifest(values, reference); - const firstKept = first.pages.find((page: any) => page.bucket === "3"); - const firstStale = first.pages.find((page: any) => page.bucket === "4"); - const secondStale = second.pages.find((page: any) => page.bucket === "4"); - - expect(values.get(pageKey(reference, firstKept))).not.toBe(""); - expect(values.get(pageKey(reference, firstStale))).not.toBe(""); - await storeAt(repository, { ...bundle, siteUrl: "https://example.com/third" }); - const third = highestManifest(values, reference); - const thirdKept = third.pages.find((page: any) => page.bucket === "3"); - - expect(third.generation).toBe(3); - expect(thirdKept.slot).toBe(firstKept.slot); - expect(values.get(pageKey(reference, thirdKept))).not.toBe(""); - expect(values.get(pageKey(reference, firstStale))).toBe(""); - expect(values.get(pageKey(reference, secondStale))).not.toBe(""); - }); - - it("fails closed on a foreign exact namespace without trying another suffix", async () => { - const foreignMarker = JSON.stringify({ - schemaVersion: 1, - kind: "feed-capability-namespace", - feedId: otherFeedId, - }); - const { storage, values } = memoryStorage({ [referenceBase]: foreignMarker }); - const repository = new FeedCapabilityRepository(storage); - - await expect(storeAt(repository, bundle)).rejects.toThrow( - "Stored feed capability namespace is invalid", - ); - - expect(values.get(referenceBase)).toBe(foreignMarker); - expect(values.has(`${referenceBase}-2`)).toBe(false); - }); - - it("uses the requested suffix exactly even when the base is occupied", async () => { - const reference = feedCapabilityReferenceForAttempt(feedId, 2)!; - const { storage, values } = memoryStorage({ [referenceBase]: "occupied" }); - const repository = new FeedCapabilityRepository(storage); - - expect(await storeAt(repository, bundle, reference)).toBe(reference); - expect(values.get(referenceBase)).toBe("occupied"); - expect(await repository.getFeedCapabilities(feedId, reference)).toEqual(bundle); - }); - - it("reports a durable episode absent from the bundle as missing", async () => { - const { storage } = memoryStorage(); - const repository = new FeedCapabilityRepository(storage); - const reference = await storeAt(repository, bundle); - - expect(await repository.getEpisodeResources(feedId, otherEpisodeId, reference)).toBeNull(); - expect(await repository.readEpisodeResources(feedId, otherEpisodeId, reference)).toEqual({ - status: "missing", - }); - expect(await repository.episodeResourcesStatus(feedId, otherEpisodeId, reference)).toBe( - "missing", - ); - }); -}); diff --git a/src/services/FeedCapabilityRepository.ts b/src/services/FeedCapabilityRepository.ts deleted file mode 100644 index 56b3b2f..0000000 --- a/src/services/FeedCapabilityRepository.ts +++ /dev/null @@ -1,574 +0,0 @@ -import type { SecretStorage } from "obsidian"; -import { - getFeedCapabilityManifestStorageId, - getFeedCapabilityPageStorageId, - isFeedCapabilityReferenceFor, - type FeedCapabilityReference, -} from "src/security/feedCapabilityReferences"; -import { - FEED_CAPABILITY_STORAGE_SCHEMA_VERSION, - MAX_SECRET_STORAGE_ITEM_BYTES, - makeManifest, - preparePages, - reconstructEnvelope, - serializePhysicalItem, - serializedByteLength, - sha256Hex, - validateManifest, - validateNamespaceMarker, - validatePage, - type FeedCapabilityManifest, - type FeedCapabilityNamespaceMarker, - type FeedCapabilityPage, - type FeedCapabilityPageDescriptor, - type PhysicalSlot, -} from "src/security/feedCapabilityStorage"; -import { - validateFeedCapabilityEnvelope, - type EpisodeResourcesEnvelope, - type FeedCapabilityEnvelope, -} from "src/security/targetEnvelopes"; -import { isEpisodeHandle, type EpisodeHandle, type FeedHandle } from "src/security/resourceHandles"; - -export { MAX_SECRET_STORAGE_ITEM_BYTES } from "src/security/feedCapabilityStorage"; -export { - MAX_SECRET_STORAGE_ID_CHARACTERS, - isFeedCapabilityReferenceFor, - type FeedCapabilityReference, -} from "src/security/feedCapabilityReferences"; - -export type CapabilityStatus = "available" | "missing" | "invalid" | "unavailable"; - -export type CapabilityReadResult = - | { status: "available"; value: T } - | { status: Exclude }; - -type ReadResult = CapabilityReadResult; - -interface LoadedGeneration { - bundle: FeedCapabilityEnvelope; - manifest: FeedCapabilityManifest; - manifestSlot: PhysicalSlot; -} - -interface LoadedManifestSlot { - manifest: FeedCapabilityManifest; - serialized: string; - slot: PhysicalSlot; -} - -export type FeedCapabilityCommitRegistry = WeakMap>>; - -export interface FeedCapabilityRepositoryOptions { - commitRegistry?: FeedCapabilityCommitRegistry; - validatePhysicalManifest?: typeof validateManifest; -} - -const COMMIT_REGISTRY_SYMBOL = Symbol.for("podnotes.feed-capability-commit-registry.v1"); - -function globalCommitRegistry(): FeedCapabilityCommitRegistry { - // oxlint-disable-next-line obsidianmd/no-global-this -- Symbol.for keeps the in-flight commit registry stable across plugin hot reloads in the same runtime. - const host = globalThis as unknown as { [key: symbol]: unknown }; - const existing = host[COMMIT_REGISTRY_SYMBOL]; - if (existing instanceof WeakMap) return existing as FeedCapabilityCommitRegistry; - const registry: FeedCapabilityCommitRegistry = new WeakMap(); - host[COMMIT_REGISTRY_SYMBOL] = registry; - return registry; -} - -export class FeedCapabilityRepositoryError extends Error { - constructor(message: string) { - super(message); - this.name = "FeedCapabilityRepositoryError"; - } -} - -function manifestKey(reference: string, slot: PhysicalSlot): string { - const id = getFeedCapabilityManifestStorageId(reference, slot); - if (!id) throw new Error("Invalid SecretStorage ID"); - return id; -} - -function pageKey(reference: string, index: string, slot: PhysicalSlot): string { - const id = getFeedCapabilityPageStorageId(reference, slot, index); - if (!id) throw new Error("Invalid SecretStorage ID"); - return id; -} - -function inactiveSlot(active?: PhysicalSlot): PhysicalSlot { - return active === "a" ? "b" : "a"; -} - -function parseBoundedItem(serialized: string, validate: (value: unknown) => T | null): T | null { - if ( - serialized.length > MAX_SECRET_STORAGE_ITEM_BYTES || - serializedByteLength(serialized) > MAX_SECRET_STORAGE_ITEM_BYTES - ) { - return null; - } - try { - return validate(JSON.parse(serialized)); - } catch { - return null; - } -} - -function markerFor(feedId: FeedHandle): FeedCapabilityNamespaceMarker { - return { - schemaVersion: FEED_CAPABILITY_STORAGE_SCHEMA_VERSION, - kind: "feed-capability-namespace", - feedId, - }; -} - -function manifestsDiverge(left: LoadedManifestSlot, right: LoadedManifestSlot): boolean { - return JSON.stringify(left.manifest) !== JSON.stringify(right.manifest); -} - -export class FeedCapabilityRepository { - private readonly commitQueues: Map>; - private readonly validatePhysicalManifest: typeof validateManifest; - - constructor( - private readonly storage: SecretStorage, - options: FeedCapabilityRepositoryOptions = {}, - ) { - const registry = options.commitRegistry ?? globalCommitRegistry(); - const existing = registry.get(storage); - this.commitQueues = existing ?? new Map(); - if (!existing) registry.set(storage, this.commitQueues); - this.validatePhysicalManifest = options.validatePhysicalManifest ?? validateManifest; - } - - /** - * Create, update, or reconstruct a feed capability at the persisted reference. - * - * The v3 library is the only authority for the binding. This repository never - * allocates a replacement reference because doing so would leave synced data - * pointing at a capability that exists only on another device. - */ - async storeFeedCapabilitiesAt( - feedId: FeedHandle | string, - reference: unknown, - value: unknown, - ): Promise { - if (!isFeedCapabilityReferenceFor(feedId, reference)) { - throw new FeedCapabilityRepositoryError("Invalid feed capability binding."); - } - const envelope = validateFeedCapabilityEnvelope(value, feedId); - if (!envelope) { - throw new FeedCapabilityRepositoryError("Invalid feed capability envelope."); - } - - return this.withFeedCommit(envelope.feedId, async () => { - this.reserveExactNamespace(envelope.feedId, reference); - return this.storeValidatedEnvelope(envelope, reference); - }); - } - - private async storeValidatedEnvelope( - envelope: FeedCapabilityEnvelope, - reference: FeedCapabilityReference, - ): Promise { - const current = await this.loadGeneration(envelope.feedId, reference); - if (current.status === "invalid") { - throw new FeedCapabilityRepositoryError("Stored feed capabilities are invalid."); - } - if (current.status === "unavailable") { - throw new FeedCapabilityRepositoryError("SecretStorage is unavailable."); - } - if ( - current.status === "available" && - JSON.stringify(current.value.bundle) === JSON.stringify(envelope) - ) { - return reference; - } - - const generation = - current.status === "available" ? current.value.manifest.generation + 1 : 1; - if (!Number.isSafeInteger(generation)) { - throw new FeedCapabilityRepositoryError("Feed capability generation is exhausted."); - } - - let contentDigest: string; - let preparedPages: Awaited>; - try { - contentDigest = await sha256Hex(JSON.stringify(envelope)); - preparedPages = await preparePages(envelope, generation); - } catch { - throw new FeedCapabilityRepositoryError( - "SecretStorage could not commit feed capabilities.", - ); - } - - const newManifestSlot = inactiveSlot( - current.status === "available" ? current.value.manifestSlot : undefined, - ); - const overwrittenManifest = this.readManifest(reference, newManifestSlot, envelope.feedId); - if (overwrittenManifest.status === "unavailable") { - throw new FeedCapabilityRepositoryError("SecretStorage is unavailable."); - } - if (overwrittenManifest.status === "invalid") { - throw new FeedCapabilityRepositoryError("Stored feed capabilities are invalid."); - } - let plannedPages: Array<{ key: string; serialized: string }>; - let descriptors: FeedCapabilityPageDescriptor[]; - let newManifestKey: string; - try { - plannedPages = preparedPages.map((prepared) => { - const validated = validatePage( - prepared.page, - envelope.feedId, - generation, - prepared.page.index, - prepared.page.bucket, - ); - if ( - !validated || - JSON.stringify(validated) !== prepared.serialized || - serializedByteLength(prepared.serialized) > MAX_SECRET_STORAGE_ITEM_BYTES - ) { - throw new Error("Invalid physical page plan"); - } - return { - key: pageKey(reference, prepared.page.index, newManifestSlot), - serialized: prepared.serialized, - }; - }); - descriptors = preparedPages.map((prepared) => ({ - index: prepared.page.index, - bucket: prepared.page.bucket, - slot: newManifestSlot, - digest: prepared.digest, - byteLength: prepared.byteLength, - episodeCount: prepared.episodeCount, - })); - newManifestKey = manifestKey(reference, newManifestSlot); - } catch { - throw new FeedCapabilityRepositoryError( - "SecretStorage could not commit feed capabilities.", - ); - } - - const manifest = makeManifest(envelope, generation, contentDigest, descriptors); - const serializedManifest = serializePhysicalItem(manifest); - if (!serializedManifest || !this.validatePhysicalManifest(manifest, envelope.feedId)) { - throw new FeedCapabilityRepositoryError( - "SecretStorage could not commit feed capabilities.", - ); - } - - const writtenPageKeys: string[] = []; - try { - for (const page of plannedPages) { - this.writeExact(page.key, page.serialized); - writtenPageKeys.push(page.key); - } - } catch { - this.blankBestEffort(writtenPageKeys); - throw new FeedCapabilityRepositoryError( - "SecretStorage could not commit feed capabilities.", - ); - } - - try { - this.writeExact(newManifestKey, serializedManifest); - } catch { - this.blankBestEffort([newManifestKey, ...writtenPageKeys]); - throw new FeedCapabilityRepositoryError( - "SecretStorage could not commit feed capabilities.", - ); - } - - const selected = await this.loadGeneration(envelope.feedId, reference); - if ( - selected.status !== "available" || - selected.value.manifest.generation !== generation || - selected.value.manifest.contentDigest !== contentDigest - ) { - this.blankBestEffort([newManifestKey, ...writtenPageKeys]); - throw new FeedCapabilityRepositoryError( - "SecretStorage could not commit feed capabilities.", - ); - } - - if (overwrittenManifest.status === "available") { - const selectedPageKeys = new Set( - descriptors.map((descriptor) => - pageKey(reference, descriptor.index, descriptor.slot), - ), - ); - const supersededPageKeys = overwrittenManifest.value.manifest.pages - .map((descriptor) => pageKey(reference, descriptor.index, descriptor.slot)) - .filter((key) => !selectedPageKeys.has(key)); - this.blankBestEffort(supersededPageKeys); - } - - return reference; - } - - private async withFeedCommit(feedId: FeedHandle, operation: () => Promise): Promise { - let release!: () => void; - const current = new Promise((resolve) => { - release = resolve; - }); - const previous = this.commitQueues.get(feedId); - this.commitQueues.set(feedId, current); - if (previous) await previous; - - try { - return await operation(); - } finally { - release(); - if (this.commitQueues.get(feedId) === current) this.commitQueues.delete(feedId); - } - } - - async getFeedCapabilities( - feedId: FeedHandle | string, - reference: unknown, - ): Promise { - const result = await this.readFeedCapabilities(feedId, reference); - return result.status === "available" ? result.value : null; - } - - async feedCapabilitiesStatus( - feedId: FeedHandle | string, - reference: unknown, - ): Promise { - return (await this.readFeedCapabilities(feedId, reference)).status; - } - - async getEpisodeResources( - feedId: FeedHandle | string, - episodeId: EpisodeHandle | string, - reference: unknown, - ): Promise { - const result = await this.readEpisodeResources(feedId, episodeId, reference); - return result.status === "available" ? result.value : null; - } - - async episodeResourcesStatus( - feedId: FeedHandle | string, - episodeId: EpisodeHandle | string, - reference: unknown, - ): Promise { - return (await this.readEpisodeResources(feedId, episodeId, reference)).status; - } - - private reserveExactNamespace(feedId: FeedHandle, reference: FeedCapabilityReference): void { - const marker = serializePhysicalItem(markerFor(feedId)); - if (!marker) { - throw new FeedCapabilityRepositoryError("Invalid feed capability namespace."); - } - - let existing: string | null; - try { - existing = this.storage.getSecret(reference); - } catch { - throw new FeedCapabilityRepositoryError("SecretStorage is unavailable."); - } - - if (existing === null) { - try { - this.writeExact(reference, marker); - } catch { - throw new FeedCapabilityRepositoryError( - "SecretStorage could not reserve a feed capability namespace.", - ); - } - return; - } - - if ( - !parseBoundedItem(existing, (candidate) => validateNamespaceMarker(candidate, feedId)) - ) { - throw new FeedCapabilityRepositoryError("Stored feed capability namespace is invalid."); - } - } - - async readFeedCapabilities( - feedId: FeedHandle | string, - reference: unknown, - ): Promise> { - if (!isFeedCapabilityReferenceFor(feedId, reference)) return { status: "invalid" }; - const generation = await this.loadGeneration(feedId, reference); - return generation.status === "available" - ? { status: "available", value: generation.value.bundle } - : generation; - } - - async readEpisodeResources( - feedId: FeedHandle | string, - episodeId: EpisodeHandle | string, - reference: unknown, - ): Promise> { - if (!isEpisodeHandle(episodeId)) return { status: "invalid" }; - const bundle = await this.readFeedCapabilities(feedId, reference); - if (bundle.status !== "available") return bundle; - const entry = bundle.value.episodeResources[episodeId]; - return entry ? { status: "available", value: entry } : { status: "missing" }; - } - - private async loadGeneration( - feedId: unknown, - reference: FeedCapabilityReference | string, - ): Promise> { - if (!isFeedCapabilityReferenceFor(feedId, reference)) return { status: "invalid" }; - - const markerRead = this.readSecret(reference); - if (markerRead.status !== "available") return markerRead; - const marker = parseBoundedItem(markerRead.value, (candidate) => - validateNamespaceMarker(candidate, feedId), - ); - if (!marker) return { status: "invalid" }; - - const left = this.readManifest(reference, "a", feedId); - const right = this.readManifest(reference, "b", feedId); - if (left.status === "unavailable" || right.status === "unavailable") { - return { status: "unavailable" }; - } - if (left.status === "invalid" || right.status === "invalid") { - return { status: "invalid" }; - } - if (left.status === "missing" && right.status === "missing") { - return { status: "missing" }; - } - - let selected: LoadedManifestSlot; - if (left.status === "available" && right.status === "available") { - if (left.value.manifest.generation === right.value.manifest.generation) { - if (manifestsDiverge(left.value, right.value)) return { status: "invalid" }; - selected = left.value; - } else { - selected = - left.value.manifest.generation > right.value.manifest.generation - ? left.value - : right.value; - } - } else if (left.status === "available") { - selected = left.value; - } else if (right.status === "available") { - selected = right.value; - } else { - return { status: "missing" }; - } - - return this.loadManifestPages(reference, selected); - } - - private readManifest( - reference: string, - slot: PhysicalSlot, - feedId: unknown, - ): ReadResult { - const read = this.readSecret(manifestKey(reference, slot), true); - if (read.status !== "available") return read; - const manifest = parseBoundedItem(read.value, (candidate) => - validateManifest(candidate, feedId), - ); - if (manifest?.pages.some((page) => page.slot !== slot)) return { status: "invalid" }; - return manifest - ? { status: "available", value: { manifest, serialized: read.value, slot } } - : { status: "invalid" }; - } - - private async loadManifestPages( - reference: string, - selected: LoadedManifestSlot, - ): Promise> { - const captured: Array<{ page: FeedCapabilityPage; serialized: string; digest: string }> = - []; - for (const descriptor of selected.manifest.pages) { - const read = this.readSecret( - pageKey(reference, descriptor.index, descriptor.slot), - true, - ); - if (read.status === "unavailable") return read; - if (read.status !== "available") return { status: "invalid" }; - if ( - serializedByteLength(read.value) !== descriptor.byteLength || - descriptor.byteLength > MAX_SECRET_STORAGE_ITEM_BYTES - ) { - return { status: "invalid" }; - } - const page = parseBoundedItem(read.value, (candidate) => - validatePage( - candidate, - selected.manifest.feedId, - selected.manifest.generation, - descriptor.index, - descriptor.bucket, - ), - ); - if (!page || Object.keys(page.episodeResources).length !== descriptor.episodeCount) { - return { status: "invalid" }; - } - captured.push({ page, serialized: read.value, digest: descriptor.digest }); - } - - try { - const digests = await Promise.all(captured.map((item) => sha256Hex(item.serialized))); - if (digests.some((digest, index) => digest !== captured[index].digest)) { - return { status: "invalid" }; - } - } catch { - return { status: "unavailable" }; - } - - const pages = captured.map((item) => item.page); - const bundle = reconstructEnvelope(selected.manifest, pages); - if (!bundle) return { status: "invalid" }; - try { - if ((await sha256Hex(JSON.stringify(bundle))) !== selected.manifest.contentDigest) { - return { status: "invalid" }; - } - } catch { - return { status: "unavailable" }; - } - return { - status: "available", - value: { bundle, manifest: selected.manifest, manifestSlot: selected.slot }, - }; - } - - private readSecret(id: string, blankIsMissing = false): ReadResult { - let value: string | null; - try { - value = this.storage.getSecret(id); - } catch { - return { status: "unavailable" }; - } - if (value === null || (blankIsMissing && value === "")) return { status: "missing" }; - return { status: "available", value }; - } - - private writeExact(id: string, serialized: string): void { - if (serializedByteLength(serialized) > MAX_SECRET_STORAGE_ITEM_BYTES) { - throw new Error("Physical item exceeds limit"); - } - try { - this.storage.setSecret(id, serialized); - } catch (error) { - try { - if (this.storage.getSecret(id) === serialized) return; - } catch { - // Exact readback remains the commit authority after an ambiguous write. - } - throw error; - } - if (this.storage.getSecret(id) !== serialized) - throw new Error("Physical item readback failed"); - } - - private blankBestEffort(ids: readonly string[]): void { - for (const id of ids) { - try { - this.storage.setSecret(id, ""); - this.storage.getSecret(id); - } catch { - // The committed generation does not reference these inactive slots. - } - } - } -} diff --git a/src/services/PodNotesNetworkServices.test.ts b/src/services/PodNotesNetworkServices.test.ts deleted file mode 100644 index 06e96e9..0000000 --- a/src/services/PodNotesNetworkServices.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { SecretStorage } from "obsidian"; -import { Platform } from "obsidian"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - desktopFactory: vi.fn(), - repositoryConstructor: vi.fn(), - repositoryInstances: [] as object[], - runtimeFactory: vi.fn(), - schedulerConstructor: vi.fn(), - schedulerInstances: [] as object[], -})); - -vi.mock("src/network/DesktopPinnedNetworkAdapter", () => ({ - createDesktopNetworkPrimitives: mocks.desktopFactory, -})); - -vi.mock("./FeedCapabilityRepository", () => ({ - FeedCapabilityRepository: class MockFeedCapabilityRepository { - constructor(storage: unknown) { - mocks.repositoryInstances.push(this); - mocks.repositoryConstructor(storage, this); - } - }, -})); - -vi.mock("src/network/NetworkScheduler", () => ({ - NetworkScheduler: class MockNetworkScheduler { - constructor() { - mocks.schedulerInstances.push(this); - mocks.schedulerConstructor(this); - } - }, -})); - -vi.mock("./FeedCapabilityBroker", () => ({ - createFeedCapabilityNetworkRuntime: mocks.runtimeFactory, -})); - -import { createPodNotesNetworkServices } from "./PodNotesNetworkServices"; - -const originalIsDesktopApp = Platform.isDesktopApp; -const secretStorage = Object.freeze({}) as SecretStorage; -const nameResolver = Object.freeze({ name: "name-resolver" }); -const adapter = Object.freeze({ name: "pinned-network-hop-adapter" }); -const broker = Object.freeze({ name: "feed-capability-broker" }); -const transport = Object.freeze({ name: "feed-capability-transport" }); -let runtimeDispose: ReturnType; - -beforeEach(() => { - Platform.isDesktopApp = true; - mocks.desktopFactory.mockReset(); - mocks.repositoryConstructor.mockReset(); - mocks.repositoryInstances.length = 0; - mocks.runtimeFactory.mockReset(); - mocks.schedulerConstructor.mockReset(); - mocks.schedulerInstances.length = 0; - runtimeDispose = vi.fn(); - mocks.desktopFactory.mockReturnValue({ nameResolver, adapter }); - mocks.runtimeFactory.mockReturnValue({ broker, transport, dispose: runtimeDispose }); -}); - -afterAll(() => { - Platform.isDesktopApp = originalIsDesktopApp; -}); - -describe("createPodNotesNetworkServices", () => { - it("returns a frozen unavailable service on mobile without touching desktop composition", () => { - Platform.isDesktopApp = false; - - const services = createPodNotesNetworkServices(secretStorage); - - expect(services).toMatchObject({ - status: "unavailable", - reason: "unsupported-platform", - }); - expect(Object.isFrozen(services)).toBe(true); - expect(mocks.desktopFactory).not.toHaveBeenCalled(); - expect(mocks.repositoryConstructor).not.toHaveBeenCalled(); - expect(mocks.schedulerConstructor).not.toHaveBeenCalled(); - expect(mocks.runtimeFactory).not.toHaveBeenCalled(); - expect(services.isDisposed()).toBe(false); - - services.dispose(); - services.dispose(); - - expect(services.isDisposed()).toBe(true); - }); - - it("constructs exactly one desktop repository, scheduler, and runtime", () => { - const services = createPodNotesNetworkServices(secretStorage); - - expect(mocks.desktopFactory).toHaveBeenCalledOnce(); - expect(mocks.repositoryConstructor).toHaveBeenCalledOnce(); - expect(mocks.repositoryConstructor).toHaveBeenCalledWith( - secretStorage, - mocks.repositoryInstances[0], - ); - expect(mocks.schedulerConstructor).toHaveBeenCalledOnce(); - expect(mocks.runtimeFactory).toHaveBeenCalledOnce(); - expect(mocks.runtimeFactory).toHaveBeenCalledWith( - mocks.repositoryInstances[0], - nameResolver, - adapter, - mocks.schedulerInstances[0], - ); - expect(services.status).toBe("available"); - if (services.status !== "available") throw new Error("Expected available services."); - expect(services).toMatchObject({ - feedCapabilityBroker: broker, - feedCapabilityTransport: transport, - }); - expect(Object.isFrozen(services)).toBe(true); - expect(services).not.toHaveProperty("feedCapabilityRepository"); - expect(services).not.toHaveProperty("scheduler"); - expect(services).not.toHaveProperty("runtime"); - }); - - it("fails closed when desktop primitives cannot be created", () => { - mocks.desktopFactory.mockImplementation(() => { - throw new Error("desktop networking unavailable"); - }); - - const services = createPodNotesNetworkServices(secretStorage); - - expect(services).toMatchObject({ - status: "unavailable", - reason: "initialization-failed", - }); - expect(Object.isFrozen(services)).toBe(true); - expect(mocks.repositoryConstructor).not.toHaveBeenCalled(); - expect(mocks.schedulerConstructor).not.toHaveBeenCalled(); - expect(mocks.runtimeFactory).not.toHaveBeenCalled(); - expect(services).not.toHaveProperty("feedCapabilityBroker"); - expect(services).not.toHaveProperty("feedCapabilityTransport"); - }); - - it("fails closed when the brokered runtime cannot be created", () => { - mocks.runtimeFactory.mockImplementation(() => { - throw new Error("runtime unavailable"); - }); - - const services = createPodNotesNetworkServices(secretStorage); - - expect(services).toMatchObject({ - status: "unavailable", - reason: "initialization-failed", - }); - expect(mocks.desktopFactory).toHaveBeenCalledOnce(); - expect(mocks.repositoryConstructor).toHaveBeenCalledOnce(); - expect(mocks.schedulerConstructor).toHaveBeenCalledOnce(); - expect(mocks.runtimeFactory).toHaveBeenCalledOnce(); - expect(services).not.toHaveProperty("feedCapabilityBroker"); - expect(services).not.toHaveProperty("feedCapabilityTransport"); - }); - - it("owns runtime disposal through one idempotent aggregate lifecycle", () => { - const services = createPodNotesNetworkServices(secretStorage); - expect(services.status).toBe("available"); - expect(services.isDisposed()).toBe(false); - - services.dispose(); - services.dispose(); - - expect(runtimeDispose).toHaveBeenCalledOnce(); - expect(services.isDisposed()).toBe(true); - }); - - it("best-effort disposes a runtime when final service composition fails", () => { - const partialDispose = vi.fn(); - mocks.runtimeFactory.mockReturnValue({ - get broker() { - throw new Error("invalid broker facade"); - }, - transport, - dispose: partialDispose, - }); - - const services = createPodNotesNetworkServices(secretStorage); - - expect(services).toMatchObject({ - status: "unavailable", - reason: "initialization-failed", - }); - expect(partialDispose).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/services/PodNotesNetworkServices.ts b/src/services/PodNotesNetworkServices.ts deleted file mode 100644 index b566952..0000000 --- a/src/services/PodNotesNetworkServices.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Platform, type SecretStorage } from "obsidian"; -import { createDesktopNetworkPrimitives } from "src/network/DesktopPinnedNetworkAdapter"; -import { NetworkScheduler } from "src/network/NetworkScheduler"; - -import { - createFeedCapabilityNetworkRuntime, - type FeedCapabilityBroker, - type FeedCapabilityNetworkRuntime, - type FeedCapabilityTransport, -} from "./FeedCapabilityBroker"; -import { FeedCapabilityRepository } from "./FeedCapabilityRepository"; - -export type PodNotesNetworkServicesUnavailableReason = - | "unsupported-platform" - | "initialization-failed"; - -interface PodNotesNetworkServicesLifecycle { - dispose(): void; - isDisposed(): boolean; -} - -export interface AvailablePodNotesNetworkServices extends PodNotesNetworkServicesLifecycle { - readonly status: "available"; - readonly feedCapabilityBroker: FeedCapabilityBroker; - readonly feedCapabilityTransport: FeedCapabilityTransport; -} - -export interface UnavailablePodNotesNetworkServices extends PodNotesNetworkServicesLifecycle { - readonly status: "unavailable"; - readonly reason: PodNotesNetworkServicesUnavailableReason; -} - -export type PodNotesNetworkServices = - | AvailablePodNotesNetworkServices - | UnavailablePodNotesNetworkServices; - -function createUnavailableServices( - reason: PodNotesNetworkServicesUnavailableReason, -): UnavailablePodNotesNetworkServices { - let disposed = false; - return Object.freeze({ - status: "unavailable", - reason, - dispose(): void { - disposed = true; - }, - isDisposed(): boolean { - return disposed; - }, - }); -} - -function createAvailableServices( - runtime: FeedCapabilityNetworkRuntime, -): AvailablePodNotesNetworkServices { - let disposed = false; - return Object.freeze({ - status: "available", - feedCapabilityBroker: runtime.broker, - feedCapabilityTransport: runtime.transport, - dispose(): void { - if (disposed) return; - disposed = true; - runtime.dispose(); - }, - isDisposed(): boolean { - return disposed; - }, - }); -} - -/** - * Plugin-scoped composition boundary for capability-authorized networking. - * Legacy callers remain disconnected until they can migrate to brokered access. - */ -export function createPodNotesNetworkServices( - secretStorage: SecretStorage, -): PodNotesNetworkServices { - if (!Platform.isDesktopApp) { - return createUnavailableServices("unsupported-platform"); - } - - let runtime: FeedCapabilityNetworkRuntime | undefined; - try { - const { nameResolver, adapter } = createDesktopNetworkPrimitives(); - const repository = new FeedCapabilityRepository(secretStorage); - const scheduler = new NetworkScheduler(); - runtime = createFeedCapabilityNetworkRuntime(repository, nameResolver, adapter, scheduler); - return createAvailableServices(runtime); - } catch { - try { - runtime?.dispose(); - } catch { - // Initialization remains fail-closed even when partial teardown fails. - } - return createUnavailableServices("initialization-failed"); - } -}