docs-connector-store.mjs
162 lines 5.9 KB
Raw
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