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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +198 -31
- package/dist/client.js +40 -26
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/skills/zola-api/SKILL.md +120 -0
- package/skills/zola-api/references/mobile-api-endpoints.md +444 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "Zola wedding planning tools for Claude Code",
|
|
10
|
-
"version": "1.
|
|
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.
|
|
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
|
+
"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.
|
|
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.
|
|
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: "^
|
|
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
|
|
38562
|
+
const token = await this.session.getToken();
|
|
38563
|
+
const sessionId = decodeJwtSessionId(token);
|
|
38397
38564
|
const headers = {
|
|
38398
38565
|
accept,
|
|
38399
|
-
authorization: `Bearer ${
|
|
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.
|
|
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
|
-
|
|
38433
|
-
|
|
38434
|
-
|
|
38435
|
-
|
|
38436
|
-
|
|
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() >
|
|
38442
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
96
|
-
this.
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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() >
|
|
126
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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": "^
|
|
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.
|
|
9
|
+
"version": "1.5.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "zola-mcp",
|
|
14
|
-
"version": "1.
|
|
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
|
+
```
|