media-import-consent.mjs
134 lines 4.1 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Per-vault media import consent store (Phase 2F-b-d-kn-b).
3 *
4 * Human-granted, revocable, expiry-aware — no connector credentials.
5 *
6 * @see docs/MEDIA-WRITE-SURFACES-CONTRACT-2F-b-d-kn.md §6
7 */
8
9 import fs from 'fs';
10 import path from 'path';
11 import { randomUUID, randomBytes } from 'crypto';
12
13 const STORE_FILENAME = 'hub_media_import_consent.json';
14
15 export const CONSENT_ID_RE = /^mic_[a-f0-9]{16}$/;
16
17 /**
18 * @typedef {'personal'|'project'|'org'} ConsentScope
19 * @typedef {'active'|'revoked'} ConsentStatus
20 */
21
22 /**
23 * @typedef {Object} MediaImportConsentRecord
24 * @property {string} connector_id
25 * @property {ConsentScope} scope
26 * @property {string} granted_by
27 * @property {string} granted_at
28 * @property {string|null} expires_at
29 * @property {ConsentStatus} status
30 */
31
32 /**
33 * @param {string} dataDir
34 * @returns {string}
35 */
36 export function getMediaImportConsentPath(dataDir) {
37 return path.join(dataDir, STORE_FILENAME);
38 }
39
40 /**
41 * @param {string} dataDir
42 * @returns {{ schema: string, vaults: Record<string, { consents?: Record<string, MediaImportConsentRecord> }> }}
43 */
44 export function loadMediaImportConsentStore(dataDir) {
45 const filePath = getMediaImportConsentPath(dataDir);
46 if (!fs.existsSync(filePath)) {
47 return { schema: 'knowtation.media_import_consent/v0', vaults: {} };
48 }
49 try {
50 const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
51 if (!parsed || typeof parsed !== 'object' || !parsed.vaults || typeof parsed.vaults !== 'object') {
52 return { schema: 'knowtation.media_import_consent/v0', vaults: {} };
53 }
54 return {
55 schema: 'knowtation.media_import_consent/v0',
56 vaults: parsed.vaults,
57 };
58 } catch {
59 return { schema: 'knowtation.media_import_consent/v0', vaults: {} };
60 }
61 }
62
63 /**
64 * @param {string} dataDir
65 * @param {object} store
66 */
67 export function saveMediaImportConsentStore(dataDir, store) {
68 const filePath = getMediaImportConsentPath(dataDir);
69 const dir = path.dirname(filePath);
70 if (!fs.existsSync(dir)) {
71 fs.mkdirSync(dir, { recursive: true });
72 }
73 const payload = {
74 schema: 'knowtation.media_import_consent/v0',
75 vaults: store.vaults ?? {},
76 };
77 const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
78 fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
79 fs.renameSync(tmp, filePath);
80 }
81
82 /** @returns {string} */
83 export function mintConsentId() {
84 return `mic_${randomBytes(8).toString('hex')}`;
85 }
86
87 /**
88 * @param {string} dataDir
89 * @param {string} vaultId
90 * @param {string} connectorId
91 * @param {ConsentScope} scope
92 * @param {string|Date} [now]
93 * @returns {MediaImportConsentRecord|null}
94 */
95 export function getActiveConsent(dataDir, vaultId, connectorId, scope, now = new Date()) {
96 const store = loadMediaImportConsentStore(dataDir);
97 const vault = store.vaults?.[vaultId];
98 if (!vault?.consents || typeof vault.consents !== 'object') return null;
99
100 const nowMs = now instanceof Date ? now.getTime() : new Date(now).getTime();
101
102 for (const record of Object.values(vault.consents)) {
103 if (!record || typeof record !== 'object') continue;
104 if (record.connector_id !== connectorId) continue;
105 if (record.scope !== scope) continue;
106 if (record.status !== 'active') continue;
107 if (record.expires_at != null) {
108 const exp = new Date(record.expires_at).getTime();
109 if (!Number.isNaN(exp) && exp <= nowMs) continue;
110 }
111 return record;
112 }
113 return null;
114 }
115
116 /**
117 * @param {string} dataDir
118 * @param {string} vaultId
119 * @param {ConsentScope} [scopeFilter]
120 * @returns {{ consent_id: string, record: MediaImportConsentRecord }[]}
121 */
122 export function listVaultConsents(dataDir, vaultId, scopeFilter) {
123 const store = loadMediaImportConsentStore(dataDir);
124 const vault = store.vaults?.[vaultId];
125 if (!vault?.consents) return [];
126 const out = [];
127 for (const [consentId, record] of Object.entries(vault.consents)) {
128 if (!record || typeof record !== 'object') continue;
129 if (scopeFilter && record.scope !== scopeFilter) continue;
130 out.push({ consent_id: consentId, record });
131 }
132 out.sort((a, b) => a.consent_id.localeCompare(b.consent_id));
133 return out;
134 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago