// The ownership registry: which account owns which Cloudflare storage resource. // // Written with raw cypher MERGE stamped with system provenance, mirroring the // house-level seedAccountGraphRoot path in the admin plugin. It deliberately // does NOT go through graph-write's writeNodeWithEdges: that primitive's // accountId floor rejects a write whose props.accountId differs from the // writer process's ACCOUNT_ID, and the broker runs house-level, writing nodes // owned by many different sub-accounts. The owning accountId here is always the // trusted header value the service resolved, never an agent argument. // A minimal shape of a neo4j-driver Session, so this lib carries no runtime or // type dependency on neo4j-driver (mirrors the SeedSessionLike pattern used in // platform/plugins/admin/mcp/src/tools/account-lifecycle.ts). // Uses PromiseLike (not Promise) and get(): any so that a real neo4j-driver // Session — whose run() returns a Result (a PromiseLike, and whose records are // driver Records) — is structurally assignable under both v5 and v6 without a // cast at the call site. export interface SessionLike { run( cypher: string, params?: Record, ): PromiseLike<{ records: Array<{ get(key: string): any }> }>; } // "pages" (Task 1728) is an ownership record, not a storage resource: it binds a // Cloudflare Pages project to the account allowed to publish it. It shares this // registry because the ownership question ("may this caller act on this named // thing?") and its answer (resolveOwner + authorizeAccess) are identical. export type ResourceKind = "d1" | "r2" | "pages"; export interface StorageResource { accountId: string; kind: ResourceKind; name: string; cfResourceId: string; } export async function registerResource( session: SessionLike, r: StorageResource, ): Promise { // MERGE on (kind, name) — the ownership key. On a shared Cloudflare account a // storage name maps to exactly one owner, so the write key must match the // resolveOwner lookup key; keying on accountId too would let two accounts hold // a node for the same name and make ownership ambiguous. accountId is set only // ON CREATE and never overwritten ON MATCH, so a register can never silently // reassign an existing resource to a different account. await session.run( `MERGE (r:StorageResource {kind: $kind, name: $name}) ON CREATE SET r.accountId = $accountId, r.createdByAgent = 'system', r.createdBySource = 'storage-broker', r.cfResourceId = $cfResourceId ON MATCH SET r.cfResourceId = $cfResourceId`, { accountId: r.accountId, kind: r.kind, name: r.name, cfResourceId: r.cfResourceId }, ); } export async function resolveOwner( session: SessionLike, kind: ResourceKind, name: string, ): Promise { const res = await session.run( `MATCH (r:StorageResource {kind: $kind, name: $name}) RETURN r.accountId AS accountId LIMIT 1`, { kind, name }, ); return res.records.length ? (res.records[0].get("accountId") as string) : null; } export async function listResources( session: SessionLike, accountId: string, kind: ResourceKind, ): Promise { const res = await session.run( `MATCH (r:StorageResource {accountId: $accountId, kind: $kind}) RETURN r.name AS name, r.cfResourceId AS cfResourceId ORDER BY r.name`, { accountId, kind }, ); return res.records.map((rec) => ({ accountId, kind, name: rec.get("name") as string, cfResourceId: rec.get("cfResourceId") as string, })); }