brainerce 2.4.0 → 2.7.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/README.md +8361 -8090
- package/dist/index.d.mts +746 -94
- package/dist/index.d.ts +746 -94
- package/dist/index.js +563 -116
- package/dist/index.mjs +563 -116
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -115,7 +115,7 @@ function isDevGuardsEnabled() {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
// src/version.ts
|
|
118
|
-
var SDK_VERSION = "2.
|
|
118
|
+
var SDK_VERSION = "2.7.0";
|
|
119
119
|
|
|
120
120
|
// src/client.ts
|
|
121
121
|
var DEFAULT_BASE_URL = "https://api.brainerce.com";
|
|
@@ -292,10 +292,14 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
292
292
|
* Storefront (public) and vibe-coded modes only. Rate-limited server-side to
|
|
293
293
|
* 3 requests / 60s per IP, plus one confirmation email per address per 24h.
|
|
294
294
|
*
|
|
295
|
-
* **Where the discount goes.**
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
295
|
+
* **Where the discount goes.** Configure the newsletter welcome offer and the
|
|
296
|
+
* platform issues the coupon itself: read it with `marketing.getBenefit()`,
|
|
297
|
+
* show those terms beside the field, and stop there.
|
|
298
|
+
*
|
|
299
|
+
* ⛔ DO NOT SHOW A CODE AFTER THIS CALL RESOLVES. No coupon exists yet. It is
|
|
300
|
+
* minted when the recipient clicks the confirmation link, and it is mailed to
|
|
301
|
+
* them at that moment — a code rendered here is a code that was never issued.
|
|
302
|
+
* Say "check your email", the same as for the subscription itself.
|
|
299
303
|
*
|
|
300
304
|
* @example
|
|
301
305
|
* ```typescript
|
|
@@ -323,6 +327,163 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
323
327
|
"/marketing/subscribe",
|
|
324
328
|
input
|
|
325
329
|
);
|
|
330
|
+
},
|
|
331
|
+
/**
|
|
332
|
+
* The welcome offer to render beside the signup field, or `null` when this
|
|
333
|
+
* store offers none.
|
|
334
|
+
*
|
|
335
|
+
* Show the discount, how long the coupon lasts, any minimum order, whether
|
|
336
|
+
* it is first-order only, and the merchant's own headline and terms. Then
|
|
337
|
+
* post to `marketing.subscribe()` and tell the shopper to check their
|
|
338
|
+
* inbox.
|
|
339
|
+
*
|
|
340
|
+
* ⛔ THE COUPON DOES NOT EXIST YET at any point in that sequence. It is
|
|
341
|
+
* created when the recipient clicks the confirmation link in their email,
|
|
342
|
+
* and it is mailed to them there. Rendering a code on this screen renders a
|
|
343
|
+
* code nobody was issued.
|
|
344
|
+
*
|
|
345
|
+
* ⛔ Takes no email address and returns nothing about any individual, on
|
|
346
|
+
* purpose. There is no "has this person already claimed" call, because an
|
|
347
|
+
* unauthenticated one would be an oracle for who shops here. If you need to
|
|
348
|
+
* discourage a repeat signup, say the offer is one per address; do not try
|
|
349
|
+
* to detect it.
|
|
350
|
+
*
|
|
351
|
+
* `null` is the common case on a store that never set this up, so handle it
|
|
352
|
+
* rather than assuming the object. Cache it per page load: it belongs to
|
|
353
|
+
* the store, not to the visitor.
|
|
354
|
+
*
|
|
355
|
+
* Storefront (public) and vibe-coded modes.
|
|
356
|
+
*
|
|
357
|
+
* @param locale - Storefront locale, e.g. `"he"`. Picks the language of the
|
|
358
|
+
* headline and terms; falls back to the store language when omitted.
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* ```typescript
|
|
362
|
+
* const offer = await brainerce.marketing.getBenefit('he');
|
|
363
|
+
* if (offer) {
|
|
364
|
+
* // "10% הנחה על ההזמנה הראשונה"
|
|
365
|
+
* render(offer.headline ?? defaultHeadline(offer), offer.terms);
|
|
366
|
+
* }
|
|
367
|
+
* await brainerce.marketing.subscribe({ email, locale: 'he', honeypot });
|
|
368
|
+
* // → "בדקו את המייל שלכם" — never a coupon code
|
|
369
|
+
* ```
|
|
370
|
+
*/
|
|
371
|
+
getBenefit: async (locale) => {
|
|
372
|
+
const query = locale ? { locale } : void 0;
|
|
373
|
+
if (this.isVibeCodedMode()) {
|
|
374
|
+
return this.vibeCodedRequest(
|
|
375
|
+
"GET",
|
|
376
|
+
"/newsletter-benefit",
|
|
377
|
+
void 0,
|
|
378
|
+
query
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
return this.storefrontRequest(
|
|
382
|
+
"GET",
|
|
383
|
+
"/newsletter-benefit",
|
|
384
|
+
void 0,
|
|
385
|
+
query
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
// -------------------- Newsletter signup benefit (Admin) --------------------
|
|
390
|
+
/**
|
|
391
|
+
* Manage the newsletter welcome offer: the terms merchants configure, and the
|
|
392
|
+
* benefits that offer has produced.
|
|
393
|
+
*
|
|
394
|
+
* Admin mode (`apiKey`) only, on the `coupons:read` / `coupons:write` scopes.
|
|
395
|
+
* The benefit IS a coupon feature — it mints a Coupon row and the coupon
|
|
396
|
+
* machinery enforces it — so it carries no scope of its own.
|
|
397
|
+
*
|
|
398
|
+
* ⛔ THERE IS NO "ISSUE A BENEFIT TO THIS ADDRESS" CALL, and there will not
|
|
399
|
+
* be one. A benefit exists because someone submitted the signup form AND
|
|
400
|
+
* clicked the confirmation link; handing one out directly would skip the
|
|
401
|
+
* consent the double opt-in exists to collect and break the one-per-address
|
|
402
|
+
* guarantee that the grant's unique constraint provides. `resend` re-sends a
|
|
403
|
+
* code that already exists; it never creates one.
|
|
404
|
+
*/
|
|
405
|
+
this.newsletterBenefit = {
|
|
406
|
+
/**
|
|
407
|
+
* The store's configuration, or `null` when none was ever saved.
|
|
408
|
+
*
|
|
409
|
+
* `null` and `{ enabled: false }` are different: never configured, versus
|
|
410
|
+
* configured and switched off. Both mean "offer nothing" to a storefront.
|
|
411
|
+
*/
|
|
412
|
+
getSettings: async () => {
|
|
413
|
+
return this.adminRequest(
|
|
414
|
+
"GET",
|
|
415
|
+
"/api/v1/newsletter-benefit/settings"
|
|
416
|
+
);
|
|
417
|
+
},
|
|
418
|
+
/**
|
|
419
|
+
* Create or replace the offer.
|
|
420
|
+
*
|
|
421
|
+
* ⛔ A FULL REPLACEMENT, not a patch. Every field is written, so a field you
|
|
422
|
+
* omit is cleared rather than kept.
|
|
423
|
+
*
|
|
424
|
+
* Saving never rewrites a promise already made: signups still waiting for a
|
|
425
|
+
* confirmation click keep the terms they were shown, and coupons already
|
|
426
|
+
* issued are untouched. Switching `enabled` off stops new offers and leaves
|
|
427
|
+
* every issued coupon working until it expires.
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* ```typescript
|
|
431
|
+
* await brainerce.newsletterBenefit.updateSettings({
|
|
432
|
+
* enabled: true,
|
|
433
|
+
* discountType: 'PERCENTAGE',
|
|
434
|
+
* discountValue: 10,
|
|
435
|
+
* minimumOrderAmount: 200,
|
|
436
|
+
* combinesWithOther: false,
|
|
437
|
+
* validityDays: 7,
|
|
438
|
+
* eligibilityTtlHours: 168,
|
|
439
|
+
* firstOrderOnly: true,
|
|
440
|
+
* content: { he: { headline: '10% הנחה על ההזמנה הראשונה' } },
|
|
441
|
+
* });
|
|
442
|
+
* ```
|
|
443
|
+
*/
|
|
444
|
+
updateSettings: async (input) => {
|
|
445
|
+
return this.adminRequest(
|
|
446
|
+
"PUT",
|
|
447
|
+
"/api/v1/newsletter-benefit/settings",
|
|
448
|
+
input
|
|
449
|
+
);
|
|
450
|
+
},
|
|
451
|
+
/**
|
|
452
|
+
* Issued benefits, newest first, as `{ data, meta }`.
|
|
453
|
+
*
|
|
454
|
+
* ⛔ NO EMAIL FILTER — the API refuses the parameter. Filter the page you
|
|
455
|
+
* get back rather than asking the server about one address.
|
|
456
|
+
*/
|
|
457
|
+
listGrants: async (params = {}) => {
|
|
458
|
+
return this.adminRequest(
|
|
459
|
+
"GET",
|
|
460
|
+
"/api/v1/newsletter-benefit/grants",
|
|
461
|
+
void 0,
|
|
462
|
+
{
|
|
463
|
+
page: params.page,
|
|
464
|
+
limit: params.limit,
|
|
465
|
+
status: params.status,
|
|
466
|
+
from: params.from,
|
|
467
|
+
to: params.to
|
|
468
|
+
}
|
|
469
|
+
);
|
|
470
|
+
},
|
|
471
|
+
/**
|
|
472
|
+
* Re-send one benefit that went astray.
|
|
473
|
+
*
|
|
474
|
+
* ⛔ SENDS THE SAME CODE. It never mints a second coupon, so a support
|
|
475
|
+
* ticket cannot become two discounts. For a benefit whose issuance failed
|
|
476
|
+
* before any coupon existed, this retries the issuance and mails the result.
|
|
477
|
+
*
|
|
478
|
+
* Rejects a signup that has not been confirmed and one that lapsed before a
|
|
479
|
+
* coupon was minted: there is nothing to re-send in either case, and
|
|
480
|
+
* nothing that may be created.
|
|
481
|
+
*/
|
|
482
|
+
resend: async (grantId) => {
|
|
483
|
+
return this.adminRequest(
|
|
484
|
+
"POST",
|
|
485
|
+
`/api/v1/newsletter-benefit/grants/${encodePathSegment(grantId)}/resend`
|
|
486
|
+
);
|
|
326
487
|
}
|
|
327
488
|
};
|
|
328
489
|
// -------------------- Stock alerts --------------------
|
|
@@ -359,10 +520,20 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
359
520
|
* Rich Text, and Page.
|
|
360
521
|
*
|
|
361
522
|
* Works in all three SDK modes (vibe-coded, storefront, admin):
|
|
362
|
-
* - **Public reads** (`get`, `list`, `getBySlug`):
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
523
|
+
* - **Public reads** (`get`, `list`, `getBySlug`): storefront and
|
|
524
|
+
* vibe-coded mode. There is no admin equivalent of a by-key/by-slug
|
|
525
|
+
* read — in admin mode they throw and point you at `listAdmin()` /
|
|
526
|
+
* `findById()`.
|
|
527
|
+
* - **Admin reads** (`listAdmin`, `findById`) and **writes** (`create`,
|
|
528
|
+
* `update`, `publish`, `unpublish`, `remove`): admin mode only — they
|
|
529
|
+
* call `/api/content/...` with the API key. Calling from storefront /
|
|
530
|
+
* vibe-coded mode throws.
|
|
531
|
+
*
|
|
532
|
+
* **⚠️ Every admin method takes an explicit `storeId`.** Admin mode has no
|
|
533
|
+
* ambient store (`storeId` is only set in storefront mode), and the routes
|
|
534
|
+
* are store-scoped: omitting it is rejected fail-closed by the store scope
|
|
535
|
+
* guard (`403 STORE_SCOPE_REQUIRED`). Pass the id of the store your API key
|
|
536
|
+
* is bound to — naming any other store is rejected as cross-tenant.
|
|
366
537
|
*
|
|
367
538
|
* **Default key:** every type has `'main'` as its universal default key.
|
|
368
539
|
* Pass no argument to fetch the main entry; pass a custom key (e.g.
|
|
@@ -387,12 +558,15 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
387
558
|
* });
|
|
388
559
|
* }
|
|
389
560
|
*
|
|
390
|
-
* // Admin — create a shipping FAQ in DRAFT
|
|
391
|
-
* await client.content.faq.create(
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
* }
|
|
561
|
+
* // Admin — create a shipping FAQ in DRAFT (storeId is required)
|
|
562
|
+
* await client.content.faq.create(
|
|
563
|
+
* {
|
|
564
|
+
* key: 'shipping',
|
|
565
|
+
* name: 'Shipping FAQ',
|
|
566
|
+
* data: { items: [{ question: '…', answer: '…' }] },
|
|
567
|
+
* },
|
|
568
|
+
* 'store_123'
|
|
569
|
+
* );
|
|
396
570
|
* ```
|
|
397
571
|
*/
|
|
398
572
|
this.content = (() => {
|
|
@@ -413,7 +587,7 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
413
587
|
return this.storefrontRequest("GET", path, void 0, query).catch(onNotFound);
|
|
414
588
|
}
|
|
415
589
|
throw new BrainerceError(
|
|
416
|
-
"content.<type>.get(key) is a public-read API. In admin mode, call client.content.
|
|
590
|
+
"content.<type>.get(key) is a public-read API. In admin mode, call client.content.listAdmin({ storeId, type: '" + type + "' }) and filter by key, or client.content.findById(id, storeId) if you already hold the id.",
|
|
417
591
|
400
|
|
418
592
|
);
|
|
419
593
|
};
|
|
@@ -426,9 +600,9 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
426
600
|
if (this.storeId && !this.apiKey) {
|
|
427
601
|
return this.storefrontRequest("GET", "/content", void 0, query);
|
|
428
602
|
}
|
|
429
|
-
|
|
430
|
-
"
|
|
431
|
-
|
|
603
|
+
throw new BrainerceError(
|
|
604
|
+
"content.<type>.list() is a public-read API. In admin mode, call client.content.listAdmin({ storeId, type: '" + type + "' }).",
|
|
605
|
+
400
|
|
432
606
|
);
|
|
433
607
|
};
|
|
434
608
|
const requireAdmin = (action) => {
|
|
@@ -439,32 +613,59 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
439
613
|
);
|
|
440
614
|
}
|
|
441
615
|
};
|
|
442
|
-
const
|
|
616
|
+
const requireStoreId = (action, storeId) => {
|
|
617
|
+
if (!storeId) {
|
|
618
|
+
throw new BrainerceError(
|
|
619
|
+
`client.content.${action}() requires a storeId. Admin mode has no ambient store \u2014 pass the id of the store your API key is bound to.`,
|
|
620
|
+
400
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
return { storeId };
|
|
624
|
+
};
|
|
625
|
+
const createByType = async (type, input, storeId) => {
|
|
443
626
|
requireAdmin("create");
|
|
444
|
-
return this.adminRequest(
|
|
627
|
+
return this.adminRequest(
|
|
628
|
+
"POST",
|
|
629
|
+
adminBase(),
|
|
630
|
+
{ ...input, type },
|
|
631
|
+
requireStoreId("create", storeId)
|
|
632
|
+
);
|
|
445
633
|
};
|
|
446
|
-
const updateById = async (id, input) => {
|
|
634
|
+
const updateById = async (id, input, storeId) => {
|
|
447
635
|
requireAdmin("update");
|
|
448
636
|
return this.adminRequest(
|
|
449
637
|
"PATCH",
|
|
450
638
|
`${adminBase()}/${encodeURIComponent(id)}`,
|
|
451
|
-
input
|
|
639
|
+
input,
|
|
640
|
+
requireStoreId("update", storeId)
|
|
452
641
|
);
|
|
453
642
|
};
|
|
454
|
-
const publishById = async (id) => {
|
|
643
|
+
const publishById = async (id, storeId) => {
|
|
455
644
|
requireAdmin("publish");
|
|
456
|
-
return this.adminRequest(
|
|
645
|
+
return this.adminRequest(
|
|
646
|
+
"POST",
|
|
647
|
+
`${adminBase()}/${encodeURIComponent(id)}/publish`,
|
|
648
|
+
void 0,
|
|
649
|
+
requireStoreId("publish", storeId)
|
|
650
|
+
);
|
|
457
651
|
};
|
|
458
|
-
const unpublishById = async (id) => {
|
|
652
|
+
const unpublishById = async (id, storeId) => {
|
|
459
653
|
requireAdmin("unpublish");
|
|
460
654
|
return this.adminRequest(
|
|
461
655
|
"POST",
|
|
462
|
-
`${adminBase()}/${encodeURIComponent(id)}/unpublish
|
|
656
|
+
`${adminBase()}/${encodeURIComponent(id)}/unpublish`,
|
|
657
|
+
void 0,
|
|
658
|
+
requireStoreId("unpublish", storeId)
|
|
463
659
|
);
|
|
464
660
|
};
|
|
465
|
-
const removeById = async (id) => {
|
|
661
|
+
const removeById = async (id, storeId) => {
|
|
466
662
|
requireAdmin("remove");
|
|
467
|
-
await this.adminRequest(
|
|
663
|
+
await this.adminRequest(
|
|
664
|
+
"DELETE",
|
|
665
|
+
`${adminBase()}/${encodeURIComponent(id)}`,
|
|
666
|
+
void 0,
|
|
667
|
+
requireStoreId("remove", storeId)
|
|
668
|
+
);
|
|
468
669
|
};
|
|
469
670
|
function makeNamespace(type) {
|
|
470
671
|
return {
|
|
@@ -474,10 +675,17 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
474
675
|
* hasn't seeded yet.
|
|
475
676
|
*/
|
|
476
677
|
get: (key = DEFAULT_KEY, locale) => publicGet(type, key, locale),
|
|
477
|
-
/**
|
|
678
|
+
/**
|
|
679
|
+
* List all PUBLISHED entries of this type (storefront / vibe-coded
|
|
680
|
+
* mode). In admin mode this throws — use
|
|
681
|
+
* `client.content.listAdmin({ storeId, type })`.
|
|
682
|
+
*/
|
|
478
683
|
list: (locale) => publicList(type, locale),
|
|
479
|
-
/**
|
|
480
|
-
|
|
684
|
+
/**
|
|
685
|
+
* Create a new entry in DRAFT (admin mode).
|
|
686
|
+
* `storeId` is required — see the namespace docs above.
|
|
687
|
+
*/
|
|
688
|
+
create: (input, storeId) => createByType(type, input, storeId)
|
|
481
689
|
};
|
|
482
690
|
}
|
|
483
691
|
return {
|
|
@@ -517,30 +725,78 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
517
725
|
}
|
|
518
726
|
},
|
|
519
727
|
// ---------- Admin operations (cross-type) ----------
|
|
520
|
-
/**
|
|
521
|
-
|
|
728
|
+
/**
|
|
729
|
+
* Find a single row by its admin id (admin mode).
|
|
730
|
+
*
|
|
731
|
+
* @example
|
|
732
|
+
* ```typescript
|
|
733
|
+
* const row = await client.content.findById('cnt_123', 'store_123');
|
|
734
|
+
* ```
|
|
735
|
+
*/
|
|
736
|
+
findById: async (id, storeId) => {
|
|
522
737
|
requireAdmin("findById");
|
|
523
|
-
return this.adminRequest("GET", `${adminBase()}/${encodeURIComponent(id)}`);
|
|
524
|
-
},
|
|
525
|
-
/** List rows in admin mode with optional filters. */
|
|
526
|
-
listAdmin: async (filters) => {
|
|
527
|
-
requireAdmin("listAdmin");
|
|
528
|
-
const params = new URLSearchParams();
|
|
529
|
-
if (filters?.type) params.set("type", filters.type);
|
|
530
|
-
if (filters?.status) params.set("status", filters.status);
|
|
531
|
-
const qs = params.toString();
|
|
532
738
|
return this.adminRequest(
|
|
533
739
|
"GET",
|
|
534
|
-
|
|
740
|
+
`${adminBase()}/${encodeURIComponent(id)}`,
|
|
741
|
+
void 0,
|
|
742
|
+
requireStoreId("findById", storeId)
|
|
535
743
|
);
|
|
536
744
|
},
|
|
537
|
-
/**
|
|
745
|
+
/**
|
|
746
|
+
* List rows in admin mode. `storeId` is required; `type` and `status`
|
|
747
|
+
* are optional filters.
|
|
748
|
+
*
|
|
749
|
+
* @example
|
|
750
|
+
* ```typescript
|
|
751
|
+
* const faqs = await client.content.listAdmin({
|
|
752
|
+
* storeId: 'store_123',
|
|
753
|
+
* type: 'FAQ',
|
|
754
|
+
* status: 'DRAFT',
|
|
755
|
+
* });
|
|
756
|
+
* ```
|
|
757
|
+
*/
|
|
758
|
+
listAdmin: async (filters) => {
|
|
759
|
+
requireAdmin("listAdmin");
|
|
760
|
+
const query = requireStoreId("listAdmin", filters?.storeId);
|
|
761
|
+
if (filters.type) query.type = filters.type;
|
|
762
|
+
if (filters.status) query.status = filters.status;
|
|
763
|
+
return this.adminRequest("GET", adminBase(), void 0, query);
|
|
764
|
+
},
|
|
765
|
+
/**
|
|
766
|
+
* Replace `data` (and optional metadata) on an existing row.
|
|
767
|
+
*
|
|
768
|
+
* @example
|
|
769
|
+
* ```typescript
|
|
770
|
+
* await client.content.update('cnt_123', { name: 'Shipping FAQ' }, 'store_123');
|
|
771
|
+
* ```
|
|
772
|
+
*/
|
|
538
773
|
update: updateById,
|
|
539
|
-
/**
|
|
774
|
+
/**
|
|
775
|
+
* Transition status DRAFT → PUBLISHED.
|
|
776
|
+
*
|
|
777
|
+
* @example
|
|
778
|
+
* ```typescript
|
|
779
|
+
* await client.content.publish('cnt_123', 'store_123');
|
|
780
|
+
* ```
|
|
781
|
+
*/
|
|
540
782
|
publish: publishById,
|
|
541
|
-
/**
|
|
783
|
+
/**
|
|
784
|
+
* Transition status PUBLISHED → DRAFT.
|
|
785
|
+
*
|
|
786
|
+
* @example
|
|
787
|
+
* ```typescript
|
|
788
|
+
* await client.content.unpublish('cnt_123', 'store_123');
|
|
789
|
+
* ```
|
|
790
|
+
*/
|
|
542
791
|
unpublish: unpublishById,
|
|
543
|
-
/**
|
|
792
|
+
/**
|
|
793
|
+
* Hard delete the row. Admin mode only.
|
|
794
|
+
*
|
|
795
|
+
* @example
|
|
796
|
+
* ```typescript
|
|
797
|
+
* await client.content.remove('cnt_123', 'store_123');
|
|
798
|
+
* ```
|
|
799
|
+
*/
|
|
544
800
|
remove: removeById
|
|
545
801
|
};
|
|
546
802
|
})();
|
|
@@ -548,15 +804,26 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
548
804
|
/**
|
|
549
805
|
* Read and manage blog posts.
|
|
550
806
|
*
|
|
807
|
+
* **⚠️ Every admin call takes an explicit `storeId`.** Admin mode has no
|
|
808
|
+
* ambient store (`storeId` is only set in storefront mode) and the admin
|
|
809
|
+
* routes are store-scoped: omitting it is rejected fail-closed by the store
|
|
810
|
+
* scope guard (`403 STORE_SCOPE_REQUIRED`). Pass the id of the store your
|
|
811
|
+
* API key is bound to — naming any other store is rejected as cross-tenant.
|
|
812
|
+
*
|
|
813
|
+
* Admin lookups are **by id**, not by slug (`getPost(slug)` is a public read
|
|
814
|
+
* and throws in admin mode — use `findById(id, storeId)`).
|
|
815
|
+
*
|
|
551
816
|
* ```typescript
|
|
552
817
|
* // Storefront / vibe-coded: list published posts
|
|
553
818
|
* const { data: posts } = await brainerce.blog.getPosts({ category: 'news' });
|
|
554
819
|
*
|
|
555
|
-
* // Fetch one by slug
|
|
820
|
+
* // Fetch one by slug (storefront / vibe-coded)
|
|
556
821
|
* const post = await brainerce.blog.getPost('my-first-post');
|
|
557
822
|
*
|
|
558
|
-
* // Admin: create a draft
|
|
559
|
-
* const
|
|
823
|
+
* // Admin: list, read one, and create a draft
|
|
824
|
+
* const all = await brainerce.blog.getPosts({}, 'store_123');
|
|
825
|
+
* const one = await brainerce.blog.findById('post_123', 'store_123');
|
|
826
|
+
* const draft = await brainerce.blog.create({ title: 'Hello World' }, 'store_123');
|
|
560
827
|
* ```
|
|
561
828
|
*/
|
|
562
829
|
this.blog = /* @__PURE__ */ (() => {
|
|
@@ -577,12 +844,30 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
577
844
|
const buildQuery = (params) => Object.fromEntries(
|
|
578
845
|
Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)])
|
|
579
846
|
);
|
|
847
|
+
const requireStoreId = (action, storeId) => {
|
|
848
|
+
if (!storeId) {
|
|
849
|
+
throw new BrainerceError(
|
|
850
|
+
`client.blog.${action}() requires a storeId in admin mode. Admin mode has no ambient store \u2014 pass the id of the store your API key is bound to.`,
|
|
851
|
+
400
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
return storeId;
|
|
855
|
+
};
|
|
580
856
|
return {
|
|
581
857
|
/**
|
|
582
|
-
* List
|
|
583
|
-
*
|
|
858
|
+
* List posts. Filters: `category`, `tag`, `page`, `limit`.
|
|
859
|
+
*
|
|
860
|
+
* Storefront / vibe-coded mode lists PUBLISHED posts and ignores
|
|
861
|
+
* `storeId` (the store is already in the base URL). Admin mode lists
|
|
862
|
+
* drafts too and REQUIRES `storeId`.
|
|
863
|
+
*
|
|
864
|
+
* @example
|
|
865
|
+
* ```typescript
|
|
866
|
+
* const { data } = await client.blog.getPosts({ category: 'news' }); // storefront
|
|
867
|
+
* const { data } = await client.blog.getPosts({}, 'store_123'); // admin
|
|
868
|
+
* ```
|
|
584
869
|
*/
|
|
585
|
-
getPosts: (params = {}) => {
|
|
870
|
+
getPosts: (params = {}, storeId) => {
|
|
586
871
|
const query = buildQuery(params);
|
|
587
872
|
if (this.isVibeCodedMode()) {
|
|
588
873
|
return this.vibeCodedRequest(
|
|
@@ -604,12 +889,23 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
604
889
|
"GET",
|
|
605
890
|
adminBase,
|
|
606
891
|
void 0,
|
|
607
|
-
|
|
892
|
+
{
|
|
893
|
+
...query,
|
|
894
|
+
storeId: requireStoreId("getPosts", storeId)
|
|
895
|
+
}
|
|
608
896
|
);
|
|
609
897
|
},
|
|
610
898
|
/**
|
|
611
|
-
* Fetch one
|
|
612
|
-
*
|
|
899
|
+
* Fetch one PUBLISHED post by its slug. Returns `null` on 404.
|
|
900
|
+
*
|
|
901
|
+
* Storefront / vibe-coded mode only — the admin API has no by-slug
|
|
902
|
+
* lookup (`GET /api/blog/posts/:id` is by id), so this throws in admin
|
|
903
|
+
* mode rather than issuing a request that can only 404.
|
|
904
|
+
*
|
|
905
|
+
* @example
|
|
906
|
+
* ```typescript
|
|
907
|
+
* const post = await client.blog.getPost('my-first-post');
|
|
908
|
+
* ```
|
|
613
909
|
*/
|
|
614
910
|
getPost: (slug) => {
|
|
615
911
|
const path = `${publicBase}/${encodeURIComponent(slug)}`;
|
|
@@ -619,42 +915,111 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
619
915
|
if (this.storeId && !this.apiKey) {
|
|
620
916
|
return this.storefrontRequest("GET", path).catch(onNotFound);
|
|
621
917
|
}
|
|
622
|
-
|
|
918
|
+
throw new BrainerceError(
|
|
919
|
+
"client.blog.getPost(slug) is a public-read API. In admin mode, look a post up by id with client.blog.findById(id, storeId), or find it with client.blog.getPosts({}, storeId).",
|
|
920
|
+
400
|
|
921
|
+
);
|
|
922
|
+
},
|
|
923
|
+
/**
|
|
924
|
+
* Fetch one post by its admin id — drafts included. Admin mode only.
|
|
925
|
+
* Returns `null` on 404.
|
|
926
|
+
*
|
|
927
|
+
* @example
|
|
928
|
+
* ```typescript
|
|
929
|
+
* const post = await client.blog.findById('post_123', 'store_123');
|
|
930
|
+
* ```
|
|
931
|
+
*/
|
|
932
|
+
findById: (id, storeId) => {
|
|
933
|
+
requireAdmin("findById");
|
|
934
|
+
return this.adminRequest(
|
|
935
|
+
"GET",
|
|
936
|
+
`${adminBase}/${encodeURIComponent(id)}`,
|
|
937
|
+
void 0,
|
|
938
|
+
{ storeId: requireStoreId("findById", storeId) }
|
|
939
|
+
).catch(onNotFound);
|
|
623
940
|
},
|
|
624
|
-
/**
|
|
625
|
-
|
|
941
|
+
/**
|
|
942
|
+
* Create a blog post in DRAFT status. Admin mode only.
|
|
943
|
+
*
|
|
944
|
+
* @example
|
|
945
|
+
* ```typescript
|
|
946
|
+
* const draft = await client.blog.create({ title: 'Hello World' }, 'store_123');
|
|
947
|
+
* ```
|
|
948
|
+
*/
|
|
949
|
+
create: (input, storeId) => {
|
|
626
950
|
requireAdmin("create");
|
|
627
|
-
return this.adminRequest("POST", adminBase, input
|
|
951
|
+
return this.adminRequest("POST", adminBase, input, {
|
|
952
|
+
storeId: requireStoreId("create", storeId)
|
|
953
|
+
});
|
|
628
954
|
},
|
|
629
|
-
/**
|
|
630
|
-
|
|
955
|
+
/**
|
|
956
|
+
* Update a blog post by ID. Admin mode only.
|
|
957
|
+
*
|
|
958
|
+
* @example
|
|
959
|
+
* ```typescript
|
|
960
|
+
* await client.blog.update('post_123', { title: 'Renamed' }, 'store_123');
|
|
961
|
+
* ```
|
|
962
|
+
*/
|
|
963
|
+
update: (id, input, storeId) => {
|
|
631
964
|
requireAdmin("update");
|
|
632
965
|
return this.adminRequest(
|
|
633
966
|
"PATCH",
|
|
634
967
|
`${adminBase}/${encodeURIComponent(id)}`,
|
|
635
|
-
input
|
|
968
|
+
input,
|
|
969
|
+
{ storeId: requireStoreId("update", storeId) }
|
|
636
970
|
);
|
|
637
971
|
},
|
|
638
|
-
/**
|
|
639
|
-
|
|
972
|
+
/**
|
|
973
|
+
* Transition status → PUBLISHED (sets publishedAt = now if unset).
|
|
974
|
+
* Admin mode only.
|
|
975
|
+
*
|
|
976
|
+
* @example
|
|
977
|
+
* ```typescript
|
|
978
|
+
* await client.blog.publish('post_123', 'store_123');
|
|
979
|
+
* ```
|
|
980
|
+
*/
|
|
981
|
+
publish: (id, storeId) => {
|
|
640
982
|
requireAdmin("publish");
|
|
641
983
|
return this.adminRequest(
|
|
642
984
|
"POST",
|
|
643
|
-
`${adminBase}/${encodeURIComponent(id)}/publish
|
|
985
|
+
`${adminBase}/${encodeURIComponent(id)}/publish`,
|
|
986
|
+
void 0,
|
|
987
|
+
{ storeId: requireStoreId("publish", storeId) }
|
|
644
988
|
);
|
|
645
989
|
},
|
|
646
|
-
/**
|
|
647
|
-
|
|
990
|
+
/**
|
|
991
|
+
* Transition status PUBLISHED → DRAFT. Admin mode only.
|
|
992
|
+
*
|
|
993
|
+
* @example
|
|
994
|
+
* ```typescript
|
|
995
|
+
* await client.blog.unpublish('post_123', 'store_123');
|
|
996
|
+
* ```
|
|
997
|
+
*/
|
|
998
|
+
unpublish: (id, storeId) => {
|
|
648
999
|
requireAdmin("unpublish");
|
|
649
1000
|
return this.adminRequest(
|
|
650
1001
|
"POST",
|
|
651
|
-
`${adminBase}/${encodeURIComponent(id)}/unpublish
|
|
1002
|
+
`${adminBase}/${encodeURIComponent(id)}/unpublish`,
|
|
1003
|
+
void 0,
|
|
1004
|
+
{ storeId: requireStoreId("unpublish", storeId) }
|
|
652
1005
|
);
|
|
653
1006
|
},
|
|
654
|
-
/**
|
|
655
|
-
|
|
1007
|
+
/**
|
|
1008
|
+
* Hard-delete a blog post. Admin mode only.
|
|
1009
|
+
*
|
|
1010
|
+
* @example
|
|
1011
|
+
* ```typescript
|
|
1012
|
+
* await client.blog.remove('post_123', 'store_123');
|
|
1013
|
+
* ```
|
|
1014
|
+
*/
|
|
1015
|
+
remove: async (id, storeId) => {
|
|
656
1016
|
requireAdmin("remove");
|
|
657
|
-
await this.adminRequest(
|
|
1017
|
+
await this.adminRequest(
|
|
1018
|
+
"DELETE",
|
|
1019
|
+
`${adminBase}/${encodeURIComponent(id)}`,
|
|
1020
|
+
void 0,
|
|
1021
|
+
{ storeId: requireStoreId("remove", storeId) }
|
|
1022
|
+
);
|
|
658
1023
|
}
|
|
659
1024
|
};
|
|
660
1025
|
})();
|
|
@@ -2181,6 +2546,64 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
2181
2546
|
* console.log('Product type:', product.type); // 'VARIABLE'
|
|
2182
2547
|
* ```
|
|
2183
2548
|
*/
|
|
2549
|
+
/**
|
|
2550
|
+
* Read a kit's contents, with its resolved price and how many can be sold.
|
|
2551
|
+
*
|
|
2552
|
+
* A KIT is one purchasable product assembled from other catalog products. It
|
|
2553
|
+
* holds no inventory of its own: `available` is whichever component runs out
|
|
2554
|
+
* first. Outside `FIXED` pricing the product row's `basePrice` is a
|
|
2555
|
+
* placeholder, so use the `price` returned here.
|
|
2556
|
+
*
|
|
2557
|
+
* Safe to call for any product — a non-KIT returns an empty, unsellable
|
|
2558
|
+
* shape rather than throwing, so callers need not branch on product type.
|
|
2559
|
+
*
|
|
2560
|
+
* @example
|
|
2561
|
+
* ```typescript
|
|
2562
|
+
* const kit = await client.getKitComponents('prod_123');
|
|
2563
|
+
* console.log(kit.price, kit.available, kit.components.length);
|
|
2564
|
+
* ```
|
|
2565
|
+
*/
|
|
2566
|
+
async getKitComponents(productId) {
|
|
2567
|
+
return this.request(
|
|
2568
|
+
"GET",
|
|
2569
|
+
`/api/v1/products/${encodePathSegment(productId)}/kit-components`
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* Replace a kit's contents, and optionally how it is priced.
|
|
2574
|
+
*
|
|
2575
|
+
* A full replace, not a patch: send the list you want the kit to end up with.
|
|
2576
|
+
* Omit `pricingMode` to leave the kit's current mode untouched.
|
|
2577
|
+
*
|
|
2578
|
+
* Rejected: a product that is not a KIT, a component from another store, a
|
|
2579
|
+
* component that is itself a KIT, a VARIABLE component with no variant
|
|
2580
|
+
* pinned, a variant that does not belong to its product, the same slot
|
|
2581
|
+
* listed twice, and a component whose product or pinned variant is not
|
|
2582
|
+
* published.
|
|
2583
|
+
*
|
|
2584
|
+
* That last one is checked over the WHOLE list you send, not just the rows
|
|
2585
|
+
* you changed. Once a product already inside a kit is unpublished, no edit
|
|
2586
|
+
* to that kit saves until you publish it again or drop it from the list.
|
|
2587
|
+
*
|
|
2588
|
+
* @example
|
|
2589
|
+
* ```typescript
|
|
2590
|
+
* await client.setKitComponents('prod_123', {
|
|
2591
|
+
* components: [
|
|
2592
|
+
* { componentProductId: 'prod_bottle', quantity: 1 },
|
|
2593
|
+
* { componentProductId: 'prod_glass', quantity: 2 },
|
|
2594
|
+
* ],
|
|
2595
|
+
* pricingMode: 'SUM_MINUS_PERCENT',
|
|
2596
|
+
* discountValue: 10,
|
|
2597
|
+
* });
|
|
2598
|
+
* ```
|
|
2599
|
+
*/
|
|
2600
|
+
async setKitComponents(productId, body) {
|
|
2601
|
+
return this.request(
|
|
2602
|
+
"PUT",
|
|
2603
|
+
`/api/v1/products/${encodePathSegment(productId)}/kit-components`,
|
|
2604
|
+
body
|
|
2605
|
+
);
|
|
2606
|
+
}
|
|
2184
2607
|
async convertToVariable(productId) {
|
|
2185
2608
|
return this.request(
|
|
2186
2609
|
"PATCH",
|
|
@@ -2765,6 +3188,13 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
2765
3188
|
* exist and 404'd silently. The live route is product-scoped:
|
|
2766
3189
|
* `GET /api/v1/products/:id/inventory`. A product with no inventory row
|
|
2767
3190
|
* reads back as all zeroes rather than 404ing.
|
|
3191
|
+
*
|
|
3192
|
+
* The response carries the whole {@link ProductInventoryResponse} — the
|
|
3193
|
+
* `InventoryItem` id, `trackingMode`, `backorderMode`, `backorderLimit`,
|
|
3194
|
+
* `lowStockThreshold`, `lastInventorySyncAt` and `updatedAt` alongside the
|
|
3195
|
+
* three counters. It always did; only the three counters were declared.
|
|
3196
|
+
* On the all-zeroes no-row branch everything but the counters is absent,
|
|
3197
|
+
* so test `id` rather than expecting a 404.
|
|
2768
3198
|
*/
|
|
2769
3199
|
async getInventory(productId) {
|
|
2770
3200
|
return this.adminRequest(
|
|
@@ -9956,71 +10386,88 @@ var _BrainerceClient = class _BrainerceClient {
|
|
|
9956
10386
|
}
|
|
9957
10387
|
throw new Error("uploadReviewPhoto requires storefront or vibe-coded mode");
|
|
9958
10388
|
}
|
|
9959
|
-
// -------------------- Team Management (Admin) -
|
|
9960
|
-
//
|
|
9961
|
-
// a `brainerce_*` API key.
|
|
9962
|
-
//
|
|
9963
|
-
//
|
|
9964
|
-
//
|
|
9965
|
-
//
|
|
10389
|
+
// -------------------- Team Management (Admin) - NOT REACHABLE BY API KEY --------------------
|
|
10390
|
+
// ⛔ These seven used to be documented as "the ONLY team endpoints reachable
|
|
10391
|
+
// with a `brainerce_*` API key". That was wrong, and every one of them 403s.
|
|
10392
|
+
//
|
|
10393
|
+
// The routes exist and the guards admit an api_key, but admission is not the
|
|
10394
|
+
// same as success. `external-api.controller.ts` hands `TeamService` a synthetic
|
|
10395
|
+
// principal — `SYSTEM_USER_ID = 'api-key-user'` (`:171`, used at `:4572`,
|
|
10396
|
+
// `:4586`, `:4607`, ...) — and every TeamService method opens with
|
|
10397
|
+
// `verifyAccountAccess` / `verifyAccountOwner`, which is an
|
|
10398
|
+
// `accountUser.findFirst({ where: { userId, ... } })`. No `AccountUser` row has
|
|
10399
|
+
// `userId = 'api-key-user'` anywhere; the sentinel is row-less by construction.
|
|
10400
|
+
// So the lookup misses and every call throws `Access denied to this account`.
|
|
10401
|
+
//
|
|
10402
|
+
// The store-level equivalents (`getStoreTeam`, `inviteStoreMember`, ...) sit
|
|
10403
|
+
// behind `DashboardOnlyGuard`, which rejects api_key principals by design. So
|
|
10404
|
+
// BOTH families are dashboard-only and there is no API-key path to team
|
|
10405
|
+
// management at all.
|
|
10406
|
+
//
|
|
10407
|
+
// ⛔ Do NOT "fix" this by letting api_key through. Team membership is
|
|
10408
|
+
// ACCOUNT-scoped and `inviteMember` grants `Role.ACCOUNT_OWNER`, i.e. ownership
|
|
10409
|
+
// of every store on the account. An API key is bound to exactly ONE store, so
|
|
10410
|
+
// admitting it here converts a single-store credential into account ownership.
|
|
10411
|
+
// That is a privilege-escalation primitive, not a missing feature.
|
|
10412
|
+
//
|
|
10413
|
+
// These throw rather than return a 403 from the wire, so the failure names its
|
|
10414
|
+
// own cause instead of arriving as a bare status code. Same treatment as
|
|
10415
|
+
// `createDonation` above. Agents: use the dashboard, or the `team:*` tools on
|
|
10416
|
+
// the admin MCP server, which are OAuth-authenticated and store-scoped.
|
|
10417
|
+
/** The message every team method throws. One string so they cannot drift. */
|
|
10418
|
+
teamIsDashboardOnly(method) {
|
|
10419
|
+
throw new Error(
|
|
10420
|
+
`${method} is not available with an API key. Team management is account-scoped and every route rejects the api_key principal, so this call can only fail. Manage team members in the dashboard, or use the store-scoped team tools on the admin MCP server (OAuth), which are bound to a single store.`
|
|
10421
|
+
);
|
|
10422
|
+
}
|
|
9966
10423
|
/**
|
|
9967
|
-
* @deprecated Retiring, but
|
|
9968
|
-
* is dashboard-only
|
|
10424
|
+
* @deprecated Retiring, but this ALWAYS throws: `/v1/team/*` rejects the
|
|
10425
|
+
* api_key principal and `getStoreTeam` is dashboard-only. Use the dashboard.
|
|
9969
10426
|
*/
|
|
9970
10427
|
async getTeamMembers() {
|
|
9971
|
-
return this.
|
|
10428
|
+
return this.teamIsDashboardOnly("getTeamMembers");
|
|
9972
10429
|
}
|
|
9973
10430
|
/**
|
|
9974
|
-
* @deprecated Retiring, but
|
|
9975
|
-
* is dashboard-only
|
|
10431
|
+
* @deprecated Retiring, but this ALWAYS throws: `/v1/team/*` rejects the
|
|
10432
|
+
* api_key principal and `getStoreTeam` is dashboard-only. Use the dashboard.
|
|
9976
10433
|
*/
|
|
9977
10434
|
async getTeamInvitations() {
|
|
9978
|
-
return this.
|
|
10435
|
+
return this.teamIsDashboardOnly("getTeamInvitations");
|
|
9979
10436
|
}
|
|
9980
10437
|
/**
|
|
9981
|
-
* @deprecated Retiring, but
|
|
9982
|
-
*
|
|
10438
|
+
* @deprecated Retiring, but this ALWAYS throws: `/v1/team/*` rejects the
|
|
10439
|
+
* api_key principal and the store-level route is dashboard-only. Use the dashboard.
|
|
9983
10440
|
*/
|
|
9984
|
-
async inviteTeamMember(
|
|
9985
|
-
return this.
|
|
10441
|
+
async inviteTeamMember(_data) {
|
|
10442
|
+
return this.teamIsDashboardOnly("inviteTeamMember");
|
|
9986
10443
|
}
|
|
9987
10444
|
/**
|
|
9988
|
-
* @deprecated Retiring, but
|
|
9989
|
-
*
|
|
10445
|
+
* @deprecated Retiring, but this ALWAYS throws: `/v1/team/*` rejects the
|
|
10446
|
+
* api_key principal and the store-level route is dashboard-only. Use the dashboard.
|
|
9990
10447
|
*/
|
|
9991
|
-
async resendTeamInvitation(
|
|
9992
|
-
return this.
|
|
9993
|
-
"POST",
|
|
9994
|
-
`/api/v1/team/invitations/${encodePathSegment(invitationId)}/resend`
|
|
9995
|
-
);
|
|
10448
|
+
async resendTeamInvitation(_invitationId) {
|
|
10449
|
+
return this.teamIsDashboardOnly("resendTeamInvitation");
|
|
9996
10450
|
}
|
|
9997
10451
|
/**
|
|
9998
|
-
* @deprecated Retiring, but
|
|
9999
|
-
*
|
|
10452
|
+
* @deprecated Retiring, but this ALWAYS throws: `/v1/team/*` rejects the
|
|
10453
|
+
* api_key principal and the store-level route is dashboard-only. Use the dashboard.
|
|
10000
10454
|
*/
|
|
10001
|
-
async revokeTeamInvitation(
|
|
10002
|
-
|
|
10003
|
-
"DELETE",
|
|
10004
|
-
`/api/v1/team/invitations/${encodePathSegment(invitationId)}`
|
|
10005
|
-
);
|
|
10455
|
+
async revokeTeamInvitation(_invitationId) {
|
|
10456
|
+
return this.teamIsDashboardOnly("revokeTeamInvitation");
|
|
10006
10457
|
}
|
|
10007
10458
|
/**
|
|
10008
|
-
* @deprecated Retiring, but
|
|
10009
|
-
*
|
|
10459
|
+
* @deprecated Retiring, but this ALWAYS throws: `/v1/team/*` rejects the
|
|
10460
|
+
* api_key principal and the store-level route is dashboard-only. Use the dashboard.
|
|
10010
10461
|
*/
|
|
10011
|
-
async updateTeamMemberRole(
|
|
10012
|
-
return this.
|
|
10013
|
-
"PATCH",
|
|
10014
|
-
`/api/v1/team/members/${encodePathSegment(memberId)}/role`,
|
|
10015
|
-
data
|
|
10016
|
-
);
|
|
10462
|
+
async updateTeamMemberRole(_memberId, _data) {
|
|
10463
|
+
return this.teamIsDashboardOnly("updateTeamMemberRole");
|
|
10017
10464
|
}
|
|
10018
10465
|
/**
|
|
10019
|
-
* @deprecated Retiring, but
|
|
10020
|
-
*
|
|
10466
|
+
* @deprecated Retiring, but this ALWAYS throws: `/v1/team/*` rejects the
|
|
10467
|
+
* api_key principal and the store-level route is dashboard-only. Use the dashboard.
|
|
10021
10468
|
*/
|
|
10022
|
-
async removeTeamMember(
|
|
10023
|
-
|
|
10469
|
+
async removeTeamMember(_memberId) {
|
|
10470
|
+
return this.teamIsDashboardOnly("removeTeamMember");
|
|
10024
10471
|
}
|
|
10025
10472
|
// -------------------- Store Team Management (Admin) --------------------
|
|
10026
10473
|
// Store-level team management. Each store has its own team with roles and permissions.
|