@tokenoftrust/cli 1.3.4 → 1.4.0-rc.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.
@@ -4,13 +4,21 @@
4
4
  * From inside a tenant checkout:
5
5
  * 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
6
6
  * 2. push your committed work to the tenant repo's `preview` ref (triggers reconcile),
7
+ * 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
8
+ * `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
9
+ * Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
10
+ * print the resulting changeId/PR number/URL,
7
11
  * 3. report back — reconcile result + compliance verdict + the preview URL — from the MCP.
8
12
  *
9
- * This is submit-for-PREVIEW, not ship-to-live (`change_accept` / a future `tot ship`
10
- * is the separate ship gate). Step 3 calls the MCP `preview_status` read-back:
11
- * given the commit just pushed it returns { status, reconcile, compliance, previewUrl } and we poll it
12
- * while reconcile is pending. If that tool isn't present (older MCP) the command still
13
- * validates + pushes and reports "reconcile pending" degrading visibly, never a crash.
13
+ * This is submit-for-PREVIEW, not ship-to-live (`change_accept`/`candidate_accept` /
14
+ * a future `tot ship` is the separate ship gate this command is submit-only, never
15
+ * accept/reject). Step 2b is best-effort: `candidate_open` failing (older MCP,
16
+ * version-control not configured, preview-access capability) is reported and
17
+ * swallowed it never blocks the preview push that already landed. Step 3 calls
18
+ * the MCP `preview_status` read-back: given the commit just pushed it returns
19
+ * { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
20
+ * pending. If that tool isn't present (older MCP) the command still validates +
21
+ * pushes and reports "reconcile pending" — degrading visibly, never a crash.
14
22
  *
15
23
  * Polling (E2): every call carries `waitMs` so a preview_status-aware MCP long-polls
16
24
  * (blocks up to waitMs, waking immediately on arrival) instead of us sleeping blind
@@ -30,6 +38,7 @@
30
38
  * Dependency-free (global fetch + `git`).
31
39
  */
32
40
  import { execFileSync } from "node:child_process";
41
+ import { createHash } from "node:crypto";
33
42
  import { setTimeout as delay } from "node:timers/promises";
34
43
  import { createMcpClient } from "../mcp.mjs";
35
44
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
@@ -104,9 +113,9 @@ export function buildChangeSummary({ message, summary, headSubject = "", statLin
104
113
  return { title, body, autoTitle: !(message && message.trim()) };
105
114
  }
106
115
 
107
- /** Print the change summary block. Push-only: this is what a human relays to the
108
- * approver, or the agent carries into change_open the CLI does not open the
109
- * change record itself. */
116
+ /** Print the change summary block the SAME title/body carried into `candidate_open`
117
+ * (below) as the PR title/description, so what the approver reads in the PR matches
118
+ * what's printed here. */
110
119
  function printChangeSummary({ title, body, autoTitle }) {
111
120
  console.log(`\n Change summary (for the approver / the change record):`);
112
121
  console.log(` ${title}`);
@@ -114,6 +123,160 @@ function printChangeSummary({ title, body, autoTitle }) {
114
123
  if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
115
124
  }
116
125
 
126
+ // ─── PR-backed candidate (g1b candidate_open, unit c1) ──────────────────────────
127
+
128
+ /**
129
+ * Parse `git diff/show --name-status` output into `{ status, path, from? }`
130
+ * entries — `status` is the single letter (A/M/D/R/C…); a rename/copy line
131
+ * (`R100\t<old>\t<new>`) carries both `from` (the old path) and `path` (the new
132
+ * one). Pure — unit-tested without git.
133
+ * @param {string} text
134
+ * @returns {{status:string, path:string, from?:string}[]}
135
+ */
136
+ export function parseNameStatus(text) {
137
+ return text
138
+ .split("\n")
139
+ .map((l) => l.trim())
140
+ .filter(Boolean)
141
+ .map((line) => {
142
+ const parts = line.split("\t");
143
+ const status = parts[0]?.[0] || "";
144
+ if ((status === "R" || status === "C") && parts.length >= 3) {
145
+ return { status, from: parts[1], path: parts[2] };
146
+ }
147
+ return { status, path: parts[1] };
148
+ })
149
+ .filter((e) => e.path);
150
+ }
151
+
152
+ /**
153
+ * Build the g1b `candidate_open` FileChange[] patch from parsed name-status
154
+ * entries — one entry per changed path, `delete:true` for removals (and a
155
+ * rename's old path). Content is read from the committed HEAD blob via
156
+ * `readBlob` (injected — never the working tree, so the patch matches exactly
157
+ * what was pushed). Binary content (doesn't round-trip as clean utf8, or
158
+ * contains a NUL) is sent base64; everything else goes as plain utf8 text.
159
+ * @param {{status:string, path:string, from?:string}[]} entries
160
+ * @param {(path: string) => Buffer} readBlob
161
+ * @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
162
+ */
163
+ export function buildFilePatch(entries, readBlob) {
164
+ const patch = [];
165
+ for (const e of entries) {
166
+ if (e.status === "R") patch.push({ path: e.from, delete: true });
167
+ if (e.status === "D") {
168
+ patch.push({ path: e.path, delete: true });
169
+ continue;
170
+ }
171
+ const buf = readBlob(e.path);
172
+ const asUtf8 = buf.toString("utf8");
173
+ const isCleanUtf8 = !asUtf8.includes("") && Buffer.from(asUtf8, "utf8").equals(buf);
174
+ patch.push(
175
+ isCleanUtf8
176
+ ? { path: e.path, content: asUtf8 }
177
+ : { path: e.path, content: buf.toString("base64"), contentEncoding: "base64" },
178
+ );
179
+ }
180
+ return patch;
181
+ }
182
+
183
+ /**
184
+ * Derive the g1b forge repo name (`"<tenant>-<tag>"`, e.g. `"acme.com-main"`)
185
+ * from the checkout's authenticated origin remote — the SAME name
186
+ * `tenant_checkout` minted it as, so no separate lookup or stored tag is
187
+ * needed. Returns null when the remote URL can't be parsed (candidate_open is
188
+ * then skipped, reported, never crashed). Pure — unit-tested.
189
+ * @param {string} remoteUrl
190
+ * @returns {string|null}
191
+ */
192
+ export function repoNameFromRemote(remoteUrl) {
193
+ try {
194
+ const last = new URL(remoteUrl).pathname.split("/").filter(Boolean).pop();
195
+ return last ? last.replace(/\.git$/, "") : null;
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * A STABLE per-developer-per-tenant candidate handle — so repeat `tot submit`
203
+ * runs update the SAME PR instead of opening a new one each time
204
+ * (`candidate_open` is idempotent on `changeId`). No local state file needed:
205
+ * it's a deterministic hash of the tenant + the acting identity, recomputed
206
+ * fresh every run. Two different developers submitting to the same tenant get
207
+ * two different (non-colliding) candidates. Pure — unit-tested.
208
+ * @param {string} tenant
209
+ * @param {string} actorKey
210
+ * @returns {string}
211
+ */
212
+ export function deriveChangeId(tenant, actorKey) {
213
+ const hash = createHash("sha256").update(`${tenant}|${actorKey}`).digest("hex").slice(0, 16);
214
+ return `local-${hash}`;
215
+ }
216
+
217
+ /** The stable identity key behind `deriveChangeId` — the signed-in developer's
218
+ * email, falling back to the token, then a generic label. Single-plane: the only
219
+ * identity `tot` carries is the developer's own OAuth session. */
220
+ export function actorKeyFor(session) {
221
+ return session?.email || session?.token || "developer";
222
+ }
223
+
224
+ /**
225
+ * Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
226
+ * unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
227
+ * milestone, symmetric with the hosted s6 draft-as-PR path). Builds the patch
228
+ * from the SAME name-status diff `buildChangeSummary` reports on, reuses its
229
+ * title/body as the PR title/description, and prints the resulting
230
+ * changeId/PR number/URL. Best-effort: any failure (no repo could be derived,
231
+ * older MCP, version control not configured, preview-access capability, …) is
232
+ * reported and swallowed — it never blocks the preview push that already
233
+ * landed or the reconcile/compliance read-back that follows.
234
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
235
+ * @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
236
+ * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer }} opts
237
+ */
238
+ export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob }) {
239
+ if (!repo) {
240
+ console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
241
+ return null;
242
+ }
243
+ try {
244
+ const patch = buildFilePatch(patchEntries, readBlob);
245
+ if (patch.length === 0) {
246
+ console.log(` ~ no file changes to open a PR-backed candidate for.`);
247
+ return null;
248
+ }
249
+ const result = await client.callTool("candidate_open", {
250
+ repo,
251
+ changeId,
252
+ // Defensively bounded to candidate_open's own schema limits (title<=200,
253
+ // body<=4000) so an over-long commit subject / summary degrades to a
254
+ // truncated PR title/body instead of an entirely-avoidable tool refusal.
255
+ title: changeSummary.title.slice(0, 200),
256
+ body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
257
+ patch,
258
+ });
259
+ reportCandidate(result, changeId);
260
+ return result;
261
+ } catch (e) {
262
+ console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
263
+ console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /** Print the candidate_open result: the PR the approver reviews, or the MCP's own
269
+ * refusal message when it couldn't open/update one. */
270
+ function reportCandidate(result, changeId) {
271
+ if (result && typeof result.prNumber === "number") {
272
+ console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
273
+ if (result.url) console.log(` ${result.url}`);
274
+ return;
275
+ }
276
+ const msg = result?.message || (result?.raw && String(result.raw)) || JSON.stringify(result ?? null);
277
+ console.log(` ~ PR-backed candidate not opened: ${msg}`);
278
+ }
279
+
117
280
  /** @param {string[]} argv @param {any} ctx */
118
281
  export async function run(argv, ctx) {
119
282
  const env = process.env;
@@ -162,9 +325,10 @@ export async function run(argv, ctx) {
162
325
  // Build the "what changed" summary the approver is greeted with — BEFORE the
163
326
  // push, because `git push` fast-forwards the local origin/<ref> tracking ref and
164
327
  // would zero out the "vs what's live in preview" diff. Explicit -m/--summary win;
165
- // otherwise it's generated from git so the change record is never blank. Push-only:
166
- // we surface it (the agent carries it into change_open) — the CLI never opens the
167
- // change record itself.
328
+ // otherwise it's generated from git so the change record is never blank. This
329
+ // same diff (name-status, so candidate_open also knows adds/deletes/renames)
330
+ // doubles as the source of the PR-backed candidate's file patch (step 2b, below)
331
+ // — one git read, two consumers, so the PR always matches what's printed here.
168
332
  const gitSafe = (cargs) => {
169
333
  try {
170
334
  return git(cargs);
@@ -179,8 +343,9 @@ export async function run(argv, ctx) {
179
343
  : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
180
344
  ? "HEAD~1"
181
345
  : "";
182
- const nameCmd = base ? ["diff", "--name-only", `${base}..HEAD`] : ["show", "--name-only", "--format=", "HEAD"];
183
- const files = gitSafe(nameCmd).split("\n").map((s) => s.trim()).filter(Boolean);
346
+ const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
347
+ const patchEntries = parseNameStatus(gitSafe(statusCmd));
348
+ const files = patchEntries.map((e) => e.path);
184
349
  const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
185
350
  const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
186
351
 
@@ -200,17 +365,30 @@ export async function run(argv, ctx) {
200
365
  console.log(`\n+ submitted ${short} to ${args.ref}.`);
201
366
  printChangeSummary(changeSummary);
202
367
 
203
- // 3. report reconcile + compliance + preview URL from the MCP (graceful seam).
368
+ // 2b + 3. open/update the PR-backed candidate, then report reconcile +
369
+ // compliance + preview URL from the MCP (both graceful seams, same session).
204
370
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
205
371
  const client = createMcpClient(baseUrl);
206
372
  let progress = null;
207
373
  try {
208
374
  // Attach auth before the first server call (developer bearer pre-initialize,
209
375
  // operator credential_validate post-initialize) — see establishSession.
210
- await establishSession(client, { env, prefer: args.identity || undefined });
211
- // Set the active tenant so preview_status reads the right scope (it keys on
212
- // the session's tenant + the commit — no tenant arg of its own).
376
+ const session = await establishSession(client, { env, prefer: args.identity || undefined });
377
+ // Set the active tenant so preview_status/candidate_open read the right scope
378
+ // (both key on the session's bound tenant/app — no tenant arg of their own).
213
379
  await client.callTool("client_switch", { tenant });
380
+
381
+ // 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
382
+ // failure here (older MCP, VC not configured, preview-access capability) is
383
+ // reported and swallowed, never blocking the preview push that already landed.
384
+ await submitCandidate(client, {
385
+ repo: repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim()),
386
+ changeId: deriveChangeId(tenant, actorKeyFor(session)),
387
+ changeSummary,
388
+ patchEntries,
389
+ readBlob: (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] }),
390
+ });
391
+
214
392
  let status;
215
393
  if (args.noWait) {
216
394
  status = normalizePreviewStatus(await client.callTool("preview_status", { commit }));
@@ -2,15 +2,14 @@
2
2
  * `tot whoami` — who is `tot` signed in as, and what can they touch.
3
3
  *
4
4
  * Reads the cached session (~/.tot/credentials.json). If a live token is present
5
- * it best-effort asks the MCP for the stores this identity may act on (proving the
5
+ * it best-effort asks the MCP for the stores this developer may act on (proving the
6
6
  * token still works), degrading cleanly to the cached status if the MCP is
7
- * unreachable or the identity isn't entitled yet. Operator credentials in the env
8
- * are reported too, since they take precedence for tool calls.
7
+ * unreachable or the developer isn't entitled yet.
9
8
  *
10
9
  * Dependency-free.
11
10
  */
12
- import { defaultCredentialsPath, readCredentials, isExpired } from "../token-store.mjs";
13
- import { hasOperatorCreds, establishSession } from "../auth.mjs";
11
+ import { defaultCredentialsPath, readCredentials, isExpired, activeProfile } from "../token-store.mjs";
12
+ import { establishSession } from "../auth.mjs";
14
13
  import { createMcpClient } from "../mcp.mjs";
15
14
  import { normalizeStores, storeListError } from "./checkout.mjs";
16
15
  import { recordServerPolicy } from "../update-check.mjs";
@@ -35,15 +34,13 @@ export async function run(argv, _ctx) {
35
34
  const creds = readCredentials(defaultCredentialsPath(env));
36
35
  const status = sessionStatus(creds);
37
36
 
38
- if (hasOperatorCreds(env)) {
39
- console.log(`operator credentials set in this shell (TOT_APP_DOMAIN=${env.TOT_APP_DOMAIN}).`);
40
- console.log(" these take precedence for `tot` tool calls.");
41
- }
37
+ const profile = activeProfile(env);
38
+ if (profile) console.log(`profile: ${profile} (TOT_PROFILE this terminal's own identity)`);
42
39
 
43
40
  if (!status.signedIn) {
44
41
  console.log("developer: not signed in.");
45
- if (!hasOperatorCreds(env)) console.log(" → next: tot login");
46
- return hasOperatorCreds(env) ? 0 : 1;
42
+ console.log(" → next: tot login");
43
+ return 1;
47
44
  }
48
45
 
49
46
  const when = status.expiresAt ? new Date(status.expiresAt).toISOString() : "unknown";
@@ -58,7 +55,7 @@ export async function run(argv, _ctx) {
58
55
  // Developer bearer must be attached BEFORE initialize so the server binds this
59
56
  // identity at handshake time — otherwise client_list resolves anonymously and
60
57
  // (misleadingly) reports no stores (establishSession orders this correctly).
61
- await establishSession(client, { env, prefer: "developer" });
58
+ await establishSession(client, { env });
62
59
  const listResp = await client.callTool("client_list", {});
63
60
  // Update-awareness Layer 2: an authed response may carry a version-support
64
61
  // policy (wire contract: `cliPolicy`). Safe no-op when absent.
package/src/mcp.mjs CHANGED
@@ -93,8 +93,8 @@ export function createMcpClient(baseUrl, opts = {}) {
93
93
  let rpcId = 0;
94
94
  let sessionId = null;
95
95
  // The developer OAuth bearer (set at login-resolve time via setToken, or up
96
- // front via opts.token). Absent for the operator path, which authenticates by
97
- // credential_validate + the Mcp-Session-Id below.
96
+ // front via opts.token) the only identity `tot` carries. Anonymous calls
97
+ // (e.g. the pre-login handshake) simply omit it.
98
98
  let bearer = typeof opts.token === "string" ? opts.token : null;
99
99
 
100
100
  /** Attach (or clear) the developer bearer for subsequent calls. */
package/src/prompt.mjs ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Tiny terminal-prompt helpers, shared across commands. Every prompt respects a
3
+ * non-TTY (CI, piped stdin) by returning the default instead of hanging — the one
4
+ * rule that keeps `tot` safe to run non-interactively.
5
+ *
6
+ * Dependency-free (node:readline/promises).
7
+ */
8
+ import { createInterface } from "node:readline/promises";
9
+
10
+ /** True only when BOTH stdin and stdout are a TTY — safe to prompt. */
11
+ export function isInteractive() {
12
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
13
+ }
14
+
15
+ /**
16
+ * Ask a yes/no question. Returns `defaultYes` immediately on a non-TTY so nothing
17
+ * ever blocks in CI. Enter (empty answer) takes the default.
18
+ * @param {string} question
19
+ * @param {boolean} defaultYes
20
+ * @returns {Promise<boolean>}
21
+ */
22
+ export async function promptYesNo(question, defaultYes) {
23
+ if (!isInteractive()) return defaultYes;
24
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
25
+ try {
26
+ const ans = (await rl.question(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
27
+ if (!ans) return defaultYes;
28
+ return ans === "y" || ans === "yes";
29
+ } finally {
30
+ rl.close();
31
+ }
32
+ }
@@ -29,17 +29,60 @@
29
29
  * Dependency-free (node:fs/os/path).
30
30
  *
31
31
  * `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
32
+ *
33
+ * PROFILES (`TOT_PROFILE`) — OPTIONAL, for parallel work. Almost every developer
34
+ * leaves this UNSET and uses the single default `credentials.json` — that path is
35
+ * unchanged and is the norm. It exists only when you want MORE THAN ONE identity
36
+ * live at once (a staff `@tokenoftrust.com` sign-in and a plain developer one, or
37
+ * many parallel test identities): export a profile per shell and each gets its OWN
38
+ * credential file under the same `~/.tot` (the renderer cache, last-tenant, etc.
39
+ * stay shared — only the identity splits). The value is an OPAQUE label — any
40
+ * string works, so `export TOT_PROFILE=$(uuidgen)` per terminal/test is fine:
41
+ * - a clean short token (`staff`, `dev`, `test-7`) is used verbatim for a
42
+ * readable `credentials.<profile>.json`;
43
+ * - any other value (symbols, uppercase, long) is HASHED to a stable, safe
44
+ * `credentials.h<hash>.json` — so an arbitrary opaque id can never collide
45
+ * with another or escape `~/.tot`, while `tot whoami` still shows what you set.
32
46
  */
33
47
  import {
34
48
  readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, rmSync,
35
49
  } from "node:fs";
36
50
  import { homedir } from "node:os";
51
+ import { createHash } from "node:crypto";
37
52
  import { join, dirname } from "node:path";
38
53
 
39
- /** Absolute path to the credential file for this environment. */
54
+ /** A short token safe to drop straight into a filename (readable profiles). */
55
+ const CLEAN_PROFILE_RE = /^[a-z0-9_-]{1,64}$/;
56
+
57
+ /**
58
+ * The active profile's DISPLAY label — the raw `TOT_PROFILE`, trimmed — or null
59
+ * when unset. This is what `tot whoami` shows; it is NOT the filename (see
60
+ * profileSlug, which makes any value filesystem-safe).
61
+ */
62
+ export function activeProfile(env = process.env) {
63
+ return String(env.TOT_PROFILE || "").trim() || null;
64
+ }
65
+
66
+ /**
67
+ * The active profile's safe filename token, or null for the default session. A
68
+ * clean short label passes through verbatim (readable files); ANY other opaque
69
+ * value is hashed — collision-resistant and incapable of a `/` or `..` path
70
+ * escape, so `TOT_PROFILE` can be a literally arbitrary identifier. Hashing the
71
+ * RAW value (not a stripped form) keeps distinct tokens distinct.
72
+ */
73
+ export function profileSlug(env = process.env) {
74
+ const raw = String(env.TOT_PROFILE || "").trim();
75
+ if (!raw) return null;
76
+ const lower = raw.toLowerCase();
77
+ if (CLEAN_PROFILE_RE.test(lower)) return lower;
78
+ return `h${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`;
79
+ }
80
+
81
+ /** Absolute path to the credential file for this environment (+ TOT_PROFILE). */
40
82
  export function defaultCredentialsPath(env = process.env) {
41
83
  const home = env.TOT_HOME || homedir();
42
- return join(home, ".tot", "credentials.json");
84
+ const slug = profileSlug(env);
85
+ return join(home, ".tot", slug ? `credentials.${slug}.json` : "credentials.json");
43
86
  }
44
87
 
45
88
  /**
package/src/validate.mjs CHANGED
@@ -184,7 +184,12 @@ function validateScriptsDoc(doc, file, config) {
184
184
  return out;
185
185
  }
186
186
 
187
- const SUPPORTED_BLOCK_CONTRACTS = new Set(["block-palette@1", "block-palette@2", "block-palette@3"]);
187
+ const SUPPORTED_BLOCK_CONTRACTS = new Set([
188
+ "block-palette@1",
189
+ "block-palette@2",
190
+ "block-palette@3",
191
+ "block-palette@4",
192
+ ]);
188
193
  const KNOWN_BLOCKS = new Set([
189
194
  "hero",
190
195
  "promo_tiles",
@@ -192,6 +197,7 @@ const KNOWN_BLOCKS = new Set([
192
197
  "featured_products",
193
198
  "editorial",
194
199
  "newsletter",
200
+ "membership_band",
195
201
  "marketing_hero",
196
202
  "trust_bar",
197
203
  "split_compare",
@@ -209,6 +215,7 @@ const REQUIRED_BLOCK_PROPS = {
209
215
  featured_collections: ["handles"],
210
216
  editorial: ["title"],
211
217
  newsletter: ["title"],
218
+ membership_band: ["tiers"],
212
219
  marketing_hero: ["headline"],
213
220
  trust_bar: ["items"],
214
221
  split_compare: ["title", "before", "after"],