@lacneu/wix-openclaw 0.2.2 → 0.3.1

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.
@@ -0,0 +1,836 @@
1
+ // Wix SEO tools — auditing a site's SEO, and managing its URL redirects.
2
+ //
3
+ // DEVELOPER PREVIEW, AND OPT-IN. Every endpoint used here is listed in Wix's
4
+ // Developer Preview: Wix may change them at any time and says they "shouldn't
5
+ // be used on live sites". `buildAllTools` registers them only when
6
+ // `config.enableSeoTools` is true, because four of them also write
7
+ // irreversibly to a live site — the two risks compound, and neither should be
8
+ // taken on someone's behalf by a default.
9
+ //
10
+ // Two APIs, two base paths:
11
+ // - Item/Site SEO tags: `/promote/seo/v1/...`
12
+ // - Redirects: `/seo-redirects-service/v1/...`
13
+ //
14
+ // Both sit behind ONE Wix permission, `SCOPE.PROMOTE.MANAGE-SEO`. Wix has no
15
+ // read-only SEO scope, so a key that can audit can also destroy redirects: the
16
+ // read/write boundary is this plugin's (`enableSeoTools` + approvals), not Wix's.
17
+ //
18
+ // `wix_seo_list_item_tags` is the entry point for an audit: it enumerates the
19
+ // items of one type — `STATIC_PAGE` gives the site's pages — each with the tags
20
+ // it sets itself, whether it sets any at all, and the tags it is expected to
21
+ // render with, marked with where each came from.
22
+ //
23
+ // TWO LIMITS THE MODEL MUST KNOW, so they live in the tool descriptions rather
24
+ // than only here:
25
+ //
26
+ // 1. For `STATIC_PAGE`, every read reflects the SAVED revision, never the
27
+ // published one, and `publishStatus` is always `PUBLISH_STATUS_UNSPECIFIED`.
28
+ // An audit that doesn't say so reports on a draft as if it were the live
29
+ // page.
30
+ // 2. `resolvedTags` is derived from the sources Wix manages. Tags that site
31
+ // code, apps or page components add at render time aren't in it, so it is
32
+ // not a copy of the page's rendered `<head>`.
33
+ //
34
+ // Verified against:
35
+ // - https://dev.wix.com/docs/api-reference/business-management/seo/item-seo-tags-v1/list-item-seo-tags.md
36
+ // - https://dev.wix.com/docs/api-reference/business-management/seo/item-seo-tags-v1/get-item-seo-tags.md
37
+ // - https://dev.wix.com/docs/api-reference/business-management/seo/site-seo-tags-v1/get-site-seo-tags.md
38
+ // - https://dev.wix.com/docs/api-reference/business-management/seo/redirects/skills/manage-url-redirects-on-a-wix-site.md
39
+ import { createHmac, randomBytes } from "node:crypto";
40
+ import { Type } from "@sinclair/typebox";
41
+ import { defineWixTool } from "./_factory.js";
42
+ import { compactQuery, } from "./_query.js";
43
+ const SiteIdParam = Type.Optional(Type.String());
44
+ /** Minted once per process. See `redirectToken`. */
45
+ const TOKEN_SECRET = randomBytes(32);
46
+ /** Stop before doing anything irreversible for a caller who has given up. */
47
+ function throwIfAborted(signal) {
48
+ if (signal?.aborted === true) {
49
+ throw new Error("Aborted before the write: nothing was sent to Wix.");
50
+ }
51
+ }
52
+ /** Base path of the SEO redirects service. Its routes are NOT REST-conventional
53
+ * — creation is `POST /create-redirect`, and the bulk routes are verbs too —
54
+ * so every path here comes from the reference, never from a convention. */
55
+ const REDIRECTS = "/seo-redirects-service/v1";
56
+ /** A single SEO tag, in Wix's shape. Left permissive on purpose: which `type`
57
+ * values an endpoint accepts differs per endpoint, and the API rejects the
58
+ * wrong ones with a named error that reaches the model intact. */
59
+ const SeoTagSchema = Type.Object({
60
+ type: Type.String({
61
+ description: "Tag type: `title`, `meta`, `script` or `link`.",
62
+ }),
63
+ props: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
64
+ description: 'Tag properties, e.g. `{"name": "description", "content": "…"}`.',
65
+ })),
66
+ children: Type.Optional(Type.String({ description: "Inner content, e.g. the text of a <title>." })),
67
+ custom: Type.Optional(Type.Boolean()),
68
+ disabled: Type.Optional(Type.Boolean()),
69
+ });
70
+ /** One redirect, as Create and Bulk Create accept it. */
71
+ const RedirectSchema = Type.Object({
72
+ from: Type.String({
73
+ description: "Path the redirect starts from, URL-encoded, e.g. `/old-page`. Cannot " +
74
+ "be the site root. Two paths differing only by a trailing slash count " +
75
+ "as the same path.",
76
+ }),
77
+ to: Type.String({
78
+ description: "Where it sends the visitor: a path such as `/new-page`, or a full URL.",
79
+ }),
80
+ options: Type.Optional(Type.Object({
81
+ groupRedirect: Type.Optional(Type.Boolean({
82
+ description: "true = match everything UNDER `from` and carry the rest of the " +
83
+ "URL over. false/omitted = exact path only. A group and an exact " +
84
+ "redirect sharing a `from` path are two different redirects.",
85
+ })),
86
+ })),
87
+ language: Type.Optional(Type.String({
88
+ description: "Language version this applies to (`fr`, `en-US`). Omit to apply to " +
89
+ "every language. A language-scoped path is stored WITHOUT its " +
90
+ "language prefix.",
91
+ })),
92
+ id: Type.Optional(Type.String({
93
+ description: "Redirect GUID. Supply one only to preserve an existing redirect's " +
94
+ "identity while re-creating it; omit for a new redirect.",
95
+ })),
96
+ });
97
+ /** Wix stores a language-scoped redirect WITHOUT its language prefix: a `fr`
98
+ * redirect from `/fr/about` comes back as `/about`. A candidate written with
99
+ * the prefix would be compared against a stored path that never has one, and
100
+ * the conflict this check exists to find would be invisible. */
101
+ function stripLanguagePrefix(path, language) {
102
+ if (language === undefined || language.length === 0)
103
+ return path;
104
+ // `fr` and `fr-CA` both prefix as `/fr/`.
105
+ const code = language.split("-")[0].toLowerCase();
106
+ const lower = path.toLowerCase();
107
+ if (lower === `/${code}`)
108
+ return "/";
109
+ return lower.startsWith(`/${code}/`) ? path.slice(code.length + 1) : path;
110
+ }
111
+ /** Wix treats two paths differing only by a trailing slash as the same path,
112
+ * and matches case-insensitively on the stored path. Comparing raw strings
113
+ * would miss exactly the conflicts this check exists to find. */
114
+ function samePath(a, b) {
115
+ if (a === undefined || b === undefined)
116
+ return false;
117
+ const norm = (p) => p.replace(/\/+$/, "").toLowerCase();
118
+ return norm(a) === norm(b);
119
+ }
120
+ /** Whether two redirects can collide at all.
121
+ *
122
+ * A redirect with no `language` applies to every language; one scoped to a
123
+ * language conflicts only with the same language and with the global ones. */
124
+ function scopesOverlap(a, b) {
125
+ if (a === undefined || a === "" || b === undefined || b === "")
126
+ return true;
127
+ return a.toLowerCase() === b.toLowerCase();
128
+ }
129
+ /**
130
+ * The redirects a create would DESTROY, found before it is sent.
131
+ *
132
+ * Two ways a create removes something, and neither is obvious from the call:
133
+ * - LOOP RESOLUTION: an existing redirect that STARTS AT the path this one
134
+ * points to is deleted so the create can proceed. No flag asks for it, and
135
+ * the API does not report it.
136
+ * - FORCE REPLACE: with the flag, the redirect holding the same `from` path
137
+ * is deleted. Without it, the create fails instead.
138
+ *
139
+ * Leaving this to the tool description made it advice: a direct call, a model
140
+ * that skipped it, or a change between the listing and the write would still
141
+ * lose a redirect nobody named. Run in the execution path, it cannot be
142
+ * skipped — and it reports WHICH redirect, which an approval prompt alone
143
+ * cannot say.
144
+ */
145
+ /** A leading path segment shaped like a language tag: `/fr`, `/pt-br`. Used
146
+ * only to widen what a create is said to endanger, never to narrow it. */
147
+ const LANGUAGE_SEGMENT = /^\/[a-z]{2,3}(?:-[a-z0-9]{2,8})?(?=\/|$)/i;
148
+ function deletionsCausedBy(existing, candidate, forceReplace) {
149
+ const found = [];
150
+ // Compare on what Wix STORES, not on what the caller wrote.
151
+ for (const one of existing) {
152
+ const sameScope = scopesOverlap(one.language, candidate.language);
153
+ // Compare on what Wix STORES — and the prefix to strip belongs to the
154
+ // EXISTING redirect as much as to the candidate. A GLOBAL create pointing
155
+ // at `/fr/blog` aims at the public path of a `fr` redirect that Wix returns
156
+ // as `/blog`: normalising only by the candidate's own language (none, here)
157
+ // left the two looking unrelated, and the deletion went through unannounced.
158
+ const candidateTo = stripLanguagePrefix(stripLanguagePrefix(candidate.to, candidate.language), one.language);
159
+ const candidateFrom = stripLanguagePrefix(stripLanguagePrefix(candidate.from, candidate.language), one.language);
160
+ // SCOPE-BLIND ON `groupRedirect`, DELIBERATELY. An exact and a group
161
+ // redirect sharing a `from` are "two different redirects" upstream, which
162
+ // invites the conclusion that a forceReplace aimed at one spares the other
163
+ // — so this announces a loss Wix might not take. But the rules upstream
164
+ // states for both checks are written on `from` equality alone, with no
165
+ // qualification by scope, and nothing says which of the two a create
166
+ // displaces. Announcing a superset costs one acknowledgement the operator
167
+ // can judge (`groupRedirect` is on every entry); announcing a subset means
168
+ // a permanent deletion nobody was shown. Narrow this only on a documented
169
+ // scope-aware rule or an observed live behaviour, not on the inference.
170
+ // ACROSS LANGUAGES, BUT ONLY WHEN THE CALLER SAID SO. Two languages live in
171
+ // different URL namespaces — a `fr` redirect answers at `/fr/blog`, a `de`
172
+ // one at `/de/blog` — so a create in one language pointing at its own
173
+ // `/blog` cannot loop with the other, and refusing there would block a
174
+ // create that destroys nothing. But a caller can write the OTHER language's
175
+ // prefix explicitly: an `en` redirect pointing at `/fr/blog` names exactly
176
+ // the public path of the `fr` redirect Wix stores as `/blog`. The scope
177
+ // filter alone never reached that comparison, so the deletion went
178
+ // unannounced. This adds that case and takes none away.
179
+ const acrossLanguages = !sameScope &&
180
+ one.language !== undefined &&
181
+ stripLanguagePrefix(candidate.to, one.language) !== candidate.to &&
182
+ samePath(one.from, stripLanguagePrefix(candidate.to, one.language));
183
+ // A GLOBAL REDIRECT ANSWERS UNDER EVERY LANGUAGE. Upstream: omitting
184
+ // `language` applies the redirect to every one of them, so a global rule
185
+ // stored as `/blog` also entered at `/de/blog`. `sameScope` is always true
186
+ // against a global rule, which meant the branch above could never look at a
187
+ // foreign prefix, and a create pointing at `/de/blog` closed a loop with it
188
+ // unannounced. The site's configured languages are not knowable here — the
189
+ // endpoint that lists them needs a different scope and can 403 — so this
190
+ // recognises a leading segment SHAPED like a language tag. It errs towards
191
+ // announcing: a `/fr/...` that is really a content folder costs one
192
+ // acknowledgement, and the operator sees the redirect it names.
193
+ const strippedGuess = candidate.to.replace(LANGUAGE_SEGMENT, "");
194
+ const globalAcrossLanguages = one.language === undefined &&
195
+ strippedGuess !== candidate.to &&
196
+ strippedGuess.length > 0 &&
197
+ samePath(one.from, strippedGuess);
198
+ if ((sameScope && samePath(one.from, candidateTo)) ||
199
+ acrossLanguages ||
200
+ globalAcrossLanguages)
201
+ found.push({ reason: "loop", redirect: one });
202
+ else if (sameScope && forceReplace && samePath(one.from, candidateFrom)) {
203
+ found.push({ reason: "force_replace", redirect: one });
204
+ }
205
+ }
206
+ return found;
207
+ }
208
+ /** A SECOND GAP, same reasoning: whether a create in one language pointing at
209
+ * its own namespace can ever resolve a loop against another language's
210
+ * redirect is not something upstream states, and the two models — compare the
211
+ * stored paths, or compare the public ones — disagree. This compares public
212
+ * paths, adding only the case where the caller wrote the other language's
213
+ * prefix. Settling the rest needs a live create and delete. */
214
+ /** A GAP THIS DOES NOT CLOSE, stated rather than hidden: if an existing GROUP
215
+ * redirect from `/docs` also resolves a loop for a create pointing at
216
+ * `/docs/page` — because a group redirect covers everything under its `from` —
217
+ * no victim is reported. Upstream defines the loop rule as the existing `from`
218
+ * EQUALLING the new `to`, never as covering it, and inventing the wider rule
219
+ * would make every create landing anywhere under a group redirect demand an
220
+ * acknowledgement. Settling it needs a create and a delete against a live site,
221
+ * which this plugin will not do to answer a question. */
222
+ /** Read every redirect on the site. Unpaged by contract — one call is the whole
223
+ * list. */
224
+ async function listExisting(client, siteId, signal) {
225
+ const resp = await client.request("GET", `${REDIRECTS}/redirects`, {
226
+ siteId,
227
+ signal,
228
+ });
229
+ // FAIL CLOSED. `resp?.redirects ?? []` read any unexpected 2xx — a body the
230
+ // client could not parse as JSON, a schema change, a proxy's own page — as
231
+ // "this site has no redirects", and the destructive write went out on the
232
+ // strength of it. The one answer a safety check must never invent is the
233
+ // reassuring one.
234
+ const redirects = resp !== null && typeof resp === "object"
235
+ ? resp.redirects
236
+ : undefined;
237
+ if (!Array.isArray(redirects)) {
238
+ throw new Error("Refusing to write: could not read the site's existing redirects, so " +
239
+ "the check for what this would delete could not run. List Redirects " +
240
+ "returned no `redirects` array.");
241
+ }
242
+ // AND EVERY ELEMENT. Validating only the envelope left the check open to a
243
+ // schema drift — these endpoints are in Developer Preview, so one is a
244
+ // stated possibility rather than a hypothetical: `[{ id, source }]` passes an
245
+ // array test, every `from` reads as undefined, no conflict is ever found, and
246
+ // the destructive write goes out.
247
+ //
248
+ // EVERY FIELD THE TOKEN BINDS, not just `from`. `redirectToken` hashes id,
249
+ // from, to, language and groupRedirect, substituting `""`/`false` for what is
250
+ // absent. Checking only `from` therefore left a drift that renames or drops
251
+ // `to` silently degrading the token into one that no longer changes when the
252
+ // destination does — and an acknowledgement is only a safeguard because it
253
+ // goes stale. The scope fields are checked for SHAPE: an unknown shape is a
254
+ // scope this version cannot reason about.
255
+ for (const one of redirects) {
256
+ const r = one;
257
+ const options = r.options;
258
+ const understood = one !== null &&
259
+ typeof one === "object" &&
260
+ typeof r.id === "string" &&
261
+ typeof r.from === "string" &&
262
+ typeof r.to === "string" &&
263
+ (r.language === undefined || typeof r.language === "string") &&
264
+ (r.options === undefined ||
265
+ // A PLAIN OBJECT CARRYING ONLY WHAT THIS VERSION READS. `options: []`
266
+ // is `typeof "object"` and passed; so did a drift that renamed the flag
267
+ // (`isGroupRedirect`), leaving the redirect described to the operator
268
+ // as exact and its token blind to the scope it really has — the two
269
+ // things the acknowledgement exists to bind.
270
+ (typeof r.options === "object" &&
271
+ r.options !== null &&
272
+ !Array.isArray(r.options) &&
273
+ Object.keys(r.options).every((k) => k === "groupRedirect") &&
274
+ (options?.groupRedirect === undefined ||
275
+ typeof options.groupRedirect === "boolean")));
276
+ if (!understood) {
277
+ throw new Error("Refusing to write: the site's redirect list contains an entry this " +
278
+ "version does not understand (it must carry string `id`, `from` and " +
279
+ "`to`, an optional string `language`, and an optional `options` " +
280
+ "object whose `groupRedirect` is a boolean), so the check for what " +
281
+ "this would delete cannot be trusted.");
282
+ }
283
+ }
284
+ return redirects;
285
+ }
286
+ /**
287
+ * One destructive redirect write per site at a time.
288
+ *
289
+ * The check and the write are two requests. Two concurrent creates can each
290
+ * read a state in which the other's redirect does not exist yet, and the second
291
+ * to land then deletes the first's — a deletion neither caller was shown.
292
+ * Serialising per site removes the race this plugin creates itself.
293
+ *
294
+ * It does NOT make the window disappear: a change made in the Wix dashboard, or
295
+ * by another process, between the read and the write is still possible, and no
296
+ * Wix precondition exists to close it. That residual is stated in the tool
297
+ * descriptions rather than papered over.
298
+ */
299
+ const siteWriteQueues = new Map();
300
+ /** TEST ONLY: how many per-site write chains are still retained.
301
+ *
302
+ * Retention is a COST, not a behaviour — no sequence of tool calls tells a map
303
+ * that grows for ever apart from one that is emptied, so the guard needs a
304
+ * window into the module rather than an assertion about output. */
305
+ export function seoWriteQueueDepth() {
306
+ return siteWriteQueues.size;
307
+ }
308
+ /** `item-seo-tags` pages by CURSOR only. The shared GET envelope also offers
309
+ * `offset`, so the schema accepted — and `buildGetPagingQuery` forwarded — a
310
+ * call mixing the two, which the endpoint does not define. Advertising only
311
+ * what it serves is cheaper than explaining the result of the mixture. */
312
+ const SeoCursorPagingSchema = Type.Object({
313
+ limit: Type.Optional(Type.Number({ minimum: 1, maximum: 1000 })),
314
+ cursor: Type.Optional(Type.String()),
315
+ });
316
+ function serializedBySite(client, siteId, work) {
317
+ // THE EFFECTIVE SITE, resolved before queuing. Keying on the raw argument put
318
+ // an omitted `siteId` and an explicit one naming the same site in two
319
+ // different queues, so the two calls the lock exists to order ran side by
320
+ // side.
321
+ const key = siteId ?? client.defaultSiteId ?? "<default>";
322
+ const previous = siteWriteQueues.get(key) ?? Promise.resolve();
323
+ const next = previous.then(work, work);
324
+ // Keep the chain alive on failure.
325
+ const settled = next.then(() => undefined, () => undefined);
326
+ siteWriteQueues.set(key, settled);
327
+ // AND DROP IT once it is the last one — the comment claimed this before the
328
+ // code did it. The key is a caller-supplied site id, queued BEFORE the
329
+ // whitelist rejects it, so a long-lived gateway retained one settled promise
330
+ // per distinct string ever passed. Deleting only while we are still the tail
331
+ // is what keeps the ordering: if a later call has already chained onto us, it
332
+ // owns the entry and must keep it.
333
+ void settled.then(() => {
334
+ if (siteWriteQueues.get(key) === settled)
335
+ siteWriteQueues.delete(key);
336
+ });
337
+ return next;
338
+ }
339
+ /**
340
+ * A token identifying a redirect AS IT IS RIGHT NOW.
341
+ *
342
+ * Acknowledging a bare GUID was two holes at once: a caller could name an ID
343
+ * on the first call and never be shown what it destroys, and an ID stays valid
344
+ * while the redirect behind it changes — Wix lets a create supply its own GUID,
345
+ * so between the refusal and the retry the same ID can point somewhere else,
346
+ * and the write would delete something the user never saw.
347
+ *
348
+ * Deriving the token from the redirect's identifying fields makes it stale the
349
+ * moment the redirect changes, and the per-process secret makes it impossible
350
+ * to produce without having listed. The EFFECTIVE SITE is part of it because
351
+ * two allowed sites can hold an identical redirect — Wix accepts caller-supplied
352
+ * GUIDs — and a token earned on one would otherwise authorise deleting its twin
353
+ * on the other, on a tenant whose state was never shown.
354
+ */
355
+ function redirectToken(site, one) {
356
+ const canonical = JSON.stringify([
357
+ site,
358
+ one.id ?? "",
359
+ one.from ?? "",
360
+ one.to ?? "",
361
+ one.language ?? "",
362
+ one.options?.groupRedirect === true,
363
+ ]);
364
+ // HMAC with a secret minted once per process, so a token cannot be computed
365
+ // by a caller who never listed. A restart invalidates outstanding tokens,
366
+ // which fails in the safe direction: the call refuses again with a fresh one.
367
+ return `${one.id ?? "no-id"}:${createHmac("sha256", TOKEN_SECRET)
368
+ .update(canonical)
369
+ .digest("hex")
370
+ .slice(0, 16)}`;
371
+ }
372
+ /** What the caller must repeat back to proceed with a destructive create. */
373
+ const AcknowledgeParam = Type.Optional(Type.Array(Type.String(), {
374
+ description: "The `token` values from a previous refusal, for the redirects you " +
375
+ "accept losing. The call is REFUSED, listing them, until every redirect " +
376
+ "it would delete is acknowledged. A token is tied to the redirect's " +
377
+ "CURRENT state: if it changed since the refusal, the token no longer " +
378
+ "matches and the call refuses again with the new one. Show the listed " +
379
+ "redirects to the user and get their answer before filling this in — the " +
380
+ "deletions are permanent.",
381
+ }));
382
+ /**
383
+ * A redirect stripped to the fields this plugin actually supports.
384
+ *
385
+ * TypeBox objects accept unknown properties, so `options.forceReplace` rode
386
+ * through a bulk create untouched — a flag whose whole effect is to delete the
387
+ * redirect holding the `from` path, while the preflight had been run with
388
+ * `forceReplace: false` and the approval prompt never showed it. A schema that
389
+ * merely fails to mention a field does not remove it, and `execute` can be
390
+ * called directly, so the stripping happens here rather than being assumed.
391
+ *
392
+ * `forceReplace` is supported ONLY as the single create's own top-level
393
+ * parameter, where the preflight accounts for it.
394
+ */
395
+ function sanitizeRedirect(one) {
396
+ return {
397
+ from: one.from,
398
+ to: one.to,
399
+ ...(one.options?.groupRedirect === true
400
+ ? { options: { groupRedirect: true } }
401
+ : {}),
402
+ ...(one.language !== undefined ? { language: one.language } : {}),
403
+ ...(one.id !== undefined ? { id: one.id } : {}),
404
+ };
405
+ }
406
+ export function buildSeoTools(client) {
407
+ return [
408
+ // ---------------------------------------------------------------- audit
409
+ defineWixTool({
410
+ name: "wix_seo_list_item_tags",
411
+ description: "AUDIT ENTRY POINT. List the items of one type with their SEO tags. " +
412
+ "Pass `STATIC_PAGE` to enumerate the site's pages; other types are " +
413
+ "`BLOG_POST`, `STORES_PRODUCT`, and `WIX_DATA_PAGE_ITEM-{pageId}` " +
414
+ "for a page built from a CMS collection. Each item reports " +
415
+ "`hasOverride` (does it set tags of its own), `tags` (those it sets) " +
416
+ "and `resolvedTags` (what it is expected to render, each marked with " +
417
+ "its source: item, host page, user pattern, Wix pattern, or site). " +
418
+ "WIRE SHAPE — a `resolvedTags` entry is an ENVELOPE: the tag is at " +
419
+ "`entry.tag` and its provenance at `entry.source`, whereas `tags` " +
420
+ "holds bare tags with no envelope. Reading `resolvedTags[].type` " +
421
+ "yields nothing, which reads as a missing title rather than as a " +
422
+ "mistake. " +
423
+ "A site without the business solution behind a type returns an empty " +
424
+ "list. Cursor paging: pass the response's cursor back as " +
425
+ "`paging.cursor`. " +
426
+ "LIMIT — for `STATIC_PAGE` the tags reflect the SAVED revision, " +
427
+ "never the published one, and `publishStatus` is always " +
428
+ "`PUBLISH_STATUS_UNSPECIFIED`: report findings as being about the " +
429
+ "saved page, not the live one. `resolvedTags` also omits tags added " +
430
+ "by site code or apps at render time, so it is not the rendered head.",
431
+ parameters: Type.Object({
432
+ siteId: SiteIdParam,
433
+ itemType: Type.String({
434
+ description: "Item type, e.g. `STATIC_PAGE`, `BLOG_POST`, `STORES_PRODUCT`.",
435
+ }),
436
+ paging: Type.Optional(SeoCursorPagingSchema),
437
+ }),
438
+ run: (params, _client, signal) => client.request("GET", `/promote/seo/v1/item-seo-tags/${encodeURIComponent(params.itemType)}`, {
439
+ siteId: params.siteId,
440
+ signal,
441
+ // BUILT FROM THE TWO KEYS THIS ENDPOINT DEFINES, not from the
442
+ // generic helper. Narrowing the schema only stops `offset` being
443
+ // ADVERTISED: TypeBox passes undeclared properties through and
444
+ // `execute` can be called directly, so the helper would still
445
+ // have forwarded a `paging.offset` that arrived at runtime.
446
+ query: compactQuery({
447
+ "paging.limit": params.paging?.limit,
448
+ "paging.cursor": params.paging?.cursor,
449
+ }),
450
+ }),
451
+ }, client),
452
+ defineWixTool({
453
+ name: "wix_seo_get_item_tags",
454
+ description: "Read one item's SEO tags: what it sets itself (`tags`), whether it " +
455
+ "sets anything (`hasOverride`), and what it is expected to render " +
456
+ "(`resolvedTags`, each marked with its source). For a static page, " +
457
+ "`itemId` is the page GUID — get it from `wix_seo_list_item_tags`. " +
458
+ "Same STATIC_PAGE limit: this is the SAVED revision, not the live one.",
459
+ parameters: Type.Object({
460
+ siteId: SiteIdParam,
461
+ itemType: Type.String(),
462
+ itemId: Type.String({
463
+ description: "Item GUID. For a static page, the page GUID.",
464
+ }),
465
+ }),
466
+ run: (params, _client, signal) => client.request("GET", `/promote/seo/v1/item-seo-tags/${encodeURIComponent(params.itemType)}/${encodeURIComponent(params.itemId)}`, { siteId: params.siteId, signal }),
467
+ }, client),
468
+ defineWixTool({
469
+ name: "wix_seo_get_site_tags",
470
+ description: "Read the site-wide SEO tags: the ones that can be changed (`tags` " +
471
+ "— default `og:image`, a `robots` directive, site verification " +
472
+ "tags, other site-wide meta) and the verification tags added " +
473
+ "through the dashboard as HTML embeds (`embedTags`, read-only). " +
474
+ "Per-page tags — titles, descriptions, canonical links, structured " +
475
+ "data — are NOT here: read those with `wix_seo_get_item_tags`.",
476
+ parameters: Type.Object({ siteId: SiteIdParam }),
477
+ run: (params, _client, signal) => client.request("GET", "/promote/seo/v1/site-seo-tags", {
478
+ siteId: params.siteId,
479
+ signal,
480
+ }),
481
+ }, client),
482
+ // ------------------------------------------------------------ redirects
483
+ defineWixTool({
484
+ name: "wix_seo_list_redirects",
485
+ description: "List EVERY redirect on the site in one response. There is no query " +
486
+ "or search endpoint for redirects and no paging — filter and sort " +
487
+ "the returned array yourself. Includes redirects Wix created on the " +
488
+ "owner's behalf, such as when a page's slug was renamed in the " +
489
+ "editor. ALWAYS call this before creating a redirect: a create can " +
490
+ "delete an existing one (see `wix_seo_create_redirect`).",
491
+ parameters: Type.Object({ siteId: SiteIdParam }),
492
+ run: (params, _client, signal) => client.request("GET", `${REDIRECTS}/redirects`, {
493
+ siteId: params.siteId,
494
+ signal,
495
+ }),
496
+ }, client),
497
+ defineWixTool({
498
+ name: "wix_seo_get_redirect",
499
+ description: "Retrieve one redirect by GUID. A 404 means no redirect has that " +
500
+ "GUID — the API does not return the documented `REDIRECT_NOT_FOUND` " +
501
+ "code today, so do not report the GUID as valid.",
502
+ parameters: Type.Object({
503
+ siteId: SiteIdParam,
504
+ redirectId: Type.String({ description: "Redirect GUID." }),
505
+ }),
506
+ run: (params, _client, signal) => client.request("GET", `${REDIRECTS}/redirects/${encodeURIComponent(params.redirectId)}`, { siteId: params.siteId, signal }),
507
+ }, client),
508
+ defineWixTool({
509
+ name: "wix_seo_create_redirect",
510
+ description: "Create a 301 redirect. It takes effect on the live site " +
511
+ "immediately, with no publish, and TAKES PRECEDENCE OVER A REAL " +
512
+ "PAGE at the same path — creating one from a path that still serves " +
513
+ "a page makes that page unreachable. " +
514
+ "THIS CALL CAN DELETE ANOTHER REDIRECT. Redirects do not chain: if " +
515
+ "an existing redirect STARTS AT the path this one points to, that " +
516
+ "existing redirect is deleted and the create proceeds. With this " +
517
+ "tool's OWN top-level `forceReplace` parameter, a redirect holding " +
518
+ "the same `from` path is deleted too; without it, a taken `from` " +
519
+ "path fails with `FROM_URL_EXISTS` and writes nothing. Wix documents " +
520
+ "that flag as `options.forceReplace`, nested in the redirect — DO " +
521
+ "NOT send it there: this tool rebuilds the redirect from the fields " +
522
+ "it supports, so a nested flag is dropped and the create fails as if " +
523
+ "you had never set it. Deletions here are permanent. " +
524
+ "THE CHECK IS RUN FOR YOU: the tool lists the site's redirects first " +
525
+ "and REFUSES, naming what it would delete, until you call again with " +
526
+ "`acknowledgeDeletions` listing the `token` of each — the redirect's " +
527
+ "GUID is NOT accepted, since an id stays valid while the redirect " +
528
+ "behind it changes. Show them to the user and get an answer before " +
529
+ "doing that. " +
530
+ "Never set `forceReplace` on your own initiative. " +
531
+ "There is no update method: to change a redirect, get it, delete it, " +
532
+ "then create it with EVERY field carried over — an omitted field is " +
533
+ "filled with its default, not its previous value.",
534
+ parameters: Type.Object({
535
+ siteId: SiteIdParam,
536
+ redirect: RedirectSchema,
537
+ forceReplace: Type.Optional(Type.Boolean({
538
+ description: "Delete the redirect already holding the `from` path and put " +
539
+ "this one in its place. The replaced redirect is gone for " +
540
+ "good. Only ever set this when the user asked for it after " +
541
+ "being told what it destroys.",
542
+ })),
543
+ acknowledgeDeletions: AcknowledgeParam,
544
+ }),
545
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
546
+ // ABORT IS CHECKED AFTER THE QUEUE, not only before it. A call can
547
+ // sit behind another site-write for as long as that one takes, and
548
+ // running it once the caller has given up sends an irreversible
549
+ // POST for an operation the runtime already reported as cancelled.
550
+ throwIfAborted(signal);
551
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
552
+ // THE PREFLIGHT IS THE CODE'S, not the model's.
553
+ const existing = await listExisting(client, params.siteId, signal);
554
+ // NORMALISED ONCE, then used for BOTH the check and the body. The
555
+ // preflight tested `=== true` while the body forwarded whatever was
556
+ // passed, so a truthy non-boolean — reachable through a direct
557
+ // `execute`, which the schema does not police — ran the check as
558
+ // "no force replace" and then serialised to `true` on the wire.
559
+ // The flag decides a permanent deletion; it cannot mean two things.
560
+ const forceReplace = params.forceReplace === true;
561
+ const doomed = deletionsCausedBy(existing, params.redirect, forceReplace);
562
+ const acknowledged = new Set(params.acknowledgeDeletions ?? []);
563
+ const unacknowledged = doomed.filter((d) => !acknowledged.has(redirectToken(effectiveSite, d.redirect)));
564
+ if (unacknowledged.length > 0) {
565
+ return {
566
+ refused: "this create would permanently delete existing redirect(s)",
567
+ // THE FULL BLAST RADIUS, never the remainder. Listing only what
568
+ // was still unacknowledged made `howToProceed` — "every token
569
+ // above" — name a DIFFERENT set on each refusal: a caller that
570
+ // REPLACED its list instead of extending it alternated between
571
+ // halves and never converged, so a legitimate deletion became
572
+ // impossible. It also understated the loss, the second prompt
573
+ // showing fewer deletions than the first.
574
+ wouldDelete: doomed.map((d) => ({
575
+ acknowledged: acknowledged.has(redirectToken(effectiveSite, d.redirect)),
576
+ reason: d.reason === "loop"
577
+ ? "redirects do not chain: this one starts at the path your new redirect points to"
578
+ : "forceReplace: this one holds the `from` path you are taking",
579
+ // Tied to the redirect's CURRENT state: it stops matching the
580
+ // moment the redirect changes, so an acknowledgement can
581
+ // never authorise deleting something else.
582
+ token: redirectToken(effectiveSite, d.redirect),
583
+ id: d.redirect.id,
584
+ from: d.redirect.from,
585
+ to: d.redirect.to,
586
+ language: d.redirect.language ?? "all languages",
587
+ groupRedirect: d.redirect.options?.groupRedirect === true,
588
+ })),
589
+ howToProceed: "Show these to the user. Only if they accept the loss, call " +
590
+ "again with `acknowledgeDeletions` listing EVERY token above, " +
591
+ "including any already marked `acknowledged: true`. A partial " +
592
+ "list is refused again.",
593
+ };
594
+ }
595
+ throwIfAborted(signal);
596
+ return client.request("POST", `${REDIRECTS}/create-redirect`, {
597
+ siteId: params.siteId,
598
+ signal,
599
+ // NOT REPLAYED. A create that Wix applied before answering 502 —
600
+ // deleting a loop-closing redirect on the way — would be applied
601
+ // twice by a retry, and nothing in either answer says which
602
+ // happened. Surfacing the error and letting the caller re-read is
603
+ // the only honest outcome.
604
+ retry: false,
605
+ body: {
606
+ redirect: sanitizeRedirect(params.redirect),
607
+ ...(forceReplace ? { options: { forceReplace: true } } : {}),
608
+ },
609
+ });
610
+ }),
611
+ }, client),
612
+ defineWixTool({
613
+ name: "wix_seo_delete_redirect",
614
+ description: "Delete one redirect by GUID. Permanent, and effective on the live " +
615
+ "site immediately. The redirect is RE-READ first and the call is " +
616
+ "REFUSED, showing its paths, language and scope, until you call again " +
617
+ "with the `token` from that refusal in `acknowledgeDeletions` — a GUID " +
618
+ "alone is not a target, because Wix accepts caller-supplied GUIDs and " +
619
+ "the same id can name a different redirect by the time you get here.",
620
+ parameters: Type.Object({
621
+ siteId: SiteIdParam,
622
+ redirectId: Type.String({ description: "Redirect GUID." }),
623
+ acknowledgeDeletions: AcknowledgeParam,
624
+ }),
625
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
626
+ throwIfAborted(signal);
627
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
628
+ // AN ID IS NOT A TARGET. Wix accepts caller-supplied GUIDs, so
629
+ // between a listing, the operator's approval and this call the same
630
+ // id can name a different redirect — and the prompt showed nothing
631
+ // but the id. The redirect is re-read here and must be
632
+ // acknowledged by a token bound to its current state, the same
633
+ // protocol the creates use.
634
+ const existing = await listExisting(client, params.siteId, signal);
635
+ const target = existing.find((r) => r.id === params.redirectId);
636
+ if (target === undefined) {
637
+ return {
638
+ refused: "no redirect with that id is on this site right now",
639
+ howToProceed: "Call `wix_seo_list_redirects` and pick the redirect again.",
640
+ };
641
+ }
642
+ const token = redirectToken(effectiveSite, target);
643
+ if (!(params.acknowledgeDeletions ?? []).includes(token)) {
644
+ return {
645
+ refused: "this deletion is permanent and has not been acknowledged",
646
+ wouldDelete: {
647
+ token,
648
+ id: target.id,
649
+ from: target.from,
650
+ to: target.to,
651
+ language: target.language ?? "all languages",
652
+ groupRedirect: target.options?.groupRedirect === true,
653
+ },
654
+ howToProceed: "Show this to the user. Only if they accept the loss, call " +
655
+ "again with `acknowledgeDeletions: [\"<token>\"]`.",
656
+ };
657
+ }
658
+ throwIfAborted(signal);
659
+ return client.request("DELETE", `${REDIRECTS}/redirects/${encodeURIComponent(params.redirectId)}`,
660
+ // NOT REPLAYED. A delete Wix applied before answering 503 comes
661
+ // back 404 on the replay, and the plugin would report a failure
662
+ // for a permanent deletion that did happen.
663
+ { siteId: params.siteId, signal, retry: false });
664
+ }),
665
+ }, client),
666
+ defineWixTool({
667
+ name: "wix_seo_bulk_create_redirects",
668
+ description: "Create 1 to 100 redirects in one call. NOT ATOMIC, and each item " +
669
+ "carries its own outcome INSIDE a successful response: read " +
670
+ "`results[].itemMetadata`, matched to your request by " +
671
+ "`originalIndex`; a failed item carries `error.code` such as " +
672
+ "`FROM_URL_EXISTS`. `bulkActionMetadata.undetailedFailures` counts " +
673
+ "items whose outcome is UNKNOWN — they may or may not have been " +
674
+ "written; call `wix_seo_list_redirects` to find out, and never " +
675
+ "report them as successes. " +
676
+ "TWO KINDS OF LOOP, and only one of them destroys. An item that " +
677
+ "closes a loop with an EARLIER ITEM OF THIS SAME REQUEST fails on its " +
678
+ "own with `REDIRECT_LOOP` and the rest of the batch goes ahead — " +
679
+ "nothing is deleted, so report that item as failed rather than as a " +
680
+ "loss. An item that closes a loop with a redirect ALREADY ON THE SITE " +
681
+ "is created and that site redirect is DELETED, permanently. Only the " +
682
+ "second is checked for you: the call is REFUSED, naming every existing " +
683
+ "redirect it would delete and the item causing it, until you call " +
684
+ "again with `acknowledgeDeletions` listing the `token` of each — a " +
685
+ "GUID is not accepted. " +
686
+ "A malformed request (an empty list) is rejected " +
687
+ "whole with a 400 and returns no per-item results.",
688
+ parameters: Type.Object({
689
+ siteId: SiteIdParam,
690
+ // ONE HUNDRED, from the reference. The skill page says 1–500; the
691
+ // method page and the Developer Preview index both say 100, and a
692
+ // batch Wix rejects outright helps nobody.
693
+ redirects: Type.Array(RedirectSchema, { minItems: 1, maxItems: 100 }),
694
+ returnFullEntity: Type.Optional(Type.Boolean({
695
+ description: "Return each created redirect in `results[].item`.",
696
+ })),
697
+ acknowledgeDeletions: AcknowledgeParam,
698
+ }),
699
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
700
+ throwIfAborted(signal);
701
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
702
+ // Same code-run preflight as the single create: bulk is not atomic,
703
+ // so a loop-causing redirect can still be deleted before the item
704
+ // that caused it fails to be written.
705
+ const existing = await listExisting(client, params.siteId, signal);
706
+ const acknowledged = new Set(params.acknowledgeDeletions ?? []);
707
+ const doomed = params.redirects.flatMap((candidate, index) => deletionsCausedBy(existing, candidate, false).map((d) => ({
708
+ ...d,
709
+ index,
710
+ })));
711
+ const unacknowledged = doomed.filter((d) => !acknowledged.has(redirectToken(effectiveSite, d.redirect)));
712
+ if (unacknowledged.length > 0) {
713
+ return {
714
+ refused: "this bulk create would permanently delete existing redirect(s)",
715
+ // THE FULL BLAST RADIUS, never the remainder. Listing only what
716
+ // was still unacknowledged made `howToProceed` — "every token
717
+ // above" — name a DIFFERENT set on each refusal: a caller that
718
+ // REPLACED its list instead of extending it alternated between
719
+ // halves and never converged, so a legitimate deletion became
720
+ // impossible. It also understated the loss, the second prompt
721
+ // showing fewer deletions than the first.
722
+ wouldDelete: doomed.map((d) => ({
723
+ acknowledged: acknowledged.has(redirectToken(effectiveSite, d.redirect)),
724
+ causedByItemIndex: d.index,
725
+ reason: "redirects do not chain: this one starts at the path that item points to",
726
+ token: redirectToken(effectiveSite, d.redirect),
727
+ id: d.redirect.id,
728
+ from: d.redirect.from,
729
+ to: d.redirect.to,
730
+ language: d.redirect.language ?? "all languages",
731
+ groupRedirect: d.redirect.options?.groupRedirect === true,
732
+ })),
733
+ howToProceed: "Show these to the user. Only if they accept the loss, call " +
734
+ "again with `acknowledgeDeletions` listing EVERY token above, " +
735
+ "including any already marked `acknowledged: true`. A partial " +
736
+ "list is refused again.",
737
+ };
738
+ }
739
+ throwIfAborted(signal);
740
+ return client.request("POST", `${REDIRECTS}/bulk/redirects/create`, {
741
+ siteId: params.siteId,
742
+ signal,
743
+ // NOT REPLAYED: bulk create is explicitly non-atomic upstream,
744
+ // so a partial application followed by a retry is a real path
745
+ // to duplicated writes and lost redirects.
746
+ retry: false,
747
+ body: {
748
+ redirects: params.redirects.map(sanitizeRedirect),
749
+ ...(params.returnFullEntity !== undefined
750
+ ? { returnFullEntity: params.returnFullEntity }
751
+ : {}),
752
+ },
753
+ });
754
+ }),
755
+ }, client),
756
+ defineWixTool({
757
+ name: "wix_seo_bulk_delete_redirects",
758
+ description: "Delete 1 to 500 redirects by GUID. Each one is RE-READ first and the " +
759
+ "call is REFUSED, showing every redirect's paths, language and scope, " +
760
+ "until the `token` for each comes back in `acknowledgeDeletions` — a " +
761
+ "GUID alone is not a target. " +
762
+ "Same per-item reporting as the " +
763
+ "bulk create: read `results[].itemMetadata` and treat " +
764
+ "`bulkActionMetadata.undetailedFailures` as unknown, not as " +
765
+ "success. A successful item means the deletion was found and sent, " +
766
+ "not confirmed — call `wix_seo_list_redirects` if the user needs " +
767
+ "confirmation. Permanent.",
768
+ parameters: Type.Object({
769
+ siteId: SiteIdParam,
770
+ redirectIds: Type.Array(Type.String(), {
771
+ minItems: 1,
772
+ maxItems: 500,
773
+ }),
774
+ acknowledgeDeletions: AcknowledgeParam,
775
+ }),
776
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
777
+ throwIfAborted(signal);
778
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
779
+ // THE SAME PROTOCOL AS THE SINGLE DELETE. Sending 500 GUIDs straight
780
+ // through had exactly the identity hole the single delete just
781
+ // closed: Wix accepts caller-supplied GUIDs, so an id can name a
782
+ // different redirect by the time this runs, and the approval prompt
783
+ // carries ids — not paths, language or scope.
784
+ const existing = await listExisting(client, params.siteId, signal);
785
+ const acknowledged = new Set(params.acknowledgeDeletions ?? []);
786
+ const resolved = params.redirectIds.map((id) => ({
787
+ id,
788
+ target: existing.find((r) => r.id === id),
789
+ }));
790
+ const missing = resolved.filter((r) => r.target === undefined);
791
+ const targets = resolved.filter((r) => r.target !== undefined);
792
+ const unacknowledged = targets.filter((r) => !acknowledged.has(redirectToken(effectiveSite, r.target)));
793
+ if (missing.length > 0 || unacknowledged.length > 0) {
794
+ return {
795
+ refused: "these deletions are permanent and have not all been acknowledged for their CURRENT state",
796
+ notOnThisSite: missing.map((r) => r.id),
797
+ // THE FULL BLAST RADIUS, never the remainder. Listing only what
798
+ // was still unacknowledged made `howToProceed` — "every token
799
+ // above" — name a DIFFERENT set on each refusal: a caller that
800
+ // REPLACED its list instead of extending it alternated between
801
+ // halves and never converged, so a legitimate deletion became
802
+ // impossible. It also understated the loss, the second prompt
803
+ // showing fewer deletions than the first.
804
+ wouldDelete: targets.map((r) => ({
805
+ acknowledged: acknowledged.has(redirectToken(effectiveSite, r.target)),
806
+ token: redirectToken(effectiveSite, r.target),
807
+ id: r.target.id,
808
+ from: r.target.from,
809
+ to: r.target.to,
810
+ language: r.target.language ?? "all languages",
811
+ groupRedirect: r.target.options?.groupRedirect === true,
812
+ })),
813
+ howToProceed: "Show these to the user. Only if they accept the loss, call " +
814
+ "again with `acknowledgeDeletions` listing EVERY token above, " +
815
+ "including any already marked `acknowledged: true`. A partial " +
816
+ "list is refused again. Drop any id listed under " +
817
+ "`notOnThisSite`.",
818
+ };
819
+ }
820
+ throwIfAborted(signal);
821
+ return client.request("POST", `${REDIRECTS}/bulk/redirects/delete`, {
822
+ siteId: params.siteId,
823
+ signal,
824
+ // NOT REPLAYED: a partly-applied batch replaced by the replay's own
825
+ // per-item results hides which deletions actually landed.
826
+ retry: false,
827
+ body: { redirectIds: params.redirectIds },
828
+ });
829
+ }),
830
+ }, client),
831
+ ];
832
+ }
833
+ // `SeoTagSchema` is exported for the write tools that land in tranche 2
834
+ // (set item tags, set site tags, SEO patterns).
835
+ export { SeoTagSchema };
836
+ //# sourceMappingURL=seo.js.map