← back to Dw Photo Capture
fm-client.js
109 lines
// FileMaker Cloud (Claris ID / Cognito) client for dwphoto — mirrors ~/Projects/filemaker-mcp.
// Auth flow: Claris email+password --SRP--> Cognito idToken (~1h) --> per-db Data API session (15m).
// FileMaker Cloud does NOT accept Basic auth on the Data API; it needs an FMID token from Cognito.
// Reads are safe; the single write (fmUpdate) defaults to dryRun so it can't touch live data by accident.
const cognito = require('amazon-cognito-identity-js');
const HOST = () => process.env.FM_CLOUD_HOST || '';
const base = (db) => `https://${HOST()}/fmi/data/vLatest/databases/${encodeURIComponent(db)}`;
// --- Cognito idToken (cache ~1h, refresh 60s early) ---
let idCache = { token: null, exp: 0 };
function getIdToken() {
const now = Date.now();
if (idCache.token && now < idCache.exp - 60000) return Promise.resolve(idCache.token);
const Username = process.env.FM_CLARIS_EMAIL, Password = process.env.FM_CLARIS_PASSWORD;
if (!Username || !Password) return Promise.reject(new Error('FM_CLARIS_EMAIL / FM_CLARIS_PASSWORD not set'));
const pool = new cognito.CognitoUserPool({
UserPoolId: process.env.FM_COGNITO_USERPOOL || 'us-west-2_NqkuZcXQY',
ClientId: process.env.FM_COGNITO_CLIENTID || '4l9rvl4mv5es1eep1qe97cautn',
});
const user = new cognito.CognitoUser({ Username, Pool: pool });
const details = new cognito.AuthenticationDetails({ Username, Password });
return new Promise((resolve, reject) => {
user.authenticateUser(details, {
onSuccess: (s) => { idCache = { token: s.getIdToken().getJwtToken(), exp: now + 60 * 60 * 1000 }; resolve(idCache.token); },
onFailure: (e) => reject(new Error('Claris ID auth failed: ' + (e && e.message || e))),
mfaRequired: () => reject(new Error('Claris ID account has MFA enabled — disable it on the API service account.')),
totpRequired: () => reject(new Error('Claris ID account requires TOTP — disable MFA on the service account.')),
newPasswordRequired: () => reject(new Error('Claris ID account requires a new (permanent) password.')),
});
});
}
// --- per-database Data API session token (cache 14m) ---
const sessions = new Map();
async function getSession(db) {
const now = Date.now();
const s = sessions.get(db);
if (s && now < s.exp) return s.token;
const idToken = await getIdToken();
const res = await fetch(`${base(db)}/sessions`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `FMID ${idToken}` }, body: '{}',
});
const json = await res.json().catch(() => ({}));
const code = json && json.messages && json.messages[0] && json.messages[0].code;
if (!res.ok || (code && code !== '0')) throw new Error(`FileMaker session failed (${db}): HTTP ${res.status} ${JSON.stringify(json.messages || json)}`);
const token = json.response.token;
sessions.set(db, { token, exp: now + 14 * 60 * 1000 });
return token;
}
async function fm(db, path, { method = 'GET', body } = {}) {
const token = await getSession(db);
// Hard timeout so a slow/stuck Data API call can never hang an HTTP request forever.
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), Number(process.env.FM_TIMEOUT_MS) || 12000);
let res;
try {
res = await fetch(`${base(db)}${path}`, {
method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined, signal: ctl.signal,
});
} catch (e) { throw new Error(e.name === 'AbortError' ? `FileMaker timeout (${db}${path})` : e.message); }
finally { clearTimeout(timer); }
const json = await res.json().catch(() => ({}));
const msg = json && json.messages && json.messages[0];
if (msg && msg.code && msg.code !== '0') {
// code 401 = "no records match" — not an error for our purposes; surface as empty.
if (msg.code === '401') return { data: [], dataInfo: { foundCount: 0 } };
const e = new Error(`FileMaker [${msg.code}] ${msg.message} (${db}${path})`); e.fmCode = msg.code; throw e;
}
if (!res.ok) throw new Error(`FileMaker HTTP ${res.status} (${db}${path})`);
return json.response;
}
async function fmFind(db, layout, query, { limit = 20, offset = 1, sort, portal } = {}) {
const body = { query: Array.isArray(query) ? query : [query], limit: String(limit), offset: String(offset) };
if (sort) body.sort = sort;
if (portal) body.portal = portal; // e.g. [] → suppress heavy portalData (big speedup on portal-laden layouts)
const r = await fm(db, `/layouts/${encodeURIComponent(layout)}/_find`, { method: 'POST', body });
return { records: r.data || [], dataInfo: r.dataInfo };
}
async function fmGet(db, layout, recordId) {
const r = await fm(db, `/layouts/${encodeURIComponent(layout)}/records/${encodeURIComponent(recordId)}`);
return (r.data && r.data[0]) || null;
}
// Update ONE record. dryRun (default) returns the before→after diff WITHOUT committing.
async function fmUpdate(db, layout, recordId, fieldData, { dryRun = true } = {}) {
const cur = await fmGet(db, layout, recordId);
if (!cur) throw new Error(`record ${recordId} not found in ${db}/${layout}`);
const before = cur.fieldData || {}, diff = {};
for (const [k, v] of Object.entries(fieldData)) if (String(before[k] ?? '') !== String(v ?? '')) diff[k] = { from: before[k], to: v };
if (dryRun) return { committed: false, dryRun: true, recordId, changes: diff };
if (!Object.keys(diff).length) return { committed: false, recordId, changes: {}, note: 'no change' };
await fm(db, `/layouts/${encodeURIComponent(layout)}/records/${encodeURIComponent(recordId)}`, { method: 'PATCH', body: { fieldData } });
return { committed: true, recordId, changes: diff };
}
// Direct PATCH with NO pre-read (fmGet on a record with big portals can be slow/stuck in a request
// handler). Use when you just need to set fields and don't need a before/after diff.
async function fmSet(db, layout, recordId, fieldData) {
await fm(db, `/layouts/${encodeURIComponent(layout)}/records/${encodeURIComponent(recordId)}`, { method: 'PATCH', body: { fieldData } });
return { committed: true, recordId, wrote: fieldData };
}
module.exports = { getIdToken, fmFind, fmGet, fmUpdate, fmSet };