@curless/shopify-storefront 0.1.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.
Files changed (129) hide show
  1. package/dist/__tests__/card-contract.test.d.ts +2 -0
  2. package/dist/__tests__/card-contract.test.d.ts.map +1 -0
  3. package/dist/__tests__/card-contract.test.js +22 -0
  4. package/dist/__tests__/card-contract.test.js.map +1 -0
  5. package/dist/__tests__/catalog-registry.test.d.ts +2 -0
  6. package/dist/__tests__/catalog-registry.test.d.ts.map +1 -0
  7. package/dist/__tests__/catalog-registry.test.js +109 -0
  8. package/dist/__tests__/catalog-registry.test.js.map +1 -0
  9. package/dist/__tests__/challenge.test.d.ts +2 -0
  10. package/dist/__tests__/challenge.test.d.ts.map +1 -0
  11. package/dist/__tests__/challenge.test.js +46 -0
  12. package/dist/__tests__/challenge.test.js.map +1 -0
  13. package/dist/__tests__/config.test.d.ts +2 -0
  14. package/dist/__tests__/config.test.d.ts.map +1 -0
  15. package/dist/__tests__/config.test.js +123 -0
  16. package/dist/__tests__/config.test.js.map +1 -0
  17. package/dist/__tests__/connection-state.test.d.ts +2 -0
  18. package/dist/__tests__/connection-state.test.d.ts.map +1 -0
  19. package/dist/__tests__/connection-state.test.js +40 -0
  20. package/dist/__tests__/connection-state.test.js.map +1 -0
  21. package/dist/__tests__/multi-token-store.test.d.ts +2 -0
  22. package/dist/__tests__/multi-token-store.test.d.ts.map +1 -0
  23. package/dist/__tests__/multi-token-store.test.js +89 -0
  24. package/dist/__tests__/multi-token-store.test.js.map +1 -0
  25. package/dist/__tests__/oauth.test.d.ts +2 -0
  26. package/dist/__tests__/oauth.test.d.ts.map +1 -0
  27. package/dist/__tests__/oauth.test.js +112 -0
  28. package/dist/__tests__/oauth.test.js.map +1 -0
  29. package/dist/__tests__/output-schema.test.d.ts +2 -0
  30. package/dist/__tests__/output-schema.test.d.ts.map +1 -0
  31. package/dist/__tests__/output-schema.test.js +112 -0
  32. package/dist/__tests__/output-schema.test.js.map +1 -0
  33. package/dist/__tests__/ping.test.d.ts +2 -0
  34. package/dist/__tests__/ping.test.d.ts.map +1 -0
  35. package/dist/__tests__/ping.test.js +22 -0
  36. package/dist/__tests__/ping.test.js.map +1 -0
  37. package/dist/__tests__/rate-limit.test.d.ts +2 -0
  38. package/dist/__tests__/rate-limit.test.d.ts.map +1 -0
  39. package/dist/__tests__/rate-limit.test.js +46 -0
  40. package/dist/__tests__/rate-limit.test.js.map +1 -0
  41. package/dist/__tests__/stock.test.d.ts +2 -0
  42. package/dist/__tests__/stock.test.d.ts.map +1 -0
  43. package/dist/__tests__/stock.test.js +109 -0
  44. package/dist/__tests__/stock.test.js.map +1 -0
  45. package/dist/__tests__/tenant.test.d.ts +2 -0
  46. package/dist/__tests__/tenant.test.d.ts.map +1 -0
  47. package/dist/__tests__/tenant.test.js +105 -0
  48. package/dist/__tests__/tenant.test.js.map +1 -0
  49. package/dist/__tests__/tools.test.d.ts +2 -0
  50. package/dist/__tests__/tools.test.d.ts.map +1 -0
  51. package/dist/__tests__/tools.test.js +543 -0
  52. package/dist/__tests__/tools.test.js.map +1 -0
  53. package/dist/__tests__/upstream-cap.test.d.ts +2 -0
  54. package/dist/__tests__/upstream-cap.test.d.ts.map +1 -0
  55. package/dist/__tests__/upstream-cap.test.js +78 -0
  56. package/dist/__tests__/upstream-cap.test.js.map +1 -0
  57. package/dist/__tests__/webhooks.test.d.ts +2 -0
  58. package/dist/__tests__/webhooks.test.d.ts.map +1 -0
  59. package/dist/__tests__/webhooks.test.js +88 -0
  60. package/dist/__tests__/webhooks.test.js.map +1 -0
  61. package/dist/card.generated.d.ts +2 -0
  62. package/dist/card.generated.d.ts.map +1 -0
  63. package/dist/card.generated.js +6 -0
  64. package/dist/card.generated.js.map +1 -0
  65. package/dist/catalog-registry.d.ts +44 -0
  66. package/dist/catalog-registry.d.ts.map +1 -0
  67. package/dist/catalog-registry.js +105 -0
  68. package/dist/catalog-registry.js.map +1 -0
  69. package/dist/catalog.d.ts +51 -0
  70. package/dist/catalog.d.ts.map +1 -0
  71. package/dist/catalog.js +189 -0
  72. package/dist/catalog.js.map +1 -0
  73. package/dist/challenge.d.ts +11 -0
  74. package/dist/challenge.d.ts.map +1 -0
  75. package/dist/challenge.js +36 -0
  76. package/dist/challenge.js.map +1 -0
  77. package/dist/config.d.ts +141 -0
  78. package/dist/config.d.ts.map +1 -0
  79. package/dist/config.js +65 -0
  80. package/dist/config.js.map +1 -0
  81. package/dist/connection.d.ts +10 -0
  82. package/dist/connection.d.ts.map +1 -0
  83. package/dist/connection.js +21 -0
  84. package/dist/connection.js.map +1 -0
  85. package/dist/index.d.ts +3 -0
  86. package/dist/index.d.ts.map +1 -0
  87. package/dist/index.js +974 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/legal.d.ts +52 -0
  90. package/dist/legal.d.ts.map +1 -0
  91. package/dist/legal.js +139 -0
  92. package/dist/legal.js.map +1 -0
  93. package/dist/load-config.d.ts +11 -0
  94. package/dist/load-config.d.ts.map +1 -0
  95. package/dist/load-config.js +23 -0
  96. package/dist/load-config.js.map +1 -0
  97. package/dist/multi-token-store.d.ts +18 -0
  98. package/dist/multi-token-store.d.ts.map +1 -0
  99. package/dist/multi-token-store.js +95 -0
  100. package/dist/multi-token-store.js.map +1 -0
  101. package/dist/oauth.d.ts +20 -0
  102. package/dist/oauth.d.ts.map +1 -0
  103. package/dist/oauth.js +123 -0
  104. package/dist/oauth.js.map +1 -0
  105. package/dist/rate-limit.d.ts +15 -0
  106. package/dist/rate-limit.d.ts.map +1 -0
  107. package/dist/rate-limit.js +56 -0
  108. package/dist/rate-limit.js.map +1 -0
  109. package/dist/stock.d.ts +39 -0
  110. package/dist/stock.d.ts.map +1 -0
  111. package/dist/stock.js +77 -0
  112. package/dist/stock.js.map +1 -0
  113. package/dist/tenant.d.ts +69 -0
  114. package/dist/tenant.d.ts.map +1 -0
  115. package/dist/tenant.js +68 -0
  116. package/dist/tenant.js.map +1 -0
  117. package/dist/token-store.d.ts +16 -0
  118. package/dist/token-store.d.ts.map +1 -0
  119. package/dist/token-store.js +47 -0
  120. package/dist/token-store.js.map +1 -0
  121. package/dist/tools.d.ts +201 -0
  122. package/dist/tools.d.ts.map +1 -0
  123. package/dist/tools.js +779 -0
  124. package/dist/tools.js.map +1 -0
  125. package/dist/webhooks.d.ts +30 -0
  126. package/dist/webhooks.d.ts.map +1 -0
  127. package/dist/webhooks.js +69 -0
  128. package/dist/webhooks.js.map +1 -0
  129. package/package.json +53 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"card.generated.js","sourceRoot":"","sources":["../src/card.generated.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,2EAA2E;AAC3E,gFAAgF;AAChF,4DAA4D;AAC5D,MAAM,CAAC,MAAM,eAAe,GAAW,MAAM,CAAC,IAAI,CAChD,0tsdAA0tsd,EAC1tsd,QAAQ,CACT,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC"}
@@ -0,0 +1,44 @@
1
+ import type { ShopifyConfig } from '@curless/shopify-adapter';
2
+ import { Catalog } from './catalog.js';
3
+ export type RegistryOptions = {
4
+ /** How often each shop's catalogue refreshes. */
5
+ refreshMs: number;
6
+ /** Drop a shop's catalogue after this long without a request. */
7
+ idleMs?: number;
8
+ /** Hard ceiling on resident catalogues; the least recently used goes first. */
9
+ maxShops?: number;
10
+ /** Injected so tests do not depend on the clock. */
11
+ now?: () => number;
12
+ };
13
+ export declare class CatalogRegistry {
14
+ private readonly resolveConfig;
15
+ private readonly opts;
16
+ private readonly entries;
17
+ private readonly idleMs;
18
+ private readonly maxShops;
19
+ private readonly now;
20
+ constructor(resolveConfig: (shop: string) => Promise<ShopifyConfig | null>, opts: RegistryOptions);
21
+ /** Resident shops — for a status page, and for the tests below. */
22
+ get size(): number;
23
+ /**
24
+ * The catalogue for one shop, created on first use.
25
+ *
26
+ * Sweeping on access rather than on a timer of its own: the work is
27
+ * proportional to traffic, and a registry nobody is using does not need to
28
+ * wake up to discover it is idle.
29
+ */
30
+ for(shop: string): Catalog;
31
+ /**
32
+ * Stop serving a shop — uninstall, and shop/redact.
33
+ *
34
+ * The timer is stopped BEFORE the entry is dropped. Dropping first would
35
+ * leave a Catalog nothing references but whose interval still fires, which is
36
+ * precisely the request to a revoked token that must not happen.
37
+ */
38
+ forget(shop: string): void;
39
+ /** Stop everything — for a clean shutdown. */
40
+ stopAll(): void;
41
+ private sweep;
42
+ private evictOverflow;
43
+ }
44
+ //# sourceMappingURL=catalog-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog-registry.d.ts","sourceRoot":"","sources":["../src/catalog-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAiB9D,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAIvC,MAAM,MAAM,eAAe,GAAG;IAC5B,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB,CAAC;AAEF,qBAAa,eAAe;IAOxB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,IAAI;IAPvB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;gBAGhB,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,EAC9D,IAAI,EAAE,eAAe;IAOxC,mEAAmE;IACnE,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED;;;;;;OAMG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAkB1B;;;;;;OAMG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAQ1B,8CAA8C;IAC9C,OAAO,IAAI,IAAI;IAKf,OAAO,CAAC,KAAK;IAUb,OAAO,CAAC,aAAa;CAgBtB"}
@@ -0,0 +1,105 @@
1
+ // One catalogue per shop, for the listed public app.
2
+ //
3
+ // A branded deployment has exactly one, built at boot and refreshed for the
4
+ // life of the process. A listed app cannot: it serves whichever shops have
5
+ // installed it, it learns of a new one mid-flight, and it must stop serving one
6
+ // the moment that merchant uninstalls.
7
+ //
8
+ // Two things go wrong if this is just a Map.
9
+ //
10
+ // • A shop that uninstalls keeps its refresh timer, so we go on calling THAT
11
+ // merchant's Shopify with a token they revoked — forever, for someone who
12
+ // is no longer a customer. Eviction has to stop the timer, not just drop
13
+ // the reference.
14
+ // • Every shop holds a full product snapshot. A few hundred merchants is a
15
+ // few hundred catalogues resident at once, and nothing ever released the
16
+ // ones nobody has asked about in a week.
17
+ import { Catalog } from './catalog.js';
18
+ export class CatalogRegistry {
19
+ resolveConfig;
20
+ opts;
21
+ entries = new Map();
22
+ idleMs;
23
+ maxShops;
24
+ now;
25
+ constructor(resolveConfig, opts) {
26
+ this.resolveConfig = resolveConfig;
27
+ this.opts = opts;
28
+ this.idleMs = opts.idleMs ?? 6 * 60 * 60 * 1000;
29
+ this.maxShops = opts.maxShops ?? 500;
30
+ this.now = opts.now ?? (() => Date.now());
31
+ }
32
+ /** Resident shops — for a status page, and for the tests below. */
33
+ get size() {
34
+ return this.entries.size;
35
+ }
36
+ /**
37
+ * The catalogue for one shop, created on first use.
38
+ *
39
+ * Sweeping on access rather than on a timer of its own: the work is
40
+ * proportional to traffic, and a registry nobody is using does not need to
41
+ * wake up to discover it is idle.
42
+ */
43
+ for(shop) {
44
+ const key = shop.toLowerCase();
45
+ this.sweep();
46
+ const hit = this.entries.get(key);
47
+ if (hit) {
48
+ hit.lastUsed = this.now();
49
+ return hit.catalog;
50
+ }
51
+ const catalog = new Catalog(() => this.resolveConfig(key), (m) => console.log(`[catalog ${key}] ${m}`));
52
+ catalog.start(this.opts.refreshMs);
53
+ this.entries.set(key, { catalog, lastUsed: this.now() });
54
+ this.evictOverflow();
55
+ return catalog;
56
+ }
57
+ /**
58
+ * Stop serving a shop — uninstall, and shop/redact.
59
+ *
60
+ * The timer is stopped BEFORE the entry is dropped. Dropping first would
61
+ * leave a Catalog nothing references but whose interval still fires, which is
62
+ * precisely the request to a revoked token that must not happen.
63
+ */
64
+ forget(shop) {
65
+ const key = shop.toLowerCase();
66
+ const hit = this.entries.get(key);
67
+ if (!hit)
68
+ return;
69
+ hit.catalog.stop();
70
+ this.entries.delete(key);
71
+ }
72
+ /** Stop everything — for a clean shutdown. */
73
+ stopAll() {
74
+ for (const [, e] of this.entries)
75
+ e.catalog.stop();
76
+ this.entries.clear();
77
+ }
78
+ sweep() {
79
+ const cutoff = this.now() - this.idleMs;
80
+ for (const [key, e] of this.entries) {
81
+ if (e.lastUsed < cutoff) {
82
+ e.catalog.stop();
83
+ this.entries.delete(key);
84
+ }
85
+ }
86
+ }
87
+ evictOverflow() {
88
+ while (this.entries.size > this.maxShops) {
89
+ let oldestKey = null;
90
+ let oldest = Number.POSITIVE_INFINITY;
91
+ for (const [key, e] of this.entries) {
92
+ if (e.lastUsed < oldest) {
93
+ oldest = e.lastUsed;
94
+ oldestKey = key;
95
+ }
96
+ }
97
+ if (oldestKey === null)
98
+ return;
99
+ const victim = this.entries.get(oldestKey);
100
+ victim?.catalog.stop();
101
+ this.entries.delete(oldestKey);
102
+ }
103
+ }
104
+ }
105
+ //# sourceMappingURL=catalog-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog-registry.js","sourceRoot":"","sources":["../src/catalog-registry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ import { type ProductVariant, type ShopifyConfig, type ShopifyDiscount, type ShopifyProduct } from '@curless/shopify-adapter';
2
+ export type CatalogSnapshot = {
3
+ products: ShopifyProduct[];
4
+ /** When this snapshot was successfully loaded. null = never loaded. */
5
+ loadedAt: Date | null;
6
+ /** Set when the LAST attempt failed — the snapshot is then stale, not absent. */
7
+ lastError?: string;
8
+ /**
9
+ * The shop's live automatic discounts — "Buy 2 get 5% OFF", "Gift over $66".
10
+ * They live nowhere near a product, so a catalogue that only reads products
11
+ * quotes a price the buyer beats at checkout and never mentions the offer that
12
+ * would have made them buy two.
13
+ */
14
+ discounts: ShopifyDiscount[];
15
+ /**
16
+ * Why there are none, when the reason is not "the shop is running none".
17
+ * `read_discounts` is granted at INSTALL, so until the merchant reinstalls the
18
+ * query is refused — and "we cannot see them" has to be distinguishable from
19
+ * "there are none", or nobody knows to ask them to reinstall.
20
+ */
21
+ discountsBlocked?: string;
22
+ };
23
+ export declare class Catalog {
24
+ private readonly resolveConfig;
25
+ private readonly log;
26
+ private snapshot;
27
+ private inFlight;
28
+ private variants;
29
+ private upstream;
30
+ constructor(resolveConfig: () => Promise<ShopifyConfig | null>, log?: (msg: string) => void);
31
+ get current(): CatalogSnapshot;
32
+ /** Products, newest snapshot. Empty only before the first successful load. */
33
+ list(): ShopifyProduct[];
34
+ byHandleOrTitle(q: string): ShopifyProduct | undefined;
35
+ refresh(): Promise<void>;
36
+ /** Every variant of one product, paginated to completion, cached briefly. */
37
+ variantsFor(handle: string, now?: number): Promise<ProductVariant[]>;
38
+ private timer;
39
+ /** Refresh now, then on an interval. Never throws — boot must not depend on Shopify. */
40
+ start(intervalMs: number): void;
41
+ /**
42
+ * Stop refreshing.
43
+ *
44
+ * A single-shop deployment never needs this — the process owns one catalogue
45
+ * for its whole life. A multi-tenant one does: when a merchant uninstalls,
46
+ * an unstoppable timer keeps calling THEIR Shopify with a token they revoked,
47
+ * forever, for a shop that is no longer a customer.
48
+ */
49
+ stop(): void;
50
+ }
51
+ //# sourceMappingURL=catalog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AASA,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,cAAc,EAKpB,MAAM,0BAA0B,CAAC;AAGlC,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACtB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AA2BF,qBAAa,OAAO;IAchB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAdtB,OAAO,CAAC,QAAQ,CAAoE;IACpF,OAAO,CAAC,QAAQ,CAA8B;IAG9C,OAAO,CAAC,QAAQ,CAA6D;IAE7E,OAAO,CAAC,QAAQ,CAAoD;gBAOjD,aAAa,EAAE,MAAM,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,EAClD,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAA2C;IAGpF,IAAI,OAAO,IAAI,eAAe,CAE7B;IAED,8EAA8E;IAC9E,IAAI,IAAI,cAAc,EAAE;IAIxB,eAAe,CAAC,CAAC,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAYhD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAmE9B,6EAA6E;IACvE,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAyBtF,OAAO,CAAC,KAAK,CAA+C;IAE5D,wFAAwF;IACxF,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAQ/B;;;;;;;OAOG;IACH,IAAI,IAAI,IAAI;CAIb"}
@@ -0,0 +1,189 @@
1
+ // The Moody catalog, held in memory and refreshed in the background.
2
+ //
3
+ // Two properties matter more than freshness here:
4
+ //
5
+ // • A tool call must never wait on Shopify. The buyer is mid-conversation;
6
+ // a 2-second catalog fetch inside "show me the vivid ones" is felt.
7
+ // • A failed refresh must never empty the shelf. An empty catalog is not a
8
+ // degraded store, it is a closed one — so a refresh that fails keeps the
9
+ // last good snapshot and says so.
10
+ import { activeDiscounts, fetchProductVariants, fetchShopifyDiscounts, fetchShopifyProducts, } from '@curless/shopify-adapter';
11
+ import { RateLimiter } from './rate-limit.js';
12
+ // A product's variants are NOT in the snapshot: a pair of lenses is Left ×
13
+ // Right, 676 rows, and 382 of those is a quarter-million objects held to answer
14
+ // "what do you sell". They are fetched for the one product a buyer is looking
15
+ // at, and remembered briefly — looking at a lens and then buying it is two calls
16
+ // for the same rows.
17
+ const VARIANT_TTL_MS = 5 * 60_000;
18
+ const VARIANT_CACHE_MAX = 50;
19
+ // A GLOBAL ceiling on cache misses, in addition to whatever per-IP limit sits in
20
+ // front of the endpoint.
21
+ //
22
+ // Per-IP is the wrong unit here, because the resource being spent is not ours —
23
+ // it is the MERCHANT's Shopify API quota. One uncached `variantsFor` costs up to
24
+ // three Admin API calls (676 variants at 250 a page), the cache holds 50 of 382
25
+ // products, and cycling handles defeats it by design. At 60 calls a minute per
26
+ // address that is 180 Admin calls a minute from ONE caller, and nothing stops
27
+ // the second caller. Exhausting that quota does not degrade us; it degrades the
28
+ // merchant's own storefront and their other apps, which is a worse outcome than
29
+ // this connector being slow.
30
+ //
31
+ // 90 a minute leaves ample room for real shoppers — a shopper opens a handful of
32
+ // lenses, and the second look at one is free — while capping the whole service's
33
+ // draw on the merchant at well under Shopify's budget.
34
+ const VARIANT_FETCHES_PER_MIN = 90;
35
+ export class Catalog {
36
+ resolveConfig;
37
+ log;
38
+ snapshot = { products: [], loadedAt: null, discounts: [] };
39
+ inFlight = null;
40
+ // Per INSTANCE, not per module: a module-level cache outlives whatever created
41
+ // it, which in tests means one case seeing another's stock levels.
42
+ variants = new Map();
43
+ // One bucket for the whole service, not per caller — see VARIANT_FETCHES_PER_MIN.
44
+ upstream = new RateLimiter(VARIANT_FETCHES_PER_MIN, 60_000);
45
+ // The config is RESOLVED per refresh, not captured at construction: the token
46
+ // arrives when the merchant installs the app, which may be long after this
47
+ // process started. Null means "not connected yet" — a state to report, not to
48
+ // crash on.
49
+ constructor(resolveConfig, log = (m) => console.log(`[catalog] ${m}`)) {
50
+ this.resolveConfig = resolveConfig;
51
+ this.log = log;
52
+ }
53
+ get current() {
54
+ return this.snapshot;
55
+ }
56
+ /** Products, newest snapshot. Empty only before the first successful load. */
57
+ list() {
58
+ return this.snapshot.products;
59
+ }
60
+ byHandleOrTitle(q) {
61
+ const norm = (s) => s.trim().toLowerCase();
62
+ const n = norm(q);
63
+ return (this.snapshot.products.find((p) => norm(p.handle) === n || norm(p.title) === n) ??
64
+ this.snapshot.products.find((p) => norm(p.title).includes(n) || norm(p.handle).includes(n)));
65
+ }
66
+ // Concurrent callers share one fetch: on boot every tool call would otherwise
67
+ // start its own, and Shopify would rate-limit us for asking the same question
68
+ // ten times.
69
+ async refresh() {
70
+ if (this.inFlight)
71
+ return this.inFlight;
72
+ this.inFlight = (async () => {
73
+ try {
74
+ const config = await this.resolveConfig();
75
+ if (!config) {
76
+ this.snapshot = {
77
+ ...this.snapshot,
78
+ lastError: 'not connected — the merchant has not installed the app yet',
79
+ };
80
+ return;
81
+ }
82
+ const products = await fetchShopifyProducts(config, fetch, (m) => this.log(m));
83
+ if (products.length === 0) {
84
+ // Shopify answered, with nothing. Could be a real empty store, could
85
+ // be a scope we lost — either way, do not replace a shelf that has
86
+ // stock on it with one that has none.
87
+ this.snapshot = {
88
+ ...this.snapshot,
89
+ lastError: 'shopify returned no products',
90
+ };
91
+ this.log('refresh returned 0 products — keeping the previous snapshot');
92
+ return;
93
+ }
94
+ // Discounts are read alongside, and are ALLOWED to fail: they need a
95
+ // scope the merchant grants at install time, and a storefront that
96
+ // stopped selling because it could not read a promotion would be a
97
+ // spectacular own goal.
98
+ const promo = await fetchShopifyDiscounts(config).catch(() => ({ ok: false, reason: 'failed', detail: 'threw' }));
99
+ if (promo.ok) {
100
+ this.snapshot = {
101
+ products,
102
+ loadedAt: new Date(),
103
+ discounts: activeDiscounts(promo.discounts),
104
+ };
105
+ if (promo.discounts.length)
106
+ this.log(`${promo.discounts.length} automatic discount(s)`);
107
+ }
108
+ else {
109
+ this.snapshot = {
110
+ products,
111
+ loadedAt: new Date(),
112
+ discounts: [],
113
+ discountsBlocked: promo.reason === 'unauthorized'
114
+ ? 'read_discounts not granted — the merchant must reinstall the app for promotions to show'
115
+ : promo.detail,
116
+ };
117
+ this.log(`discounts unavailable: ${promo.reason}`);
118
+ }
119
+ // A fresh catalogue can mean changed prices or stock; do not answer from
120
+ // variants read against the previous one.
121
+ this.variants.clear();
122
+ this.log(`loaded ${products.length} products`);
123
+ }
124
+ catch (err) {
125
+ this.snapshot = {
126
+ ...this.snapshot,
127
+ lastError: err instanceof Error ? err.message : String(err),
128
+ };
129
+ this.log(`refresh FAILED: ${this.snapshot.lastError}`);
130
+ }
131
+ finally {
132
+ this.inFlight = null;
133
+ }
134
+ })();
135
+ return this.inFlight;
136
+ }
137
+ /** Every variant of one product, paginated to completion, cached briefly. */
138
+ async variantsFor(handle, now = Date.now()) {
139
+ const hit = this.variants.get(handle);
140
+ if (hit && now - hit.at < VARIANT_TTL_MS)
141
+ return hit.rows;
142
+ // Over the global ceiling: serve a stale copy if we have one, and otherwise
143
+ // say nothing rather than spend more of the merchant's quota. An empty list
144
+ // reads downstream as "we cannot resolve that combination", which asks the
145
+ // buyer to choose again — not ideal, and much better than taking the
146
+ // merchant's own store down with us.
147
+ if (!this.upstream.take('variants', now)) {
148
+ this.log('variant fetch ceiling reached — serving stale/empty');
149
+ return hit?.rows ?? [];
150
+ }
151
+ const config = await this.resolveConfig();
152
+ if (!config)
153
+ return [];
154
+ const rows = await fetchProductVariants(config, handle, fetch, (m) => this.log(m));
155
+ this.variants.set(handle, { at: now, rows });
156
+ if (this.variants.size > VARIANT_CACHE_MAX) {
157
+ const oldest = [...this.variants.entries()]
158
+ .sort((a, b) => a[1].at - b[1].at)
159
+ .slice(0, Math.floor(VARIANT_CACHE_MAX / 2));
160
+ for (const [k] of oldest)
161
+ this.variants.delete(k);
162
+ }
163
+ return rows;
164
+ }
165
+ timer = null;
166
+ /** Refresh now, then on an interval. Never throws — boot must not depend on Shopify. */
167
+ start(intervalMs) {
168
+ void this.refresh();
169
+ this.stop();
170
+ this.timer = setInterval(() => void this.refresh(), intervalMs);
171
+ // Do not hold the process open for a cache refresh.
172
+ if (typeof this.timer.unref === 'function')
173
+ this.timer.unref();
174
+ }
175
+ /**
176
+ * Stop refreshing.
177
+ *
178
+ * A single-shop deployment never needs this — the process owns one catalogue
179
+ * for its whole life. A multi-tenant one does: when a merchant uninstalls,
180
+ * an unstoppable timer keeps calling THEIR Shopify with a token they revoked,
181
+ * forever, for a shop that is no longer a customer.
182
+ */
183
+ stop() {
184
+ if (this.timer)
185
+ clearInterval(this.timer);
186
+ this.timer = null;
187
+ }
188
+ }
189
+ //# sourceMappingURL=catalog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog.js","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,EAAE;AACF,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,wEAAwE;AACxE,6EAA6E;AAC7E,6EAA6E;AAC7E,sCAAsC;AACtC,OAAO,EAKL,eAAe,EACf,oBAAoB,EACpB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAwB9C,2EAA2E;AAC3E,gFAAgF;AAChF,8EAA8E;AAC9E,iFAAiF;AACjF,qBAAqB;AACrB,MAAM,cAAc,GAAG,CAAC,GAAG,MAAM,CAAC;AAClC,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAE7B,iFAAiF;AACjF,yBAAyB;AACzB,EAAE;AACF,gFAAgF;AAChF,iFAAiF;AACjF,gFAAgF;AAChF,+EAA+E;AAC/E,8EAA8E;AAC9E,gFAAgF;AAChF,gFAAgF;AAChF,6BAA6B;AAC7B,EAAE;AACF,iFAAiF;AACjF,iFAAiF;AACjF,uDAAuD;AACvD,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAEnC,MAAM,OAAO,OAAO;IAcC;IACA;IAdX,QAAQ,GAAoB,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC;IAC5E,QAAQ,GAAyB,IAAI,CAAC;IAC9C,+EAA+E;IAC/E,mEAAmE;IAC3D,QAAQ,GAAG,IAAI,GAAG,EAAkD,CAAC;IAC7E,kFAAkF;IAC1E,QAAQ,GAAG,IAAI,WAAW,CAAC,uBAAuB,EAAE,MAAM,CAAC,CAAC;IAEpE,8EAA8E;IAC9E,2EAA2E;IAC3E,8EAA8E;IAC9E,YAAY;IACZ,YACmB,aAAkD,EAClD,MAA6B,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,EAAE,CAAC;QADjE,kBAAa,GAAb,aAAa,CAAqC;QAClD,QAAG,GAAH,GAAG,CAA8D;IACjF,CAAC;IAEJ,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,8EAA8E;IAC9E,IAAI;QACF,OAAO,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAChC,CAAC;IAED,eAAe,CAAC,CAAS;QACvB,MAAM,IAAI,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;QACnD,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,OAAO,CACL,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAC/E,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAC5F,CAAC;IACJ,CAAC;IAED,8EAA8E;IAC9E,8EAA8E;IAC9E,aAAa;IACb,KAAK,CAAC,OAAO;QACX,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAC,QAAQ,CAAC;QACxC,IAAI,CAAC,QAAQ,GAAG,CAAC,KAAK,IAAI,EAAE;YAC1B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC;gBAC1C,IAAI,CAAC,MAAM,EAAE,CAAC;oBACZ,IAAI,CAAC,QAAQ,GAAG;wBACd,GAAG,IAAI,CAAC,QAAQ;wBAChB,SAAS,EAAE,4DAA4D;qBACxE,CAAC;oBACF,OAAO;gBACT,CAAC;gBACD,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,CAAS,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;gBACvF,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBAC1B,qEAAqE;oBACrE,mEAAmE;oBACnE,sCAAsC;oBACtC,IAAI,CAAC,QAAQ,GAAG;wBACd,GAAG,IAAI,CAAC,QAAQ;wBAChB,SAAS,EAAE,8BAA8B;qBAC1C,CAAC;oBACF,IAAI,CAAC,GAAG,CAAC,6DAA6D,CAAC,CAAC;oBACxE,OAAO;gBACT,CAAC;gBACD,qEAAqE;gBACrE,mEAAmE;gBACnE,mEAAmE;gBACnE,wBAAwB;gBACxB,MAAM,KAAK,GAAG,MAAM,qBAAqB,CAAC,MAAM,CAAC,CAAC,KAAK,CACrD,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,CAAU,CAClE,CAAC;gBACF,IAAI,KAAK,CAAC,EAAE,EAAE,CAAC;oBACb,IAAI,CAAC,QAAQ,GAAG;wBACd,QAAQ;wBACR,QAAQ,EAAE,IAAI,IAAI,EAAE;wBACpB,SAAS,EAAE,eAAe,CAAC,KAAK,CAAC,SAAS,CAAC;qBAC5C,CAAC;oBACF,IAAI,KAAK,CAAC,SAAS,CAAC,MAAM;wBAAE,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,wBAAwB,CAAC,CAAC;gBAC1F,CAAC;qBAAM,CAAC;oBACN,IAAI,CAAC,QAAQ,GAAG;wBACd,QAAQ;wBACR,QAAQ,EAAE,IAAI,IAAI,EAAE;wBACpB,SAAS,EAAE,EAAE;wBACb,gBAAgB,EACd,KAAK,CAAC,MAAM,KAAK,cAAc;4BAC7B,CAAC,CAAC,yFAAyF;4BAC3F,CAAC,CAAC,KAAK,CAAC,MAAM;qBACnB,CAAC;oBACF,IAAI,CAAC,GAAG,CAAC,0BAA0B,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;gBACrD,CAAC;gBACD,yEAAyE;gBACzE,0CAA0C;gBAC1C,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC;gBACtB,IAAI,CAAC,GAAG,CAAC,UAAU,QAAQ,CAAC,MAAM,WAAW,CAAC,CAAC;YACjD,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,CAAC,QAAQ,GAAG;oBACd,GAAG,IAAI,CAAC,QAAQ;oBAChB,SAAS,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;iBAC5D,CAAC;gBACF,IAAI,CAAC,GAAG,CAAC,mBAAmB,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC,CAAC;YACzD,CAAC;oBAAS,CAAC;gBACT,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;YACvB,CAAC;QACH,CAAC,CAAC,EAAE,CAAC;QACL,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,6EAA6E;IAC7E,KAAK,CAAC,WAAW,CAAC,MAAc,EAAE,MAAc,IAAI,CAAC,GAAG,EAAE;QACxD,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACtC,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,CAAC,EAAE,GAAG,cAAc;YAAE,OAAO,GAAG,CAAC,IAAI,CAAC;QAC1D,4EAA4E;QAC5E,4EAA4E;QAC5E,2EAA2E;QAC3E,qEAAqE;QACrE,qCAAqC;QACrC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,EAAE,GAAG,CAAC,EAAE,CAAC;YACzC,IAAI,CAAC,GAAG,CAAC,qDAAqD,CAAC,CAAC;YAChE,OAAO,GAAG,EAAE,IAAI,IAAI,EAAE,CAAC;QACzB,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC;QAC1C,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,CAAC;QACvB,MAAM,IAAI,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAS,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3F,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC;QAC7C,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,GAAG,iBAAiB,EAAE,CAAC;YAC3C,MAAM,MAAM,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;iBACxC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;iBACjC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,iBAAiB,GAAG,CAAC,CAAC,CAAC,CAAC;YAC/C,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,MAAM;gBAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACpD,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAEO,KAAK,GAA0C,IAAI,CAAC;IAE5D,wFAAwF;IACxF,KAAK,CAAC,UAAkB;QACtB,KAAK,IAAI,CAAC,OAAO,EAAE,CAAC;QACpB,IAAI,CAAC,IAAI,EAAE,CAAC;QACZ,IAAI,CAAC,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC,OAAO,EAAE,EAAE,UAAU,CAAC,CAAC;QAChE,oDAAoD;QACpD,IAAI,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,KAAK,UAAU;YAAE,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;IACjE,CAAC;IAED;;;;;;;OAOG;IACH,IAAI;QACF,IAAI,IAAI,CAAC,KAAK;YAAE,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;IACpB,CAAC;CACF"}
@@ -0,0 +1,11 @@
1
+ /** `host=token,host=token` → a lookup. Malformed pairs are skipped, not fatal. */
2
+ export declare const parseChallengeTokens: (raw: string | undefined) => Map<string, string>;
3
+ /**
4
+ * The token to serve on `host`.
5
+ *
6
+ * A per-host entry wins. Falling back to the single-value variable for an
7
+ * unlisted host is deliberate: every existing single-host deployment keeps
8
+ * working with no config change.
9
+ */
10
+ export declare const challengeFor: (host: string | undefined, perHost: string | undefined, single: string | undefined) => string | undefined;
11
+ //# sourceMappingURL=challenge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"challenge.d.ts","sourceRoot":"","sources":["../src/challenge.ts"],"names":[],"mappings":"AAYA,kFAAkF;AAClF,eAAO,MAAM,oBAAoB,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAYhF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GACvB,MAAM,MAAM,GAAG,SAAS,EACxB,SAAS,MAAM,GAAG,SAAS,EAC3B,QAAQ,MAAM,GAAG,SAAS,KACzB,MAAM,GAAG,SAC0E,CAAC"}
@@ -0,0 +1,36 @@
1
+ // ChatGPT Apps domain verification, per HOSTNAME.
2
+ //
3
+ // The token OpenAI issues is bound to an ORIGIN, and this service answers on
4
+ // more than one: the merchant's new domain and the one the directories still
5
+ // have registered. A single token served on every host means that verifying the
6
+ // second hostname silently breaks the first — it starts returning the other
7
+ // one's token, and the app that was already verified fails its next re-check.
8
+ // "Verification just dropped" points nowhere near the cause.
9
+ //
10
+ // Both hosts have to stay verified while the listings are moved over one at a
11
+ // time, which is the whole shape of a domain migration.
12
+ /** `host=token,host=token` → a lookup. Malformed pairs are skipped, not fatal. */
13
+ export const parseChallengeTokens = (raw) => {
14
+ const out = new Map();
15
+ for (const pair of (raw ?? '').split(',')) {
16
+ // indexOf, not split('='): a token may legitimately contain '='. Only the
17
+ // FIRST separator divides host from token.
18
+ const at = pair.indexOf('=');
19
+ if (at < 1)
20
+ continue;
21
+ const host = pair.slice(0, at).trim().toLowerCase();
22
+ const token = pair.slice(at + 1).trim();
23
+ if (host && token)
24
+ out.set(host, token);
25
+ }
26
+ return out;
27
+ };
28
+ /**
29
+ * The token to serve on `host`.
30
+ *
31
+ * A per-host entry wins. Falling back to the single-value variable for an
32
+ * unlisted host is deliberate: every existing single-host deployment keeps
33
+ * working with no config change.
34
+ */
35
+ export const challengeFor = (host, perHost, single) => parseChallengeTokens(perHost).get((host ?? '').toLowerCase()) ?? single ?? undefined;
36
+ //# sourceMappingURL=challenge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"challenge.js","sourceRoot":"","sources":["../src/challenge.ts"],"names":[],"mappings":"AAAA,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,gFAAgF;AAChF,4EAA4E;AAC5E,8EAA8E;AAC9E,6DAA6D;AAC7D,EAAE;AACF,8EAA8E;AAC9E,wDAAwD;AAExD,kFAAkF;AAClF,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,GAAuB,EAAuB,EAAE;IACnF,MAAM,GAAG,GAAG,IAAI,GAAG,EAAkB,CAAC;IACtC,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1C,0EAA0E;QAC1E,2CAA2C;QAC3C,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,EAAE,GAAG,CAAC;YAAE,SAAS;QACrB,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;QACpD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACxC,IAAI,IAAI,IAAI,KAAK;YAAE,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAC1C,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAC1B,IAAwB,EACxB,OAA2B,EAC3B,MAA0B,EACN,EAAE,CACtB,oBAAoB,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,IAAI,MAAM,IAAI,SAAS,CAAC"}
@@ -0,0 +1,141 @@
1
+ /**
2
+ * One storefront's identity, vocabulary and shop wiring.
3
+ *
4
+ * The single-tenant connector this grew out of held all of it as literals:
5
+ * 'Moody Lenses', 'https://moodycosmetics.com', `list_moody_lenses`. That reads
6
+ * fine until there is a second merchant, at which point the only way to add one
7
+ * is to copy 2,443 lines and rename 172 strings — and then fix every bug twice.
8
+ *
9
+ * What is NOT here is as deliberate as what is. The product model stays generic
10
+ * Shopify: no per-merchant fields, no category enums. The moment a merchant
11
+ * needs a bespoke field, it belongs in their Shopify metafields, not in this
12
+ * type.
13
+ */
14
+ /** Secrets never appear in a config file — they arrive through the environment. */
15
+ export type StorefrontSecrets = {
16
+ clientId: string;
17
+ clientSecret: string;
18
+ };
19
+ export type StorefrontConfig = {
20
+ /**
21
+ * Deployment id, and the path segment it is served under:
22
+ * shopify.curless.ai/<id>. Also the default token filename.
23
+ */
24
+ id: string;
25
+ /** MCP `serverInfo`, passed through to the host verbatim. */
26
+ server: {
27
+ name: string;
28
+ title: string;
29
+ /**
30
+ * The merchant's own site. Left unset it is filled from the shop's
31
+ * `primaryDomain` — the store knows its own address, and asking a human to
32
+ * retype it is a chance to get it wrong.
33
+ */
34
+ websiteUrl?: string;
35
+ iconUrl?: string;
36
+ /**
37
+ * The merchant's own artwork, as files beside the config.
38
+ *
39
+ * Files rather than bytes compiled into the package: one build carrying
40
+ * every merchant's brand means adding a customer edits shared code, and it
41
+ * once meant a shop that had supplied no logo served the FIRST merchant's —
42
+ * naming the wrong party on someone else's connector. Absent here, the
43
+ * routes 404 and `serverInfo.icons` is omitted; nothing is guessed.
44
+ *
45
+ * Paths are resolved against the config file's own directory.
46
+ */
47
+ iconFile?: string;
48
+ logoFile?: string;
49
+ };
50
+ /**
51
+ * How the tools are named.
52
+ *
53
+ * A branded deployment sets these and gets `list_moody_lenses`; a model
54
+ * choosing among several storefront connectors picks by name, and a specific
55
+ * one is harder to pick wrong. The listed public app leaves them unset and
56
+ * gets `list_products`, because a review-time capability list has to be fixed
57
+ * and one install serves exactly one shop.
58
+ */
59
+ naming?: {
60
+ prefix?: string;
61
+ noun?: string;
62
+ nounPlural?: string;
63
+ };
64
+ shopify: {
65
+ /**
66
+ * `env` — one shop, wired at deploy time, credentials from the environment.
67
+ * `oauth-install` — the listed app: the shop is whoever installed it, so it
68
+ * cannot be known here. Not implemented yet; declared so the config shape
69
+ * does not have to change when it is.
70
+ */
71
+ mode: 'env' | 'oauth-install';
72
+ shopDomain?: string;
73
+ scopes?: string;
74
+ tokenPath?: string;
75
+ };
76
+ /** Where this deployment answers. `publicUrl` differs when the merchant fronts it with their own hostname. */
77
+ urls: {
78
+ appUrl: string;
79
+ publicUrl?: string;
80
+ };
81
+ /**
82
+ * Merchant-specific guidance, appended verbatim to the tool it belongs to.
83
+ *
84
+ * Split by tool because it is not all the same kind of fact. At Moody the
85
+ * colour IS the product name (browse), a pair has one option per eye
86
+ * (product), and a checkout needs both eyes with the exact shape
87
+ * {"Left":"-2.00","Right":"-1.50"} (checkout). Generic copy can carry the
88
+ * RULE — fill every option, never round — but not the shape, and the shape
89
+ * is what stops an assistant guessing at the moment money is committed.
90
+ */
91
+ hints?: {
92
+ browse?: string;
93
+ product?: string;
94
+ checkout?: string;
95
+ /** One real product name, shown in the `product` argument's description. */
96
+ example?: string;
97
+ };
98
+ legal: {
99
+ operator?: string;
100
+ operatorContact?: string;
101
+ /** Left unset it is filled from the shop's `contactEmail`. */
102
+ merchantContact?: string;
103
+ region?: string;
104
+ updated?: string;
105
+ };
106
+ };
107
+ /** The words the descriptions speak in, so shared copy can name the merchant's goods. */
108
+ export type Vocabulary = {
109
+ merchant: string;
110
+ noun: string;
111
+ nounPlural: string;
112
+ hints: {
113
+ browse: string;
114
+ product: string;
115
+ checkout: string;
116
+ example: string;
117
+ };
118
+ };
119
+ export declare const vocabulary: (config: StorefrontConfig) => Vocabulary;
120
+ /** What the tools are actually called, once naming has been applied. */
121
+ export type ToolNames = {
122
+ list: string;
123
+ get: string;
124
+ checkout: string;
125
+ lookupOrder: string;
126
+ };
127
+ export declare const toolNames: (config: StorefrontConfig) => ToolNames;
128
+ /** Everything the runtime needs, with defaults applied once, at the edge. */
129
+ export type ResolvedStorefront = {
130
+ config: StorefrontConfig;
131
+ tools: ToolNames;
132
+ vocab: Vocabulary;
133
+ appUrl: string;
134
+ publicUrl: string;
135
+ redirectUri: string;
136
+ scopes: string;
137
+ tokenPath: string;
138
+ cardUriPrefix: string;
139
+ };
140
+ export declare const resolveStorefront: (config: StorefrontConfig) => ResolvedStorefront;
141
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}