/** * subscription-store.ts, named external-calendar feed subscriptions with honest, * per-subscription status. The engine that agent-side `/calendar subscribe` and the * connect wizard drive. * * Reaches the network ONLY through an injected `FeedFetcher`, and reads time ONLY * through an injected `Clock`. That is the whole IO boundary: tests supply fake * feeds and a fake clock, so no test ever touches a real URL, and refresh/staleness * timing is deterministic. Persistence is the CALLER's job (the agent stores the * feed URL via its secret manager and the rest of the metadata in its config); * `snapshot()` / `restore()` move that metadata across restarts, events re-fetched. * * UX shape (per the owner's least-friction rule): `add({ url })` is paste-URL-and-done, * it validates by fetching, auto-derives the subscription name from the feed's * X-WR-CALNAME (falling back to the URL host) when the caller gives no name, and * applies a sensible default refresh interval with no mandatory knobs. Every status * it reports is honest: stale carries its age, unreachable/parse-error carry the * stage and detail. * * PURE of ambient IO, no direct fs/network/process; all IO is injected. * * A feed is externally-controlled input, so reading its event content records an * untrusted ingest through the injected `recordUntrustedIngest`. That recording * hangs off two EXPLICIT readers, `readEvents()` and `readAllEvents()`, and * never off the plain accessors `events()` / `allEvents()`, which record * nothing. The refresh path likewise records NOTHING: arrival is not ingest * (see untrusted-events.ts and docs/decisions/2026-07-27-arrival-is-not-ingest.md). */ import { type CalendarUntrustedIngestRecorder } from './untrusted-events.js'; import type { CalendarEvent, CalendarSubscription, Clock, FeedFetcher, RefreshReport, SubscriptionSnapshot } from './types.js'; /** Sensible refresh cadence for a read-only feed that rarely changes minute-to-minute. */ export declare const DEFAULT_REFRESH_INTERVAL_MS: number; /** Never hammer a feed faster than this, even if a caller asks. */ export declare const MIN_REFRESH_INTERVAL_MS: number; /** A feed refreshed less often than this is capped up to here. */ export declare const MAX_REFRESH_INTERVAL_MS: number; export interface SubscriptionStoreOptions { readonly fetcher: FeedFetcher; readonly clock?: Clock; readonly defaultRefreshIntervalMs?: number; /** * Records that a turn read untrusted event content. * * A subscribed feed is continuous externally-controlled input: whoever can * write to the calendar behind the URL writes the summaries, descriptions, * locations and attendee names this store hands out. * * **Called from `readEvents()` and `readAllEvents()`, the two EXPLICIT * reads, and from nowhere else.** `refresh()`, `refreshDue()` and * `applyFetch()` are arrival: they run because a timer said so, with nobody * watching. Recording there would write into whatever turn happened to be * open and refuse that turn's outward action over an event nothing asked * for, which hands anyone who can put an entry on a subscribed calendar a * remote off switch. See docs/decisions/2026-07-27-arrival-is-not-ingest.md. * * The plain accessors `events()` / `allEvents()` do NOT record, and the * distinction is not stylistic. `events()` reads as a pure accessor and is * already used as one on an arrival path: goodvibes-agent's * `calendar-subscription-registry.ts` calls `store.events(name)` inside * `refresh()`, purely to count and persist after a timer fired. Had recording * been a side effect of `events()`, the moment that consumer wired this * recorder its timer-driven refresh would have started recording ingests, * arrival becoming ingest through a name that promised otherwise. The * recorder being optional would only have made the trap dormant, not absent. */ readonly recordUntrustedIngest?: CalendarUntrustedIngestRecorder; } export interface AddSubscriptionInput { readonly url: string; /** Optional; when omitted, derived from the feed's X-WR-CALNAME or the URL host. */ readonly name?: string; readonly refreshIntervalMs?: number; } /** The outcome of validate-by-fetch, what the wizard shows before saving. */ export type ValidationResult = { readonly ok: true; readonly calendarName?: string; readonly eventCount: number; readonly derivedName: string; } | { readonly ok: false; /** Which stage failed, so failure wording can name it honestly. */ readonly stage: 'fetch' | 'parse'; readonly detail: string; }; export type AddResult = { readonly ok: true; readonly subscription: CalendarSubscription; readonly report: RefreshReport; } | { readonly ok: false; readonly stage: 'fetch' | 'parse' | 'duplicate'; readonly detail: string; }; /** * Mask a feed URL for display. Google/Outlook "secret address" URLs grant read * access, so a subscription's URL is secrets-adjacent, surfaces should show this, * never the raw URL. Keeps the scheme+host and the last few chars, masks the middle. */ export declare function maskFeedUrl(url: string): string; export declare class SubscriptionStore { private readonly fetcher; private readonly clock; private readonly defaultInterval; private readonly records; private readonly recordUntrustedIngest?; constructor(options: SubscriptionStoreOptions); /** * Record that the caller just read this subscription's event content. * * The ONLY place this store records an ingest, and it is reached only from a * read. The feed URL is masked before it becomes an origin: a Google/Outlook * "secret address" grants read access, and an origin surfaces into refusal * text an operator sees. */ private recordRead; /** * Fetch a feed WITHOUT saving it and report whether it is a usable calendar. Drives * the wizard's validate-before-save step and the derived-name preview. */ validateByFetch(url: string, requestedName?: string): Promise; /** * Paste-URL-and-done: validate by fetching, and on success register the subscription * with an auto-derived name and default cadence, storing its events. Refuses (without * saving) on a fetch/parse failure or a duplicate name, always with an honest reason. */ add(input: AddSubscriptionInput): Promise; /** Remove a subscription and drop its cached events. Returns whether it existed. */ remove(name: string): boolean; list(): CalendarSubscription[]; get(name: string): CalendarSubscription | undefined; has(name: string): boolean; /** * Events from the most recent successful parse of the named subscription. * * A PURE accessor: it records nothing. Callers that are looking at the * content because a turn asked for it want `readEvents()` instead, see the * note on `recordUntrustedIngest` for why the recording is a separate, * explicitly-named call rather than a side effect of this one. */ events(name: string): readonly CalendarEvent[]; /** * All subscriptions' events, each tagged with its source subscription name. * * A PURE accessor, like `events()`. `readAllEvents()` is the recording form. */ allEvents(): { readonly name: string; readonly events: readonly CalendarEvent[]; }[]; /** * `events()`, plus the record that a turn read this subscription's untrusted * event content. * * Call this from a path that runs because SOMEONE ASKED, a tool call, a * command, a rendered agenda. Never from a poll or a refresh: those are * arrival, and `events()` is the accessor they want. */ readEvents(name: string): readonly CalendarEvent[]; /** `allEvents()`, plus the ingest record for every subscription handed back. */ readAllEvents(): { readonly name: string; readonly events: readonly CalendarEvent[]; }[]; /** * Refresh one subscription. Skips the network when not due unless `force`. Sends * conditional-fetch validators (etag/last-modified) so an unchanged feed comes back * 304 and keeps its events. Updates honest status either way. */ refresh(name: string, opts?: { force?: boolean; }): Promise; /** Apply a fetch result to a record, mutating status/events and returning the honest report. */ private applyFetch; /** Refresh every subscription that is due (or never fetched). Used on boot + on demand. */ refreshDue(opts?: { force?: boolean; }): Promise; /** Metadata snapshot for persistence; events are intentionally excluded (re-fetched on boot). */ snapshot(): SubscriptionSnapshot[]; /** Restore subscription metadata (from `snapshot()`); call `refreshDue()` afterward to load events. */ restore(snapshots: readonly SubscriptionSnapshot[]): void; private deriveName; private healthOf; private toPublic; } //# sourceMappingURL=subscription-store.d.ts.map