zola-mcp 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "Zola wedding planning tools for Claude Code",
10
- "version": "1.4.3"
10
+ "version": "1.5.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Zola",
16
16
  "source": "./",
17
17
  "description": "Zola wedding planning tools for Claude — vendors, budget, guests, seating, events, registry, inquiries, and more via MCP",
18
- "version": "1.4.3",
18
+ "version": "1.5.0",
19
19
  "author": {
20
20
  "name": "Chris Chall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zola",
3
3
  "displayName": "Zola",
4
- "version": "1.4.3",
4
+ "version": "1.5.0",
5
5
  "description": "Zola wedding planning tools for Claude — vendors, budget, guests, seating, events, registry, inquiries, and more via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall",
package/dist/bundle.js CHANGED
@@ -34728,8 +34728,12 @@ var API_KEY_RE = new RegExp([
34728
34728
  // webhook signing secret (Stripe-style)
34729
34729
  ].map((p) => `\\b${p}`).join("|"), "g");
34730
34730
  var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
34731
+ var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
34732
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
34733
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
34734
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
34731
34735
  function redactSecrets(text) {
34732
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
34736
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
34733
34737
  }
34734
34738
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
34735
34739
  const str = text === null || text === void 0 ? "" : String(text);
@@ -34865,6 +34869,41 @@ var pageSchema = {
34865
34869
  page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
34866
34870
  };
34867
34871
 
34872
+ // node_modules/@chrischall/mcp-utils/dist/auth/cached-token.js
34873
+ function createCachedTokenSource(opts) {
34874
+ const now = opts.now ?? Date.now;
34875
+ const bufferMs = opts.bufferMs ?? 6e4;
34876
+ const defaultTtlMs = opts.defaultTtlMs ?? 36e5;
34877
+ let cached2 = null;
34878
+ let inFlight = null;
34879
+ const expiryOf = (minted) => {
34880
+ if (minted.expiresAt !== void 0) {
34881
+ return minted.expiresAt instanceof Date ? minted.expiresAt.getTime() : minted.expiresAt;
34882
+ }
34883
+ return now() + (minted.ttlMs ?? defaultTtlMs);
34884
+ };
34885
+ return {
34886
+ getToken() {
34887
+ if (cached2 && cached2.expiresAt - bufferMs > now())
34888
+ return Promise.resolve(cached2.token);
34889
+ if (inFlight)
34890
+ return inFlight;
34891
+ const p = opts.mint().then((minted) => {
34892
+ cached2 = { token: minted.token, expiresAt: expiryOf(minted) };
34893
+ return minted.token;
34894
+ }).finally(() => {
34895
+ if (inFlight === p)
34896
+ inFlight = null;
34897
+ });
34898
+ inFlight = p;
34899
+ return p;
34900
+ },
34901
+ invalidate() {
34902
+ cached2 = null;
34903
+ }
34904
+ };
34905
+ }
34906
+
34868
34907
  // node_modules/@chrischall/mcp-utils/dist/auth/index.js
34869
34908
  function bridgeDownHintOf(err) {
34870
34909
  if (err instanceof Error && err.name === "FetchproxyBridgeDownError") {
@@ -34921,6 +34960,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34921
34960
  "capture_request_header",
34922
34961
  "capture_redirect",
34923
34962
  "read_indexed_db",
34963
+ "read_dom",
34924
34964
  "download"
34925
34965
  ]);
34926
34966
 
@@ -35222,6 +35262,44 @@ function assertIndexedDbScopesArray(value, label) {
35222
35262
  }
35223
35263
  }
35224
35264
  }
35265
+ var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35266
+ var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35267
+ function assertDomSelectorsArray(value, label) {
35268
+ if (!Array.isArray(value)) {
35269
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35270
+ }
35271
+ const seen = /* @__PURE__ */ new Set();
35272
+ for (let i = 0; i < value.length; i++) {
35273
+ const entry = value[i];
35274
+ assertObject(entry, `${label}[${i}]`);
35275
+ if (entry.name === void 0) {
35276
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35277
+ }
35278
+ if (entry.selector === void 0) {
35279
+ throw new ProtocolError(`${label}[${i}].selector: missing`);
35280
+ }
35281
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35282
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35283
+ }
35284
+ if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
35285
+ throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
35286
+ }
35287
+ if (entry.attribute !== void 0) {
35288
+ if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
35289
+ throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
35290
+ }
35291
+ }
35292
+ if (seen.has(entry.name)) {
35293
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35294
+ }
35295
+ seen.add(entry.name);
35296
+ for (const k of Object.keys(entry)) {
35297
+ if (k !== "name" && k !== "selector" && k !== "attribute") {
35298
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35299
+ }
35300
+ }
35301
+ }
35302
+ }
35225
35303
  function validateFrame(raw) {
35226
35304
  assertObject(raw, "frame");
35227
35305
  const t = raw.type;
@@ -35297,6 +35375,9 @@ function validateHello(raw) {
35297
35375
  if (raw.sessionStoragePointers !== void 0) {
35298
35376
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35299
35377
  }
35378
+ if (raw.domSelectors !== void 0) {
35379
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35380
+ }
35300
35381
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35301
35382
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35302
35383
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35531,6 +35612,21 @@ function validateInnerRequest(raw) {
35531
35612
  }
35532
35613
  return raw;
35533
35614
  }
35615
+ if (raw.op === "read_dom") {
35616
+ assertObject(raw.init, "inner.init");
35617
+ if (raw.init.origin === void 0)
35618
+ throw new ProtocolError("inner.init.origin: missing");
35619
+ if (raw.init.names === void 0)
35620
+ throw new ProtocolError("inner.init.names: missing");
35621
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35622
+ assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
35623
+ for (const k of Object.keys(raw.init)) {
35624
+ if (k !== "origin" && k !== "names") {
35625
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
35626
+ }
35627
+ }
35628
+ return raw;
35629
+ }
35534
35630
  if (raw.op === "download") {
35535
35631
  assertObject(raw.init, "inner.init");
35536
35632
  if (raw.init.url === void 0) {
@@ -35556,7 +35652,7 @@ function validateInnerRequest(raw) {
35556
35652
  }
35557
35653
  return raw;
35558
35654
  }
35559
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
35655
+ throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
35560
35656
  }
35561
35657
  function assertNonEmptyKeyArray(value, label) {
35562
35658
  if (!Array.isArray(value)) {
@@ -35641,6 +35737,13 @@ function validateInnerResponse(raw) {
35641
35737
  assertObject(raw.values, "inner.values");
35642
35738
  return raw;
35643
35739
  }
35740
+ if (op === "read_dom") {
35741
+ if (raw.values === void 0) {
35742
+ throw new ProtocolError("inner.values: missing on read_dom response");
35743
+ }
35744
+ assertStringMap(raw.values, "inner.values");
35745
+ return raw;
35746
+ }
35644
35747
  if (op === "download") {
35645
35748
  assertObject(raw.value, "inner.value");
35646
35749
  assertString(raw.value.path, "inner.value.path");
@@ -35980,6 +36083,13 @@ async function buildServerHello(opts) {
35980
36083
  jsonPointer: d.jsonPointer
35981
36084
  }));
35982
36085
  }
36086
+ if (opts.domSelectors && opts.domSelectors.length > 0) {
36087
+ hello.domSelectors = opts.domSelectors.map((d) => ({
36088
+ name: d.name,
36089
+ selector: d.selector,
36090
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36091
+ }));
36092
+ }
35983
36093
  return hello;
35984
36094
  }
35985
36095
 
@@ -36069,7 +36179,8 @@ async function startHost(opts) {
36069
36179
  captureHeaders: opts.ownCaptureHeaders,
36070
36180
  indexedDbScopes: opts.ownIndexedDbScopes,
36071
36181
  localStoragePointers: opts.ownLocalStoragePointers,
36072
- sessionStoragePointers: opts.ownSessionStoragePointers
36182
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36183
+ domSelectors: opts.ownDomSelectors
36073
36184
  });
36074
36185
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
36075
36186
  let extensionWs = null;
@@ -36304,6 +36415,7 @@ async function startPeer(opts) {
36304
36415
  sessionStorageKeys: opts.sessionStorageKeys,
36305
36416
  captureHeaders: opts.captureHeaders,
36306
36417
  indexedDbScopes: opts.indexedDbScopes,
36418
+ domSelectors: opts.domSelectors,
36307
36419
  localStoragePointers: opts.localStoragePointers,
36308
36420
  sessionStoragePointers: opts.sessionStoragePointers
36309
36421
  });
@@ -36711,6 +36823,11 @@ var FetchproxyServer = class {
36711
36823
  key: d.key,
36712
36824
  jsonPointer: d.jsonPointer
36713
36825
  })),
36826
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
36827
+ name: d.name,
36828
+ selector: d.selector,
36829
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36830
+ })),
36714
36831
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36715
36832
  // adapter was about to set these to the same numbers anyway; the
36716
36833
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36831,6 +36948,7 @@ var FetchproxyServer = class {
36831
36948
  ownIndexedDbScopes: this.opts.indexedDbScopes,
36832
36949
  ownLocalStoragePointers: this.opts.localStoragePointers,
36833
36950
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
36951
+ ownDomSelectors: this.opts.domSelectors,
36834
36952
  onPairCode: this.opts.onPairCode
36835
36953
  });
36836
36954
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -36858,7 +36976,8 @@ var FetchproxyServer = class {
36858
36976
  captureHeaders: this.opts.captureHeaders,
36859
36977
  indexedDbScopes: this.opts.indexedDbScopes,
36860
36978
  localStoragePointers: this.opts.localStoragePointers,
36861
- sessionStoragePointers: this.opts.sessionStoragePointers
36979
+ sessionStoragePointers: this.opts.sessionStoragePointers,
36980
+ domSelectors: this.opts.domSelectors
36862
36981
  });
36863
36982
  this.peerHandle.onInner((inner) => this.onInner(inner));
36864
36983
  this.peerHandle.onRenegotiate(() => {
@@ -37833,6 +37952,46 @@ var FetchproxyServer = class {
37833
37952
  await this.sendInnerFrame(inner);
37834
37953
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37835
37954
  }
37955
+ /**
37956
+ * 1.4.0+: read declared DOM values from the user's signed-in tab.
37957
+ * Requires `'read_dom'` in capabilities AND every requested `name` to
37958
+ * match a declared `domSelectors` entry. The extension reads each
37959
+ * declared selector from the matched tab's DOM (isolated-world
37960
+ * `querySelector`, value or attribute) — no page-JS execution.
37961
+ *
37962
+ * Returns a `Record<string, string>` of `name → value`, with names
37963
+ * whose element (or attribute) was absent omitted. Throws
37964
+ * `FetchproxyProtocolError` on bridge failures and a plain `Error` on
37965
+ * developer mistakes (undeclared capability, undeclared name).
37966
+ */
37967
+ async readDom(opts) {
37968
+ if (!this.opts.capabilities.includes("read_dom")) {
37969
+ throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
37970
+ }
37971
+ await this.ensureConnected();
37972
+ this.throwIfPendingPair();
37973
+ if (!Array.isArray(opts.names) || opts.names.length === 0) {
37974
+ throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
37975
+ }
37976
+ this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
37977
+ if (opts.subdomain !== void 0)
37978
+ assertSubdomainLabel(opts.subdomain);
37979
+ const baseDomain = this.resolveBaseDomain(opts.domain);
37980
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
37981
+ const origin = `https://${host}`;
37982
+ const id = this.nextRequestId++;
37983
+ const inner = {
37984
+ type: "request",
37985
+ id,
37986
+ op: "read_dom",
37987
+ init: { origin, names: [...opts.names] }
37988
+ };
37989
+ const pending = new Promise((resolve, reject) => {
37990
+ this.pendingStorage.set(id, { resolve, reject });
37991
+ });
37992
+ await this.sendInnerFrame(inner);
37993
+ return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
37994
+ }
37836
37995
  assertScopeSubset(requested, declared, label) {
37837
37996
  const undeclared = undeclaredKeys(requested, declared);
37838
37997
  if (undeclared.length > 0) {
@@ -37904,7 +38063,7 @@ var FetchproxyServer = class {
37904
38063
  if (storageCb) {
37905
38064
  this.pendingStorage.delete(inner.id);
37906
38065
  if (inner.ok) {
37907
- if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
38066
+ if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
37908
38067
  storageCb.resolve({ ...inner.values });
37909
38068
  } else {
37910
38069
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
@@ -38229,7 +38388,7 @@ var BootstrapDisabledError = class extends Error {
38229
38388
  // package.json
38230
38389
  var package_default = {
38231
38390
  name: "zola-mcp",
38232
- version: "1.4.3",
38391
+ version: "1.5.0",
38233
38392
  mcpName: "io.github.chrischall/zola-mcp",
38234
38393
  description: "Zola wedding MCP server for Claude",
38235
38394
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38275,7 +38434,7 @@ var package_default = {
38275
38434
  "test:watch": "vitest"
38276
38435
  },
38277
38436
  dependencies: {
38278
- "@chrischall/mcp-utils": "^0.10.3",
38437
+ "@chrischall/mcp-utils": "^0.13.0",
38279
38438
  "@fetchproxy/bootstrap": "^1.3.0",
38280
38439
  "@fetchproxy/server": "^1.3.0",
38281
38440
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -38286,7 +38445,7 @@ var package_default = {
38286
38445
  "@types/node": "^26.0.0",
38287
38446
  "@vitest/coverage-v8": "^4.1.8",
38288
38447
  esbuild: "^0.28.0",
38289
- typescript: "^6.0.3",
38448
+ typescript: "^7.0.2",
38290
38449
  vitest: "^4.1.8"
38291
38450
  }
38292
38451
  };
@@ -38328,18 +38487,26 @@ async function resolveRefreshToken() {
38328
38487
  var __dirname = dirname(fileURLToPath(import.meta.url));
38329
38488
  await loadDotenvSafely({ path: join2(__dirname, "..", ".env"), override: false });
38330
38489
  var MOBILE_BASE_URL = "https://mobile-api.zola.com";
38490
+ var SESSION_REFRESH_BUFFER_MS = 5 * 60 * 1e3;
38331
38491
  var ZolaClient = class {
38332
- sessionToken = null;
38333
- sessionExpiry = null;
38334
38492
  cachedContext = null;
38335
38493
  // WAF requires x-zola-session-id on all mobile-api.zola.com requests
38336
38494
  deviceSessionId = crypto.randomUUID().toUpperCase();
38495
+ // The `ZOLA_SESSION_TOKEN` seed is a cold-start-only shortcut, exactly as the
38496
+ // old `ensureSession` gated it on `sessionToken === null`; a 401 re-mint goes
38497
+ // straight to `refresh()`, never back to the env token.
38498
+ triedEnvSeed = false;
38499
+ // Single-flight cached mint: caches the 30-min session JWT until 5 min before
38500
+ // its `exp`, coalesces concurrent mints, and re-mints on demand after a 401.
38501
+ session = createCachedTokenSource({
38502
+ mint: () => this.mintSession(),
38503
+ bufferMs: SESSION_REFRESH_BUFFER_MS
38504
+ });
38337
38505
  /**
38338
38506
  * Make a request to the Zola mobile API (mobile-api.zola.com).
38339
38507
  * Uses Bearer JWT auth with x-zola-session-id header.
38340
38508
  */
38341
38509
  async requestMobile(method, path, body) {
38342
- await this.ensureSession();
38343
38510
  return this.doRequest(method, path, body);
38344
38511
  }
38345
38512
  /**
@@ -38349,7 +38516,6 @@ var ZolaClient = class {
38349
38516
  * raw bytes and the server's content-type so callers can pass it through.
38350
38517
  */
38351
38518
  async requestMobileBinary(method, path, body) {
38352
- await this.ensureSession();
38353
38519
  const response = await this.sendWithRetry(method, path, body, false, false, "*/*");
38354
38520
  const contentType = response.headers.get("content-type") ?? "application/octet-stream";
38355
38521
  const bytes = new Uint8Array(await response.arrayBuffer());
@@ -38393,10 +38559,11 @@ var ZolaClient = class {
38393
38559
  return text ? JSON.parse(text) : null;
38394
38560
  }
38395
38561
  async sendWithRetry(method, path, body, isAuthRetry = false, isRateRetry = false, accept = "application/json") {
38396
- const sessionId = decodeJwtSessionId(this.sessionToken);
38562
+ const token = await this.session.getToken();
38563
+ const sessionId = decodeJwtSessionId(token);
38397
38564
  const headers = {
38398
38565
  accept,
38399
- authorization: `Bearer ${this.sessionToken}`,
38566
+ authorization: `Bearer ${token}`,
38400
38567
  "x-zola-platform-type": "iphone_app",
38401
38568
  "x-zola-session-id": this.deviceSessionId,
38402
38569
  "user-agent": "Zola/42.5.0 (iPad; iOS 26.4; Scale/2.0)",
@@ -38409,9 +38576,7 @@ var ZolaClient = class {
38409
38576
  ...body !== void 0 ? { body: JSON.stringify(body) } : {}
38410
38577
  });
38411
38578
  if (response.status === 401 && !isAuthRetry) {
38412
- this.sessionToken = null;
38413
- this.sessionExpiry = null;
38414
- await this.refresh();
38579
+ this.session.invalidate();
38415
38580
  return this.sendWithRetry(method, path, body, true, isRateRetry, accept);
38416
38581
  }
38417
38582
  if (response.status === 429) {
@@ -38429,30 +38594,33 @@ var ZolaClient = class {
38429
38594
  }
38430
38595
  return response;
38431
38596
  }
38432
- async ensureSession() {
38433
- if (this.sessionToken && this.sessionExpiry) {
38434
- if (this.sessionExpiry.getTime() - Date.now() > 5 * 60 * 1e3) return;
38435
- }
38436
- if (this.sessionToken === null) {
38597
+ /**
38598
+ * Mint a session token for the cache. On the very first mint, use a valid
38599
+ * `ZOLA_SESSION_TOKEN` if present (a cold-start shortcut that skips the
38600
+ * initial refresh); otherwise, and on every later mint, refresh via the
38601
+ * mobile API. Returns `{ token, expiresAt }` so the cache serves it until
38602
+ * `SESSION_REFRESH_BUFFER_MS` before the JWT's `exp`.
38603
+ */
38604
+ async mintSession() {
38605
+ if (!this.triedEnvSeed) {
38606
+ this.triedEnvSeed = true;
38437
38607
  const envSession = readEnvVar("ZOLA_SESSION_TOKEN");
38438
38608
  if (envSession) {
38439
38609
  try {
38440
38610
  const exp = decodeJwtExp(envSession);
38441
- if (exp * 1e3 - Date.now() > 5 * 60 * 1e3) {
38442
- this.sessionToken = envSession;
38443
- this.sessionExpiry = new Date(exp * 1e3);
38444
- return;
38611
+ if (exp * 1e3 - Date.now() > SESSION_REFRESH_BUFFER_MS) {
38612
+ return { token: envSession, expiresAt: exp * 1e3 };
38445
38613
  }
38446
38614
  } catch {
38447
38615
  }
38448
38616
  }
38449
38617
  }
38450
- await this.refresh();
38618
+ return this.refresh();
38451
38619
  }
38452
38620
  /**
38453
38621
  * Refresh the session using the mobile API endpoint.
38454
38622
  * POST /v3/sessions/refresh with the refresh token JWT.
38455
- * Returns a new session_token (30-min) and optionally a new refresh_token.
38623
+ * Returns a new session_token (30-min) as a {@link MintedToken}.
38456
38624
  *
38457
38625
  * The refresh token comes from `resolveRefreshToken()`, which tries
38458
38626
  * (1) ZOLA_REFRESH_TOKEN env var, then (2) the fetchproxy 0.3.0 extension's
@@ -38481,8 +38649,7 @@ To fix: set ZOLA_REFRESH_TOKEN, or install the fetchproxy extension and sign int
38481
38649
  const result = await response.json();
38482
38650
  const { session_token } = result.data;
38483
38651
  const exp = decodeJwtExp(session_token);
38484
- this.sessionToken = session_token;
38485
- this.sessionExpiry = new Date(exp * 1e3);
38652
+ return { token: session_token, expiresAt: exp * 1e3 };
38486
38653
  }
38487
38654
  };
38488
38655
  var client = new ZolaClient();
@@ -40448,7 +40615,7 @@ function registerEventInvitationTools(server) {
40448
40615
  }
40449
40616
 
40450
40617
  // src/index.ts
40451
- var VERSION = "1.4.3";
40618
+ var VERSION = "1.5.0";
40452
40619
  await runMcp({
40453
40620
  name: "zola-mcp",
40454
40621
  version: VERSION,
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
- import { readEnvVar, loadDotenvSafely, decodeJwtExp, decodeJwtSessionId, formatApiError, truncateErrorMessage, } from '@chrischall/mcp-utils';
3
+ import { readEnvVar, loadDotenvSafely, decodeJwtExp, decodeJwtSessionId, formatApiError, truncateErrorMessage, createCachedTokenSource, } from '@chrischall/mcp-utils';
4
4
  import { resolveRefreshToken } from './auth.js';
5
5
  // Load `.env` next to the compiled entry point. `loadDotenvSafely` is a
6
6
  // no-throw loader: in bundled mode (no resolvable `dotenv`) it returns false
@@ -8,18 +8,29 @@ import { resolveRefreshToken } from './auth.js';
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
10
10
  const MOBILE_BASE_URL = 'https://mobile-api.zola.com';
11
+ // Refresh the session token this long before its JWT `exp` — the same 5-minute
12
+ // comfort margin the hand-rolled cache used for both the "still valid" check and
13
+ // the `ZOLA_SESSION_TOKEN` seed's freshness gate.
14
+ const SESSION_REFRESH_BUFFER_MS = 5 * 60 * 1000;
11
15
  export class ZolaClient {
12
- sessionToken = null;
13
- sessionExpiry = null;
14
16
  cachedContext = null;
15
17
  // WAF requires x-zola-session-id on all mobile-api.zola.com requests
16
18
  deviceSessionId = crypto.randomUUID().toUpperCase();
19
+ // The `ZOLA_SESSION_TOKEN` seed is a cold-start-only shortcut, exactly as the
20
+ // old `ensureSession` gated it on `sessionToken === null`; a 401 re-mint goes
21
+ // straight to `refresh()`, never back to the env token.
22
+ triedEnvSeed = false;
23
+ // Single-flight cached mint: caches the 30-min session JWT until 5 min before
24
+ // its `exp`, coalesces concurrent mints, and re-mints on demand after a 401.
25
+ session = createCachedTokenSource({
26
+ mint: () => this.mintSession(),
27
+ bufferMs: SESSION_REFRESH_BUFFER_MS,
28
+ });
17
29
  /**
18
30
  * Make a request to the Zola mobile API (mobile-api.zola.com).
19
31
  * Uses Bearer JWT auth with x-zola-session-id header.
20
32
  */
21
33
  async requestMobile(method, path, body) {
22
- await this.ensureSession();
23
34
  return this.doRequest(method, path, body);
24
35
  }
25
36
  /**
@@ -29,7 +40,6 @@ export class ZolaClient {
29
40
  * raw bytes and the server's content-type so callers can pass it through.
30
41
  */
31
42
  async requestMobileBinary(method, path, body) {
32
- await this.ensureSession();
33
43
  const response = await this.sendWithRetry(method, path, body, false, false, '*/*');
34
44
  const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
35
45
  const bytes = new Uint8Array(await response.arrayBuffer());
@@ -75,10 +85,14 @@ export class ZolaClient {
75
85
  return (text ? JSON.parse(text) : null);
76
86
  }
77
87
  async sendWithRetry(method, path, body, isAuthRetry = false, isRateRetry = false, accept = 'application/json') {
78
- const sessionId = decodeJwtSessionId(this.sessionToken);
88
+ // Current session token from the cache (mints/refreshes as needed). The
89
+ // per-request `x-zola-user-session-id` is derived from it every call, so a
90
+ // re-minted token below carries its own session id on the replay.
91
+ const token = await this.session.getToken();
92
+ const sessionId = decodeJwtSessionId(token);
79
93
  const headers = {
80
94
  accept,
81
- authorization: `Bearer ${this.sessionToken}`,
95
+ authorization: `Bearer ${token}`,
82
96
  'x-zola-platform-type': 'iphone_app',
83
97
  'x-zola-session-id': this.deviceSessionId,
84
98
  'user-agent': 'Zola/42.5.0 (iPad; iOS 26.4; Scale/2.0)',
@@ -92,9 +106,8 @@ export class ZolaClient {
92
106
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
93
107
  });
94
108
  if (response.status === 401 && !isAuthRetry) {
95
- this.sessionToken = null;
96
- this.sessionExpiry = null;
97
- await this.refresh();
109
+ // Drop the cached token so the replay's getToken re-mints via refresh().
110
+ this.session.invalidate();
98
111
  return this.sendWithRetry(method, path, body, true, isRateRetry, accept);
99
112
  }
100
113
  if (response.status === 429) {
@@ -110,22 +123,24 @@ export class ZolaClient {
110
123
  }
111
124
  return response;
112
125
  }
113
- async ensureSession() {
114
- // Session still valid with comfortable margin
115
- if (this.sessionToken && this.sessionExpiry) {
116
- if (this.sessionExpiry.getTime() - Date.now() > 5 * 60 * 1000)
117
- return;
118
- }
119
- // Check for session token in env (first load only)
120
- if (this.sessionToken === null) {
126
+ /**
127
+ * Mint a session token for the cache. On the very first mint, use a valid
128
+ * `ZOLA_SESSION_TOKEN` if present (a cold-start shortcut that skips the
129
+ * initial refresh); otherwise, and on every later mint, refresh via the
130
+ * mobile API. Returns `{ token, expiresAt }` so the cache serves it until
131
+ * `SESSION_REFRESH_BUFFER_MS` before the JWT's `exp`.
132
+ */
133
+ async mintSession() {
134
+ // Cold-start seed: use ZOLA_SESSION_TOKEN if present and comfortably
135
+ // unexpired. Only attempted once — a 401 re-mint always goes to refresh().
136
+ if (!this.triedEnvSeed) {
137
+ this.triedEnvSeed = true;
121
138
  const envSession = readEnvVar('ZOLA_SESSION_TOKEN');
122
139
  if (envSession) {
123
140
  try {
124
141
  const exp = decodeJwtExp(envSession);
125
- if (exp * 1000 - Date.now() > 5 * 60 * 1000) {
126
- this.sessionToken = envSession;
127
- this.sessionExpiry = new Date(exp * 1000);
128
- return;
142
+ if (exp * 1000 - Date.now() > SESSION_REFRESH_BUFFER_MS) {
143
+ return { token: envSession, expiresAt: exp * 1000 };
129
144
  }
130
145
  }
131
146
  catch {
@@ -133,12 +148,12 @@ export class ZolaClient {
133
148
  }
134
149
  }
135
150
  }
136
- await this.refresh();
151
+ return this.refresh();
137
152
  }
138
153
  /**
139
154
  * Refresh the session using the mobile API endpoint.
140
155
  * POST /v3/sessions/refresh with the refresh token JWT.
141
- * Returns a new session_token (30-min) and optionally a new refresh_token.
156
+ * Returns a new session_token (30-min) as a {@link MintedToken}.
142
157
  *
143
158
  * The refresh token comes from `resolveRefreshToken()`, which tries
144
159
  * (1) ZOLA_REFRESH_TOKEN env var, then (2) the fetchproxy 0.3.0 extension's
@@ -168,8 +183,7 @@ export class ZolaClient {
168
183
  const result = (await response.json());
169
184
  const { session_token } = result.data;
170
185
  const exp = decodeJwtExp(session_token);
171
- this.sessionToken = session_token;
172
- this.sessionExpiry = new Date(exp * 1000);
186
+ return { token: session_token, expiresAt: exp * 1000 };
173
187
  }
174
188
  }
175
189
  export const client = new ZolaClient();
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { registerWebsiteThemeTools } from './tools/website-theme.js';
12
12
  import { registerRegistryItemTools } from './tools/registry-items.js';
13
13
  import { registerInvitationTools } from './tools/invitations.js';
14
14
  import { registerEventInvitationTools } from './tools/event-invitations.js';
15
- const VERSION = '1.4.3'; // x-release-please-version
15
+ const VERSION = '1.5.0'; // x-release-please-version
16
16
  await runMcp({
17
17
  name: 'zola-mcp',
18
18
  version: VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zola-mcp",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "mcpName": "io.github.chrischall/zola-mcp",
5
5
  "description": "Zola wedding MCP server for Claude",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -46,7 +46,7 @@
46
46
  "test:watch": "vitest"
47
47
  },
48
48
  "dependencies": {
49
- "@chrischall/mcp-utils": "^0.10.3",
49
+ "@chrischall/mcp-utils": "^0.13.0",
50
50
  "@fetchproxy/bootstrap": "^1.3.0",
51
51
  "@fetchproxy/server": "^1.3.0",
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -57,7 +57,7 @@
57
57
  "@types/node": "^26.0.0",
58
58
  "@vitest/coverage-v8": "^4.1.8",
59
59
  "esbuild": "^0.28.0",
60
- "typescript": "^6.0.3",
60
+ "typescript": "^7.0.2",
61
61
  "vitest": "^4.1.8"
62
62
  }
63
63
  }
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/zola-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.4.3",
9
+ "version": "1.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "zola-mcp",
14
- "version": "1.4.3",
14
+ "version": "1.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -0,0 +1,120 @@
1
+ ---
2
+ name: zola-api
3
+ description: >-
4
+ Query or update Zola wedding-planning data (vendors, budget, guests, seating,
5
+ events/RSVPs, registry, gift tracker, inquiries, wedding website) straight
6
+ from a shell with curl against mobile-api.zola.com, instead of running the
7
+ zola-mcp server. Use when you want Zola data without the MCP, in a script,
8
+ or on a machine where the MCP isn't installed. Triggers on "check Zola",
9
+ "Zola vendors/budget/guests/RSVP/seating/registry", or any Zola wedding
10
+ data request that should hit the API directly.
11
+ ---
12
+
13
+ # Zola mobile API via curl (no MCP)
14
+
15
+ Zola's mobile API (`mobile-api.zola.com` — the same surface the iOS/iPad app
16
+ and `zola-mcp` use) is a plain Bearer-JWT REST API reachable directly from a
17
+ server or shell — no browser bridge needed. This skill shells out to `curl`
18
+ with the JWT in an `Authorization: Bearer` header, exactly as
19
+ `zola-mcp`'s `src/client.ts` does.
20
+
21
+ ## One-time setup: get the refresh token
22
+
23
+ You need Zola's long-lived (~1 year) refresh JWT — the `usr` cookie from a
24
+ signed-in `zola.com` session. Same credential `zola-mcp` uses:
25
+
26
+ ```sh
27
+ # Prefer the env var zola-mcp itself reads (check its .env first):
28
+ grep -h ZOLA_REFRESH_TOKEN ~/git/zola-mcp/.env 2>/dev/null
29
+ export ZOLA_REFRESH_TOKEN='eyJhbGciOi...' # or export directly if you have it
30
+ ```
31
+
32
+ If you don't have it yet: sign into zola.com, open DevTools → Application →
33
+ Cookies → `https://www.zola.com`, copy the `usr` value. (`zola-mcp` also has a
34
+ fetchproxy fallback that reads this cookie from a signed-in browser tab — see
35
+ its README — but that's the MCP's path, not this skill's; this skill assumes
36
+ you already have the token in hand.)
37
+
38
+ ## Core call pattern
39
+
40
+ Every mobile-api call needs a short-lived (30 min) **session token**, minted
41
+ from the refresh token, plus a fixed set of headers (CloudFront WAF requires
42
+ `x-zola-session-id` on every request — omit it and you get a 403).
43
+
44
+ ```sh
45
+ BASE=https://mobile-api.zola.com
46
+ DEVICE_SESSION_ID=$(uuidgen | tr 'a-z' 'A-Z') # one per "session"; reuse across calls
47
+ UA='Zola/42.5.0 (iPad; iOS 26.4; Scale/2.0)'
48
+
49
+ # 1. Mint a 30-min session token from the refresh token
50
+ SESSION_TOKEN=$(curl -sS -X POST "$BASE/v3/sessions/refresh" \
51
+ -H 'content-type: application/json' -H 'accept: application/json' \
52
+ -H "x-zola-platform-type: iphone_app" -H "x-zola-session-id: $DEVICE_SESSION_ID" \
53
+ -H "user-agent: $UA" \
54
+ -d "{\"token\":\"$ZOLA_REFRESH_TOKEN\"}" | jq -r '.data.session_token')
55
+
56
+ # 2. Every subsequent call reuses $SESSION_TOKEN until it expires (~30 min),
57
+ # then re-run step 1. All calls carry the same 4 headers:
58
+ curl -sS -X GET "$BASE/v3/users/me/context" \
59
+ -H "authorization: Bearer $SESSION_TOKEN" \
60
+ -H "x-zola-platform-type: iphone_app" -H "x-zola-session-id: $DEVICE_SESSION_ID" \
61
+ -H "user-agent: $UA" | jq '.data'
62
+ ```
63
+
64
+ Wrap this in a shell function or export the 4 headers once — every recipe in
65
+ `references/mobile-api-endpoints.md` reuses `$BASE`, `$SESSION_TOKEN`,
66
+ `$DEVICE_SESSION_ID`, `$UA`. POST/PUT bodies additionally need
67
+ `-H 'content-type: application/json'`.
68
+
69
+ (The app also sends a fifth header, `x-zola-user-session-id`, derived from a
70
+ claim inside the session JWT — `zola-mcp` only attaches it when the decode
71
+ succeeds, so it's not load-bearing; every endpoint below works without it.)
72
+
73
+ ## The one rule: resolve context first
74
+
75
+ Most write/list endpoints are scoped by numeric IDs, never inferred — call
76
+ `GET /v3/users/me/context` once per session and keep the three IDs around:
77
+
78
+ ```sh
79
+ CTX=$(curl -sS "$BASE/v3/users/me/context" -H "authorization: Bearer $SESSION_TOKEN" \
80
+ -H "x-zola-platform-type: iphone_app" -H "x-zola-session-id: $DEVICE_SESSION_ID" -H "user-agent: $UA")
81
+ ACCT=$(jq -r '.data.wedding_account.wedding_account_id' <<<"$CTX")
82
+ REG=$(jq -r '.data.registry.id' <<<"$CTX")
83
+ WEDDING_ID=$(jq -r '.data.wedding.wedding_id' <<<"$CTX")
84
+ ```
85
+
86
+ `ACCT` (`wedding_account_id`) feeds guest/event/website-content paths; `REG`
87
+ (`registry_id`) feeds registry/gift-tracker paths.
88
+
89
+ ## Ready-to-run endpoints
90
+
91
+ `references/mobile-api-endpoints.md` has real, ready-to-run `curl` + `jq`
92
+ recipes for every one of the 30 `zola-mcp` tools' underlying calls
93
+ (vendors, budget, guests, seating, inquiries, events/RSVPs/gifts/registry,
94
+ discover/storefronts, registry items, invitations/card-projects, website
95
+ pages, website theme/customization, website content). Transcribed straight
96
+ from `src/tools/*.ts` — same paths, same request bodies.
97
+
98
+ ## Mutation gotcha: most writes are read-modify-write
99
+
100
+ Zola's write endpoints replace whole objects, not deltas — the same tools
101
+ that build a request first re-GET the current record and only patch the
102
+ requested fields, or a full-state write can wipe unrelated fields.
103
+ `docs/zola-api-quirks.md` in this repo documents the worst offenders (a
104
+ `header_font` write nulling every other website color; guest writes needing
105
+ to preserve `event_invitations` verbatim or lose them). Follow the
106
+ read-modify-write shape shown per-endpoint in the references file — don't
107
+ send a bare partial body to `PUT`/`POST` write endpoints.
108
+
109
+ ## Output / error contract
110
+
111
+ - A 2xx body is `{"data": ...}` for nearly every endpoint — pipe to
112
+ `jq '.data'`.
113
+ - `401` — session token expired; re-mint via `POST /v3/sessions/refresh`
114
+ (step 1 above) and retry once.
115
+ - `429` — rate limited; back off ~2s and retry once.
116
+ - Any other non-2xx — the body is a JSON error envelope; read it directly,
117
+ it is not redacted here (unlike the MCP, which redacts secrets from error
118
+ text — don't paste raw error bodies containing the session token anywhere
119
+ public).
120
+ - This project (`zola-mcp`) is developed and maintained by AI (Claude).
@@ -0,0 +1,444 @@
1
+ # Zola mobile-api endpoints (curl + jq)
2
+
3
+ All paths are relative to `$BASE=https://mobile-api.zola.com`. Every call
4
+ carries `authorization: Bearer $SESSION_TOKEN`, `x-zola-platform-type:
5
+ iphone_app`, `x-zola-session-id: $DEVICE_SESSION_ID`, `user-agent: $UA` (see
6
+ `SKILL.md`); POST/PUT/DELETE-with-body also need `-H 'content-type:
7
+ application/json'`. `$ACCT` = `wedding_account_id`, `$REG` = `registry_id`,
8
+ `$WEDDING_ID` = `wedding_id`, all three from `GET /v3/users/me/context` (see
9
+ `SKILL.md`'s "resolve context first" section). Response envelope is `{"data": ...}`
10
+ unless noted. Paths/bodies below are transcribed from `src/tools/*.ts` —
11
+ each section names its source file.
12
+
13
+ Shorthand used below:
14
+
15
+ ```sh
16
+ H=(-H "authorization: Bearer $SESSION_TOKEN" -H "x-zola-platform-type: iphone_app" \
17
+ -H "x-zola-session-id: $DEVICE_SESSION_ID" -H "user-agent: $UA")
18
+ HJ=("${H[@]}" -H 'content-type: application/json')
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Context (`src/client.ts`)
24
+
25
+ ```sh
26
+ curl -sS "${H[@]}" "$BASE/v3/users/me/context" | jq '.data'
27
+ # .data.user.id, .data.wedding_account.wedding_account_id,
28
+ # .data.wedding.{wedding_id,wedding_date,slug}, .data.registry.id
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Vendors (`src/tools/vendors.ts`)
34
+
35
+ **List booked vendors** — `POST /v3/account-vendors/booked-list` body `{}`:
36
+
37
+ ```sh
38
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/account-vendors/booked-list" -d '{}' \
39
+ | jq '.data.booked_vendors'
40
+ ```
41
+
42
+ **Search vendors (typeahead)** — `POST /v3/reference-vendors/typeahead-taxonomy`:
43
+
44
+ ```sh
45
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/reference-vendors/typeahead-taxonomy" \
46
+ -d '{"query":"Acme Photography","taxonomy_key":"wedding-photographers"}' | jq '.data'
47
+ # taxonomy_key default: wedding-venues. Other keys: wedding-planners, wedding-bands-djs, ...
48
+ ```
49
+
50
+ **Book a vendor** — find an unbooked slot from `booked-list` for the
51
+ `vendor_type`, then `PUT /v5/account-vendors/vendor` (note: **v5**, not v3):
52
+
53
+ ```sh
54
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v5/account-vendors/vendor" -d '{
55
+ "uuid": "<slot-uuid-from-booked-list>", "id": 0, "vendor_type": "PHOTOGRAPHER",
56
+ "booked": true, "booking_source": "BOOKED_VENDORS",
57
+ "price_cents": 350000, "event_date": null,
58
+ "sync_with_budget_tool_enabled": true, "facet_keys": [],
59
+ "reference_vendor_request": {
60
+ "id": null, "name": "Acme Photography", "email": null, "phone": null,
61
+ "address": {"city": "Charlotte", "state_province_region": "NC"}
62
+ }
63
+ }' | jq '.data'
64
+ ```
65
+
66
+ **Update a booked vendor** — same `PUT /v5/account-vendors/vendor`, but
67
+ read-modify-write: GET `booked-list`, find by `uuid`, keep `id`/`vendor_type`,
68
+ only patch the fields you're changing (name/city/state/email/price/date
69
+ default to the current value).
70
+
71
+ **Unbook a vendor** — `POST /v3/account-vendors/vendor/unbook`:
72
+
73
+ ```sh
74
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/account-vendors/vendor/unbook" \
75
+ -d '{"uuid":"<vendor-uuid>"}'
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Budget (`src/tools/budget.ts`)
81
+
82
+ **Get budget** — `GET /v3/budgets`:
83
+
84
+ ```sh
85
+ curl -sS "${H[@]}" "$BASE/v3/budgets" | jq '{
86
+ budgeted_cents: .data.budgeted_cents, cost_cents: .data.cost_cents,
87
+ paid_cents: .data.paid_cents, balance_due_cents: .data.balance_due_cents,
88
+ items: [.data.taxonomy_nodes[].items[] | {uuid, title, cost_cents, paid_cents}]
89
+ }'
90
+ ```
91
+
92
+ **Update a budget item** — read-modify-write: `GET /v3/budgets`, find the item
93
+ by `uuid`, then `PUT /v3/budgets/items` with every required field (server
94
+ rejects a bare partial):
95
+
96
+ ```sh
97
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/budgets/items" -d '{
98
+ "item_uuid": "<uuid>", "taxonomy_node_uuid": "<from-get>",
99
+ "estimated_cost_cents": <from-get>, "actual_cost_cents": 250000,
100
+ "note": "Deposit paid", "item_type": "<from-get, e.g. VENUE>",
101
+ "title": "<from-get>"
102
+ }' | jq '.data'
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Guests (`src/tools/guests.ts`)
108
+
109
+ **List guests** — `POST /v3/guestlists/directory/wedding-accounts/$ACCT` body
110
+ `{"sort_by_name_asc": true}`. Guest shape is **flat** (fields directly on the
111
+ guest object, no `{guest:{...}}` wrapper):
112
+
113
+ ```sh
114
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/guestlists/directory/wedding-accounts/$ACCT" \
115
+ -d '{"sort_by_name_asc": true}' \
116
+ | jq '.data.guest_groups[] | {guest_group_id, guests: [.guests[] | {guest_id, first_name, family_name, rsvp}]}'
117
+ ```
118
+
119
+ **Add a guest group** — `POST /v3/guestlists/groups`:
120
+
121
+ ```sh
122
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/guestlists/groups" -d '{
123
+ "wedding_account_id": '"$ACCT"',
124
+ "guests": [{
125
+ "first_name": "Mike", "family_name": "Smith", "relationship_type": "PRIMARY",
126
+ "source": "IOS", "email_address": "", "mobile_phone": "", "affiliation": "PRIMARY_FRIEND",
127
+ "tier": "A", "country_code": "US", "prefix": "", "middle_name": "", "suffix": "",
128
+ "home_phone": "", "address1": "", "address2": "", "city": "", "state_province": "",
129
+ "postal_code": "", "event_invitations": [], "tags": []
130
+ }],
131
+ "guest_group_affiliation": "PRIMARY_FRIEND", "guest_group_tier": "A",
132
+ "guest_group_uuid": "", "envelope_recipient": "", "invited": true,
133
+ "invitation_sent": false, "save_the_date_sent": false,
134
+ "rsvp_question_answers": [], "gift_count": 0
135
+ }' | jq '.data'
136
+ ```
137
+
138
+ **Update a guest group's address** — read-modify-write via
139
+ `PUT /v3/guestlists/groups/wedding-accounts/$ACCT/bulk/directory`. **Must**
140
+ preserve each guest's existing `event_invitations` verbatim (a group that
141
+ sends `event_invitations: []` wipes them, per `docs/zola-api-quirks.md` §5):
142
+
143
+ ```sh
144
+ # 1. GET the directory (above), find the target group by guest_group_id,
145
+ # keep its `guests[]` array as-is except the address fields you're changing.
146
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/guestlists/groups/wedding-accounts/$ACCT/bulk/directory" \
147
+ -d '{"updated_guest_groups": [ <full-group-object-with-patched-guest-addresses> ]}' \
148
+ | jq '.data'
149
+ ```
150
+
151
+ **Remove a guest group** — `PUT /v3/guestlists/groups/wedding-accounts/$ACCT/delete`:
152
+
153
+ ```sh
154
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/guestlists/groups/wedding-accounts/$ACCT/delete" \
155
+ -d '{"wedding_account_id": '"$ACCT"', "guest_group_ids": [<id>]}'
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Seating (`src/tools/seating.ts`)
161
+
162
+ ```sh
163
+ curl -sS "${H[@]}" "$BASE/v3/seating-charts/summaries" | jq '.' # NOT wrapped in `data`
164
+ curl -sS "${H[@]}" "$BASE/v3/seating-charts/<chart-uuid>" | jq '.'
165
+ ```
166
+
167
+ **Unseated guests** — `POST /v3/guestlists/directory/wedding-accounts/$ACCT`
168
+ (same call as guests list), filter client-side for
169
+ `.guests[].seating_chart_seat == null`.
170
+
171
+ **Assign a seat** — `PUT /v3/seating-charts/seats`:
172
+
173
+ ```sh
174
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/seating-charts/seats" -d '{
175
+ "guest_uuid": "<guest-uuid>", "seat_uuid": "<seat-uuid>",
176
+ "table_uuid": "<table-uuid>", "seating_chart_uuid": "<chart-uuid>"
177
+ }' | jq '.'
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Inquiries (`src/tools/inquiries.ts`)
183
+
184
+ ```sh
185
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/inquiries/unified-inquiries" -d '{}' \
186
+ | jq '.data[].inquiry_summaries[] | {inquiry_uuid, vendor_name: .vendor_card.vendor_name, unread, status_text}'
187
+
188
+ curl -sS "${H[@]}" "$BASE/v3/inquiries/<inquiry-uuid>/conversation" | jq '.data.messages'
189
+
190
+ curl -sS "${H[@]}" -X PUT "$BASE/v3/inquiries/<inquiry-uuid>/conversation/read"
191
+ ```
192
+
193
+ ---
194
+
195
+ ## Events, RSVPs, gifts, registry (`src/tools/events.ts`)
196
+
197
+ ```sh
198
+ curl -sS "${H[@]}" "$BASE/v3/websites/events/wedding-accounts/$ACCT/groups" \
199
+ | jq '.data[].events[]' # event_entity_id, name, start_at, num_guests_*
200
+
201
+ curl -sS "${H[@]}" "$BASE/v3/websites/events/track-rsvps" | jq '.data.modules'
202
+
203
+ curl -sS "${H[@]}" "$BASE/v3/gift_tracker/$REG" | jq '.data | del(.info_modules)'
204
+
205
+ curl -sS "${H[@]}" "$BASE/v4/shop/registry?registry_id=$REG&updated_modules=true" | jq '.data'
206
+ ```
207
+
208
+ **Update an event** — read-modify-write: GET the groups above, find by
209
+ `event_entity_id`, then `PUT /v3/websites/events/{event_id}` with the full
210
+ event object (patch only the fields you're changing; everything else —
211
+ `type`, `uuid`, `wedding_account_id`, counts, `meal_options` — round-trips
212
+ from the GET):
213
+
214
+ ```sh
215
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/websites/events/<event_id>" -d '{
216
+ "event_entity_id": <event_id>, "uuid": "<from-get>", "wedding_account_id": '"$ACCT"',
217
+ "type": "<from-get>", "name": "Reception", "start_at": "<from-get>", "end_at": "<from-get>",
218
+ "timezone": "<from-get>", "venue_name": "The Grand Hall", "address1": "", "address2": "",
219
+ "city": "Charlotte", "state_province": "NC", "postal_code": "", "country_code": "US",
220
+ "note": "", "attire": "Black tie", "collect_rsvps": true, "public": <from-get>,
221
+ "display_order": 0, "num_guests_attending": <from-get>, "num_guests_declined": <from-get>,
222
+ "num_guests_not_responded": <from-get>, "meal_options": <from-get>,
223
+ "rsvp_questions": [], "add_booked_vendor": false
224
+ }' | jq '.data'
225
+ ```
226
+
227
+ **Event invitations (per-guest, read-modify-write)** — from
228
+ `src/tools/event-invitations.ts`. Each guest carries an `event_invitations`
229
+ array; invite = append `{"event_id":<id>,"id":null,"rsvp_type":"NO_RESPONSE"}`,
230
+ uninvite = drop that element, **preserve every other element verbatim**
231
+ (same wipe risk as the guest-address write above). Write through the same
232
+ `PUT /v3/guestlists/groups/wedding-accounts/$ACCT/bulk/directory` used for
233
+ guest writes, sending `{"updated_guest_groups": [...]}` with full group
234
+ objects (each guest keeps its full shape, only `event_invitations` patched).
235
+
236
+ ---
237
+
238
+ ## Discover / storefronts (`src/tools/discover.ts`)
239
+
240
+ ```sh
241
+ curl -sS "${H[@]}" "$BASE/v4/your-wedding" | jq '.data'
242
+
243
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/storefronts/search" -d '{
244
+ "taxonomy_node_id": 2, "city": "Charlotte", "state": "NC",
245
+ "limit": 24, "offset": 0, "facets": {},
246
+ "metro_types": ["HOME","HOME_SERVICE","AWAY"], "metros": [],
247
+ "exclude_inquired_storefronts": false, "exclude_booked_storefronts": false,
248
+ "boost_featured_storefronts": false, "suggested_vendors_for_inquiry_limit": 12
249
+ }' | jq '.data'
250
+ # taxonomy_node_id: 1=Venues 2=Photographers 3=Florists 7=Planners 9=Bands/DJs
251
+
252
+ curl -sS "${H[@]}" "$BASE/v3/storefronts/<storefront-uuid>" | jq '.data'
253
+ curl -sS "${H[@]}" "$BASE/v3/favorites/" | jq '.data'
254
+ ```
255
+
256
+ ---
257
+
258
+ ## Registry items (`src/tools/registry-items.ts`)
259
+
260
+ ```sh
261
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/categories/<category_id>/entities" \
262
+ -d '{"offset":0,"limit":50,"registry_id":"'"$REG"'"}' | jq '.data'
263
+
264
+ # Default collection id: `.data.default_collection_id` from the registry GET
265
+ # above, or the first `groups[].modules[]` entry with `type == "COLLECTION"`.
266
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/registries/$REG/collections/<collection_id>" \
267
+ -d '{"sku_id":"<sku>","quantity":1,"most_wanted":false,"enable_group_gifting":false}' \
268
+ | jq '.data'
269
+
270
+ # Full replace — all fields required:
271
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/registries/$REG/items/<collection_item_id>" -d '{
272
+ "quantity": 2, "group_gift": false, "marked_fulfilled": false,
273
+ "personal_note": "", "most_wanted": true, "collection_id": "<collection_id>"
274
+ }' | jq '.data'
275
+
276
+ curl -sS "${H[@]}" -X DELETE "$BASE/v3/registries/$REG/items/<collection_item_id>"
277
+ ```
278
+
279
+ ---
280
+
281
+ ## Website pages & settings (`src/tools/website.ts`)
282
+
283
+ ```sh
284
+ curl -sS "${H[@]}" "$BASE/v3/websites/pages/wedding-accounts/full" | jq '.data'
285
+
286
+ curl -sS "${H[@]}" -X PUT "$BASE/v3/websites/pages/<page_id>/hidden/true" # or /false
287
+
288
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/websites/pages/wedding-accounts/$ACCT/reorder" \
289
+ -d '{"ids": [<page_id_1>, <page_id_2>, ...]}' | jq '.data'
290
+
291
+ # Partial patch is fine here (unlike most write endpoints):
292
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/websites/pages-v2/<page_id>" \
293
+ -d '{"page_id": <page_id>, "title": "Our Story"}' | jq '.data'
294
+ ```
295
+
296
+ **Wedding settings** — read via `GET /v3/users/me/context` → `.data.wedding`;
297
+ write is read-modify-write (all fields required) to
298
+ `PUT /v3/weddings/{wedding_id}`:
299
+
300
+ ```sh
301
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/weddings/$WEDDING_ID" -d '{
302
+ "wedding_id": '"$WEDDING_ID"', "account_id": '"$ACCT"',
303
+ "slug": "<from-get>", "owner_first_name": "<from-get>", "owner_last_name": "<from-get>",
304
+ "partner_first_name": "<from-get>", "partner_last_name": "<from-get>",
305
+ "title": "Alex & Jordan", "wedding_date": "<from-get>", "hashtag": "#alexandjordan2026",
306
+ "enable_search_engine": true, "enable_search_zola": true,
307
+ "city": "Charlotte", "state_province": "NC", "guest_count": 150
308
+ }' | jq '.data'
309
+ ```
310
+
311
+ ---
312
+
313
+ ## Website theme & customization (`src/tools/website-theme.ts`)
314
+
315
+ ```sh
316
+ curl -sS "${H[@]}" "$BASE/v3/themes/current" | jq '.data'
317
+ curl -sS "${H[@]}" "$BASE/v3/websites/website-customizations/context" | jq '.data.current_style_customizations'
318
+
319
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/themes/search" \
320
+ -d '{"limit":50,"offset":0,"theme_layout_types":["MULTI_PAGE"]}' | jq '.data'
321
+
322
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/themes/current" \
323
+ -d '{"theme_key":"galata","theme_layout_type":"MULTI_PAGE"}' | jq '.data'
324
+ ```
325
+
326
+ **Update colors/fonts** — `POST /v3/websites/website-customizations/context`.
327
+ **Gotcha (see `docs/zola-api-quirks.md` §1–4):** changing `header_font`
328
+ (i.e. `header_font_family_id`) wipes every *other* active color unless you
329
+ re-send them in the same POST — GET the current state first and bundle its
330
+ non-null `accent_color` / `background_color` / `body.color` /
331
+ `navigation_customization.background_color` into the body. `body_font`'s
332
+ `font_family_id` only accepts `68` (Libre Baskerville) or `198` (Circular) —
333
+ any other value 500s. `header_color` / `nav_font_color` are **not** writable
334
+ on this API at all (web-api-only, cookie+CSRF auth — out of scope here).
335
+
336
+ ```sh
337
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/websites/website-customizations/context" -d '{
338
+ "accent_color": "C9A66B",
339
+ "background_color": "FFFFFF",
340
+ "body_font": {"color": "222222"},
341
+ "navigation_customization": {"background_color": "FFFFFF"}
342
+ }' | jq '.data'
343
+ ```
344
+
345
+ ---
346
+
347
+ ## Website content: FAQs / home sections / POIs / travel (`src/tools/website-content.ts`)
348
+
349
+ All four follow the same list/add/update/remove shape;
350
+ `{wedding_account_id: $ACCT}` and an `_entity_id: 0` (create) or the real id
351
+ (update) prefix every write body. Deletes share one path shape:
352
+ `DELETE /v3/websites/{pages}/{entities}/{entity_id}/wedding-accounts/$ACCT`
353
+ where `{pages}` is looked up per-type from
354
+ `GET /v3/websites/pages/wedding-accounts/full` (`.data.{home,faq,poi,travel}_page.page_id`).
355
+
356
+ ```sh
357
+ # FAQs
358
+ curl -sS "${H[@]}" "$BASE/v3/websites/faqs/wedding-accounts/$ACCT" | jq '.data'
359
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/websites/faqs" -d '{
360
+ "wedding_account_id": '"$ACCT"', "faq_entity_id": 0,
361
+ "question": "What is the dress code?", "answer": "Cocktail attire", "display_order": 0
362
+ }' | jq '.data'
363
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/websites/faqs/<faq_entity_id>" -d '{
364
+ "wedding_account_id": '"$ACCT"', "faq_entity_id": <faq_entity_id>,
365
+ "question": "...", "answer": "...", "display_order": 0
366
+ }' | jq '.data'
367
+ curl -sS "${H[@]}" -X DELETE "$BASE/v3/websites/pages/<faq_page_id>/entities/<faq_entity_id>/wedding-accounts/$ACCT"
368
+
369
+ # Home page story sections — same shape, path `/v3/websites/home-sections[...]`,
370
+ # id field `homepage_entity_id`, extra `hidden` boolean, `title`+`subtitle`+`description`.
371
+
372
+ # Points of interest — `/v3/websites/points-of-interest[...]`, id field
373
+ # `poi_entity_id`; fields: title, description, address1/2, city, state_province,
374
+ # postal_code, country_code, latitude, longitude, google_place_id, contact_phone, url.
375
+
376
+ # Travel items — `/v3/websites/travel[...]`, id field `travel_entity_id`;
377
+ # fields: type (HOTEL|FLIGHT|TRAIN|BUS|CAR|OTHER), name, note, code,
378
+ # address1/2, city, state_province, postal_code, country_code, latitude,
379
+ # longitude, google_place_id, contact_number, email_address, url, source
380
+ # (GOOGLE_PLACES|MANUAL), timezone, display_order.
381
+ ```
382
+
383
+ Home-section/POI/travel `list`/`add`/`update`/`remove` all mirror the FAQ
384
+ calls above 1:1 (same verbs, same `wedding_account_id` prefix, same delete
385
+ shape) — see `src/tools/website-content.ts` for the exact field names per
386
+ type if you need the full body.
387
+
388
+ ---
389
+
390
+ ## Invitations / card projects (`src/tools/invitations.ts`)
391
+
392
+ ```sh
393
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/card-projects/search_request" -d '{
394
+ "completed": false, "limit": 30, "card_suite_uuids": [], "card_suite_ids": [],
395
+ "offset": 0, "fetch_customizations": true, "include_deleted": false,
396
+ "medium": ["PAPER","MAGNET","DIGITAL"], "single_sample": false
397
+ }' | jq '.data'
398
+
399
+ curl -sS "${H[@]}" "$BASE/v3/card-projects/<project_uuid>" | jq '.data'
400
+ curl -sS "${H[@]}" "$BASE/v3/card-projects/<project_uuid>/validate" | jq '.data'
401
+ curl -sS "${H[@]}" "$BASE/v3/card-projects/<project_uuid>/project-guest-groups" | jq '.data'
402
+ curl -sS "${HJ[@]}" -X POST "$BASE/v4/card-catalog/suites/details/<suite_uuid>" | jq '.data'
403
+
404
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/card-catalog/search/faceted" -d '{
405
+ "lead_card_types": [], "include_updated_proof_module": true, "limit": 50, "offset": 0,
406
+ "digital_suite": false, "include_lead_card_type_metadata": true,
407
+ "lead_card_type": "INVITATION", "include_module": true
408
+ }' | jq '.data'
409
+
410
+ curl -sS "${H[@]}" "$BASE/v3/favorites/card-suites/" | jq '.data'
411
+ curl -sS "${H[@]}" "$BASE/v3/websites/rsvps/wedding-accounts/$ACCT" | jq '.data'
412
+
413
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/card-projects" -d '{
414
+ "quantity": 150, "lead_variation_uuid": "<variation-uuid>",
415
+ "extra_customizable": false, "account_id": '"$ACCT"', "suite_uuid": "<suite-uuid>"
416
+ }' | jq '.data'
417
+
418
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/card-projects/<project_uuid>" -d '{
419
+ "customizations": {"<customization_uuid>": {"variation_uuid": "<new-variation-uuid>"}}
420
+ }' | jq '.data'
421
+
422
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/card-projects/<project_uuid>/project-guest-groups" -d '{
423
+ "guest_group_requests": [{"guest_group_id": <id>, "enabled": true}]
424
+ }' | jq '.data'
425
+
426
+ curl -sS "${HJ[@]}" -X POST "$BASE/v3/card-templates/preview" -d '{
427
+ "variation_uuids": ["<variation-uuid>"], "customizable": true,
428
+ "substitutions": {"first_name": "Alex", "wedding_date": "2026-10-17"}
429
+ }' | jq '.data'
430
+ ```
431
+
432
+ **QR code** — preview returns raw image bytes (Content-Type lies as
433
+ `image/jpeg` even for a PNG — sniff magic bytes if scripting):
434
+
435
+ ```sh
436
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/card-projects/qrcode/preview" \
437
+ -H 'accept: */*' -d '{"dimension":"MEDIUM","url_type":"CUSTOM","enabled":true,"url":"https://example.com"}' \
438
+ -o qrcode.png
439
+
440
+ curl -sS "${HJ[@]}" -X PUT "$BASE/v3/card-projects/<project_uuid>/customization/page/<page_uuid>/qrcode" -d '{
441
+ "dimension": "MEDIUM", "url_type": "CUSTOM", "color": "000000",
442
+ "enabled": true, "url": "https://example.com"
443
+ }' | jq '.data'
444
+ ```