← 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 };