docs-connector-store.mjs
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days ago
| 1 | /** |
| 2 | * File-backed document connector metadata. |
| 3 | * |
| 4 | * Provider credentials live exclusively in the encrypted token vault or |
| 5 | * process environment. This store contains connector state and opaque cursors. |
| 6 | */ |
| 7 | |
| 8 | import fs from 'fs'; |
| 9 | import path from 'path'; |
| 10 | import { randomUUID } from 'crypto'; |
| 11 | import { constantTimeEqual } from '../companion-oauth-pkce.mjs'; |
| 12 | |
| 13 | export const DOCS_STORE_FILENAME = 'docs_connectors.json'; |
| 14 | const CONNECTOR_ID_RE = /^conn_[A-Za-z0-9_-]{8,64}$/; |
| 15 | const PROVIDERS = new Set(['google-drive', 'notion']); |
| 16 | const STATUSES = new Set(['pending', 'connected', 'needs_reauth', 'revoked']); |
| 17 | const SYNC_ERRORS = new Set(['auth_expired', 'rate_limited', 'provider_error', 'network_error', 'none']); |
| 18 | |
| 19 | /** |
| 20 | * Load the complete docs connector store. Malformed files fail closed to an |
| 21 | * empty store rather than exposing partially parsed state. |
| 22 | * @param {string} dataDir |
| 23 | * @returns {{ vaults: Record<string, { connectors: object[] }> }} |
| 24 | */ |
| 25 | export function loadDocsStore(dataDir) { |
| 26 | const filePath = path.join(dataDir, DOCS_STORE_FILENAME); |
| 27 | if (!fs.existsSync(filePath)) return { vaults: {} }; |
| 28 | try { |
| 29 | const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); |
| 30 | if (!parsed || typeof parsed !== 'object' || !parsed.vaults || typeof parsed.vaults !== 'object') { |
| 31 | return { vaults: {} }; |
| 32 | } |
| 33 | for (const vault of Object.values(parsed.vaults)) { |
| 34 | if (!vault || typeof vault !== 'object' || !Array.isArray(vault.connectors)) { |
| 35 | return { vaults: {} }; |
| 36 | } |
| 37 | } |
| 38 | return parsed; |
| 39 | } catch { |
| 40 | return { vaults: {} }; |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Atomically persist the complete docs connector store. |
| 46 | * @param {string} dataDir |
| 47 | * @param {{ vaults: Record<string, { connectors: object[] }> }} store |
| 48 | */ |
| 49 | export function saveDocsStore(dataDir, store) { |
| 50 | if (!store || typeof store !== 'object' || !store.vaults || typeof store.vaults !== 'object') { |
| 51 | throw new TypeError('Invalid docs connector store'); |
| 52 | } |
| 53 | fs.mkdirSync(dataDir, { recursive: true }); |
| 54 | const filePath = path.join(dataDir, DOCS_STORE_FILENAME); |
| 55 | const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`; |
| 56 | fs.writeFileSync(tmp, JSON.stringify(store, null, 2), { encoding: 'utf8', mode: 0o600 }); |
| 57 | fs.renameSync(tmp, filePath); |
| 58 | } |
| 59 | |
| 60 | function vaultStore(store, vaultId) { |
| 61 | if (typeof vaultId !== 'string' || !vaultId.trim()) throw new TypeError('vaultId is required'); |
| 62 | if (!store.vaults[vaultId]) store.vaults[vaultId] = { connectors: [] }; |
| 63 | if (!Array.isArray(store.vaults[vaultId].connectors)) store.vaults[vaultId].connectors = []; |
| 64 | return store.vaults[vaultId]; |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * Return one stored connector. |
| 69 | * @param {string} dataDir |
| 70 | * @param {string} vaultId |
| 71 | * @param {string} connectorId |
| 72 | */ |
| 73 | export function getConnector(dataDir, vaultId, connectorId) { |
| 74 | const store = loadDocsStore(dataDir); |
| 75 | return vaultStore(store, vaultId).connectors.find((row) => row.connector_id === connectorId); |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * List connectors for one vault. |
| 80 | * @param {string} dataDir |
| 81 | * @param {string} vaultId |
| 82 | */ |
| 83 | export function listConnectors(dataDir, vaultId) { |
| 84 | const store = loadDocsStore(dataDir); |
| 85 | return vaultStore(store, vaultId).connectors.slice(); |
| 86 | } |
| 87 | |
| 88 | function validateConnector(connector) { |
| 89 | if (!connector || typeof connector !== 'object') throw new TypeError('Invalid docs connector'); |
| 90 | if (!CONNECTOR_ID_RE.test(connector.connector_id ?? '')) throw new TypeError('Invalid connector id'); |
| 91 | if (!PROVIDERS.has(connector.provider)) throw new TypeError('Invalid docs provider'); |
| 92 | if (typeof connector.display_name !== 'string' || connector.display_name.length > 128) { |
| 93 | throw new TypeError('Invalid connector display name'); |
| 94 | } |
| 95 | if (!STATUSES.has(connector.status)) throw new TypeError('Invalid connector status'); |
| 96 | if (connector.sync_cursor !== null && connector.sync_cursor !== undefined && typeof connector.sync_cursor !== 'string') { |
| 97 | throw new TypeError('Invalid connector sync cursor'); |
| 98 | } |
| 99 | if (!SYNC_ERRORS.has(connector.last_sync_error ?? 'none')) throw new TypeError('Invalid connector sync error'); |
| 100 | if (!Number.isFinite(connector.file_count) || connector.file_count < 0) { |
| 101 | throw new TypeError('Invalid connector file count'); |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Insert or replace one connector record. |
| 107 | * @param {string} dataDir |
| 108 | * @param {string} vaultId |
| 109 | * @param {object} connector |
| 110 | */ |
| 111 | export function saveConnector(dataDir, vaultId, connector) { |
| 112 | validateConnector(connector); |
| 113 | const store = loadDocsStore(dataDir); |
| 114 | const vault = vaultStore(store, vaultId); |
| 115 | const idx = vault.connectors.findIndex((row) => row.connector_id === connector.connector_id); |
| 116 | if (idx === -1) vault.connectors.push(connector); |
| 117 | else vault.connectors[idx] = connector; |
| 118 | saveDocsStore(dataDir, store); |
| 119 | return connector; |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Produce the secret-free client projection. |
| 124 | * @param {object} connector |
| 125 | */ |
| 126 | export function connectorForClient(connector) { |
| 127 | return { |
| 128 | connector_id: connector.connector_id, |
| 129 | provider: connector.provider, |
| 130 | display_name: connector.display_name, |
| 131 | status: connector.status, |
| 132 | last_sync_at: connector.last_sync_at ?? null, |
| 133 | last_sync_error: connector.last_sync_error ?? 'none', |
| 134 | file_count: Number.isFinite(connector.file_count) ? connector.file_count : 0, |
| 135 | revoked_at: connector.revoked_at ?? null, |
| 136 | }; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Locate a pending connector using constant-time state comparison. |
| 141 | * @param {string} dataDir |
| 142 | * @param {string} state |
| 143 | * @returns {{ vaultId: string, connector: object } | null} |
| 144 | */ |
| 145 | export function findPendingByState(dataDir, state) { |
| 146 | if (typeof state !== 'string' || !state) return null; |
| 147 | const store = loadDocsStore(dataDir); |
| 148 | for (const [vaultId, vault] of Object.entries(store.vaults)) { |
| 149 | for (const connector of vault.connectors) { |
| 150 | if (connector.status !== 'pending' || !connector.oauth_pending) continue; |
| 151 | if (constantTimeEqual(connector.oauth_pending.state, state)) return { vaultId, connector }; |
| 152 | } |
| 153 | } |
| 154 | return null; |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Generate a connector id from 64 bits of UUID entropy. |
| 159 | */ |
| 160 | export function newConnectorId() { |
| 161 | return `conn_${randomUUID().replace(/-/g, '').slice(0, 16)}`; |
| 162 | } |
File History
1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days ago