@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,112 @@
1
+ // The install flow's security, which is the whole of it. Both checks below pass
2
+ // silently when omitted — the merchant still installs, the token still arrives —
3
+ // which is exactly why they need tests rather than a careful reading.
4
+ import { createHmac } from 'node:crypto';
5
+ import { describe, expect, test, vi } from 'vitest';
6
+ import { exchangeCode, installUrl, isShopDomain, makeState, verifyCallbackHmac, verifyState, } from '../oauth.js';
7
+ const CONFIG = {
8
+ clientId: 'cid',
9
+ clientSecret: 'shhh',
10
+ shopDomain: 'moodylenses.myshopify.com',
11
+ appUrl: 'https://mcp.curless.ai/moody',
12
+ scopes: 'read_products,read_orders',
13
+ };
14
+ // Build a signed callback query string the way Shopify does: raw (already
15
+ // encoded) pairs, sorted by key, hmac appended.
16
+ const signedQuery = (secret, query) => {
17
+ const pairs = Object.keys(query)
18
+ .sort()
19
+ .map((k) => `${k}=${encodeURIComponent(query[k])}`);
20
+ const hmac = createHmac('sha256', secret).update(pairs.join('&')).digest('hex');
21
+ return `?${pairs.join('&')}&hmac=${hmac}`;
22
+ };
23
+ describe('shop domain', () => {
24
+ test('only real Shopify domains', () => {
25
+ expect(isShopDomain('moodylenses.myshopify.com')).toBe(true);
26
+ // A lookalike here would send the merchant's authorisation elsewhere.
27
+ expect(isShopDomain('moodylenses.myshopify.com.evil.com')).toBe(false);
28
+ expect(isShopDomain('evil.com/moodylenses.myshopify.com')).toBe(false);
29
+ expect(isShopDomain('')).toBe(false);
30
+ });
31
+ });
32
+ describe('callback hmac', () => {
33
+ const Q = { code: 'abc', shop: CONFIG.shopDomain, state: 'x.1.y', timestamp: '1' };
34
+ test('accepts what Shopify signed', () => {
35
+ expect(verifyCallbackHmac('shhh', signedQuery('shhh', Q))).toBe(true);
36
+ });
37
+ // The one that matters: Shopify's signature, the attacker's code.
38
+ test('rejects a tampered parameter', () => {
39
+ const good = signedQuery('shhh', Q);
40
+ expect(verifyCallbackHmac('shhh', good.replace('code=abc', 'code=attacker'))).toBe(false);
41
+ });
42
+ test('rejects a signature made with another secret', () => {
43
+ expect(verifyCallbackHmac('shhh', signedQuery('not-ours', Q))).toBe(false);
44
+ });
45
+ test('a missing hmac is a rejection, not a skip', () => {
46
+ expect(verifyCallbackHmac('shhh', '?code=abc&shop=x.myshopify.com')).toBe(false);
47
+ });
48
+ // Verified against the RAW query, so a value that had to be encoded still
49
+ // matches. Rebuilding the message from DECODED values fails here — and fails
50
+ // closed, i.e. as "the merchant cannot install", which reads like anything but
51
+ // a signing bug.
52
+ test('a value containing encoded characters still verifies', () => {
53
+ const q = { ...Q, host: 'bW9vZHlsZW5zZXM=', code: 'a+b/c=' };
54
+ expect(verifyCallbackHmac('shhh', signedQuery('shhh', q))).toBe(true);
55
+ });
56
+ });
57
+ describe('state nonce', () => {
58
+ test('round-trips what we issued', () => {
59
+ expect(verifyState('shhh', makeState('shhh'))).toBe(true);
60
+ });
61
+ test('a state we did not issue is refused', () => {
62
+ expect(verifyState('shhh', 'made.up.x')).toBe(false);
63
+ expect(verifyState('shhh', makeState('another-secret'))).toBe(false);
64
+ expect(verifyState('shhh', '')).toBe(false);
65
+ });
66
+ // Without an expiry a state stays usable forever — a URL captured once (a
67
+ // history entry, a proxy log) remains a valid start-of-install indefinitely.
68
+ test('an old state expires', () => {
69
+ const now = 1_700_000_000_000;
70
+ const state = makeState('shhh', now);
71
+ expect(verifyState('shhh', state, now + 60_000)).toBe(true);
72
+ expect(verifyState('shhh', state, now + 16 * 60_000)).toBe(false);
73
+ });
74
+ test('a state from the future is refused too', () => {
75
+ const now = 1_700_000_000_000;
76
+ expect(verifyState('shhh', makeState('shhh', now + 10 * 60_000), now)).toBe(false);
77
+ });
78
+ test('two installs do not share a nonce', () => {
79
+ expect(makeState('shhh')).not.toBe(makeState('shhh'));
80
+ });
81
+ });
82
+ describe('install url', () => {
83
+ test('asks the right shop for the right scopes, back to our callback', () => {
84
+ const url = new URL(installUrl(CONFIG, 'st.at'));
85
+ expect(url.host).toBe('moodylenses.myshopify.com');
86
+ expect(url.pathname).toBe('/admin/oauth/authorize');
87
+ expect(url.searchParams.get('scope')).toBe('read_products,read_orders');
88
+ expect(url.searchParams.get('redirect_uri')).toBe('https://mcp.curless.ai/moody/auth/callback');
89
+ expect(url.searchParams.get('state')).toBe('st.at');
90
+ // Absent = offline token. Present-but-empty is neither offline nor per-user.
91
+ expect(url.searchParams.has('grant_options[]')).toBe(false);
92
+ });
93
+ });
94
+ describe('code exchange', () => {
95
+ test('returns the token and the scopes actually granted', async () => {
96
+ const f = vi.fn(async () => new Response(JSON.stringify({ access_token: 'shpat_x', scope: 'read_products' }), {
97
+ status: 200,
98
+ headers: { 'content-type': 'application/json' },
99
+ }));
100
+ // Shopify may grant LESS than asked; the caller records what it got.
101
+ await expect(exchangeCode(CONFIG, 'code', f)).resolves.toEqual({
102
+ accessToken: 'shpat_x',
103
+ scope: 'read_products',
104
+ });
105
+ });
106
+ test('a failure never echoes the response body', async () => {
107
+ const f = vi.fn(async () => new Response('{"error":"reflected input here"}', { status: 400 }));
108
+ await expect(exchangeCode(CONFIG, 'bad', f)).rejects.toThrow(/HTTP 400/);
109
+ await expect(exchangeCode(CONFIG, 'bad', f)).rejects.not.toThrow(/reflected/);
110
+ });
111
+ });
112
+ //# sourceMappingURL=oauth.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.test.js","sourceRoot":"","sources":["../../src/__tests__/oauth.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=output-schema.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"output-schema.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/output-schema.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,112 @@
1
+ // The declared shape must match what the tools actually return.
2
+ //
3
+ // An outputSchema is a promise to the model about what it is holding. A promise
4
+ // that is sometimes false is worse than none: the reader trusting it is the
5
+ // assistant deciding what to tell a buyer. So every `view` a tool can produce is
6
+ // checked against the enum that claims to list them.
7
+ import { describe, expect, test } from 'vitest';
8
+ import { toolNames, vocabulary } from '../config.js';
9
+ import { buildOutputSchemas, buildTools } from '../tools.js';
10
+ // The names this suite was written against. Keeping moody's here means the
11
+ // schema coverage doubles as parity: every shape asserted below is the shape
12
+ // the live connector answers with today.
13
+ const MOODY_CONFIG = {
14
+ id: 'moody',
15
+ server: { name: 'moody-lenses', title: 'Moody Lenses' },
16
+ naming: { prefix: 'moody', noun: 'lens', nounPlural: 'lenses' },
17
+ shopify: { mode: 'env' },
18
+ urls: { appUrl: 'https://shopify.curless.ai/moody' },
19
+ legal: {},
20
+ };
21
+ const NAMES = toolNames(MOODY_CONFIG);
22
+ const TOOLS = buildTools(NAMES, vocabulary(MOODY_CONFIG));
23
+ const OUTPUT_SCHEMAS = buildOutputSchemas(NAMES);
24
+ const schemaFor = (name) => OUTPUT_SCHEMAS[name];
25
+ const viewsOf = (name) => {
26
+ const v = schemaFor(name)?.properties.view;
27
+ return v?.enum ?? (v?.const ? [v.const] : []);
28
+ };
29
+ describe('output schemas', () => {
30
+ test('every tool declares one — the submission flags the ones that do not', () => {
31
+ for (const t of TOOLS)
32
+ expect(schemaFor(t.name), t.name).toBeTruthy();
33
+ });
34
+ // `view` is how a reader tells a checkout link from "tell me the other eye".
35
+ // Everything else about a payload can evolve; this cannot go missing.
36
+ test('view is always present and always required', () => {
37
+ for (const t of TOOLS) {
38
+ expect(schemaFor(t.name)?.required, t.name).toContain('view');
39
+ expect(viewsOf(t.name).length, t.name).toBeGreaterThan(0);
40
+ }
41
+ });
42
+ // The list is drawn from tools.ts itself, so a new branch that returns a new
43
+ // view fails here rather than silently contradicting the declaration.
44
+ test('the declared views cover every view the code can return', async () => {
45
+ const src = await import('node:fs/promises').then((fs) => fs.readFile(new URL('../tools.ts', import.meta.url), 'utf8'));
46
+ // Only the ones inside `structured: { view: '…' }`, not the schema's own.
47
+ const returned = new Set([...src.matchAll(/structured:\s*\{\s*\n?\s*view:\s*'([a-z-]+)'/g)].map((m) => m[1]));
48
+ const declared = new Set(TOOLS.flatMap((t) => viewsOf(t.name)));
49
+ // 'error' is reachable from several tools; it is declared where returned.
50
+ const missing = [...returned].filter((v) => !declared.has(v));
51
+ expect(missing, `views returned by the code but not declared: ${missing.join(', ')}`).toEqual([]);
52
+ });
53
+ test('a checkout result declares the field the buyer is actually sent to', () => {
54
+ expect(Object.keys(schemaFor('create_moody_checkout').properties)).toContain('checkoutUrl');
55
+ });
56
+ // Both mean "we will not tell you", and the schema must not imply otherwise by
57
+ // offering a shape that distinguishes them.
58
+ test('order lookup declares one not-found view, not two', () => {
59
+ const views = viewsOf('lookup_moody_order');
60
+ expect(views).toContain('order-not-found');
61
+ expect(views.filter((v) => v.includes('not-found'))).toHaveLength(1);
62
+ });
63
+ });
64
+ // Anthropic's pre-submission checklist: "Every tool must include a `title` and
65
+ // the applicable hint". The hints were there from the start because OpenAI asks
66
+ // for them; the title was missing on all four and nothing failed — an automated
67
+ // policy scan passed us into the directory anyway, and the gap only showed up on
68
+ // a read of the criteria. So it gets a test rather than a memory.
69
+ describe('tool annotations', () => {
70
+ test('every tool carries a title and all three hints', () => {
71
+ for (const t of TOOLS) {
72
+ const a = t.annotations;
73
+ expect(a?.title, `${t.name} has no title`).toBeTruthy();
74
+ // The title is read by a BUYER in a permission line, so it must not be the
75
+ // function name wearing a hat.
76
+ expect(a?.title, `${t.name} title is just the tool name`).not.toBe(t.name);
77
+ expect(a?.readOnlyHint, `${t.name} is not declared read-only`).toBe(true);
78
+ }
79
+ });
80
+ test('tool names stay inside the 64-character limit', () => {
81
+ for (const t of TOOLS)
82
+ expect(t.name.length, t.name).toBeLessThanOrEqual(64);
83
+ });
84
+ });
85
+ // Anthropic's criteria reject tool descriptions that "interfere with Claude
86
+ // calling other tools": describe what the tool does, do not tell the model how
87
+ // to behave. This connector shipped such a line for a day — "Use this INSTEAD
88
+ // of web search" — written in good faith after watching a host answer from a
89
+ // scraped page, and it read as reasonable right up until the criteria were
90
+ // read. It is a tempting sentence to write again the next time a host routes
91
+ // somewhere else, so it gets a guard rather than a resolution.
92
+ //
93
+ // Note what is NOT banned: telling the model how to use THIS tool ("call this
94
+ // FIRST, with no arguments", "do not interview them first"). That is the tool
95
+ // describing its own correct use. The line is drawn at reaching for the other
96
+ // tools in the list.
97
+ describe('tool descriptions stay inside the directory criteria', () => {
98
+ const STEERING = [
99
+ /instead of (?:web )?search/i,
100
+ /rather than (?:using )?(?:web )?search/i,
101
+ /do not (?:use|call) (?:the )?(?:web|other|any other)/i,
102
+ /don'?t (?:use|call) (?:the )?(?:web|other|any other)/i,
103
+ ];
104
+ test('no description steers the model away from other tools', () => {
105
+ for (const t of TOOLS) {
106
+ for (const pattern of STEERING) {
107
+ expect(pattern.test(t.description), `${t.name}: ${pattern}`).toBe(false);
108
+ }
109
+ }
110
+ });
111
+ });
112
+ //# sourceMappingURL=output-schema.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"output-schema.test.js","sourceRoot":"","sources":["../../src/__tests__/output-schema.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ping.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ping.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/ping.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,22 @@
1
+ // The spec's `ping` is a request the receiver MUST answer promptly with an
2
+ // empty result, and hosts use it to decide whether the server is still there.
3
+ // Answering -32601 made Claude tell the buyer "Unable to reach Moody Lenses"
4
+ // and "There was a problem displaying content" — on a connection where every
5
+ // tool call in the same log line was succeeding. The banner said nothing
6
+ // worked; the server log said everything did.
7
+ //
8
+ // Asserted against the SOURCE because the module starts a listener on import.
9
+ import { readFileSync } from 'node:fs';
10
+ import { describe, expect, test } from 'vitest';
11
+ const SRC = readFileSync(new URL('../index.ts', import.meta.url), 'utf8');
12
+ describe('JSON-RPC methods a host relies on', () => {
13
+ test('ping is handled, not left to fall through to method-not-found', () => {
14
+ expect(SRC).toMatch(/method === 'ping'/);
15
+ });
16
+ test('and it answers with a result, which is what "MUST respond" means', () => {
17
+ const at = SRC.indexOf("method === 'ping'");
18
+ expect(at).toBeGreaterThan(-1);
19
+ expect(SRC.slice(at, at + 400)).toMatch(/return ok\(c, id, \{\}\)/);
20
+ });
21
+ });
22
+ //# sourceMappingURL=ping.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ping.test.js","sourceRoot":"","sources":["../../src/__tests__/ping.test.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,8EAA8E;AAC9E,6EAA6E;AAC7E,6EAA6E;AAC7E,yEAAyE;AACzE,8CAA8C;AAC9C,EAAE;AACF,8EAA8E;AAC9E,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEhD,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,GAAG,CAAC,aAAa,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC;AAE1E,QAAQ,CAAC,mCAAmC,EAAE,GAAG,EAAE;IACjD,IAAI,CAAC,+DAA+D,EAAE,GAAG,EAAE;QACzE,MAAM,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,kEAAkE,EAAE,GAAG,EAAE;QAC5E,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC;QAC5C,MAAM,CAAC,EAAE,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC;QAC/B,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,0BAA0B,CAAC,CAAC;IACtE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=rate-limit.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/rate-limit.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,46 @@
1
+ // `/mcp` is public and unauthenticated — a shopper has no account with us — so
2
+ // the limiter is the only thing between the internet and the merchant's Shopify
3
+ // quota, and between a guessed order number and someone's purchase history.
4
+ import { describe, expect, test } from 'vitest';
5
+ import { RateLimiter, clientIp } from '../rate-limit.js';
6
+ describe('RateLimiter', () => {
7
+ test('allows up to the limit, then refuses', () => {
8
+ const rl = new RateLimiter(3, 60_000);
9
+ expect([rl.take('a'), rl.take('a'), rl.take('a')]).toEqual([true, true, true]);
10
+ expect(rl.take('a')).toBe(false);
11
+ });
12
+ test('one caller cannot spend another caller"s budget', () => {
13
+ const rl = new RateLimiter(1, 60_000);
14
+ expect(rl.take('a')).toBe(true);
15
+ expect(rl.take('a')).toBe(false);
16
+ expect(rl.take('b'), 'a different address is a different bucket').toBe(true);
17
+ });
18
+ test('the window reopens', () => {
19
+ const rl = new RateLimiter(1, 1_000);
20
+ const t = 1_700_000_000_000;
21
+ expect(rl.take('a', t)).toBe(true);
22
+ expect(rl.take('a', t + 500)).toBe(false);
23
+ expect(rl.take('a', t + 1_001)).toBe(true);
24
+ });
25
+ test('a flood of distinct addresses cannot grow the map without bound', () => {
26
+ const rl = new RateLimiter(1, 60_000, 100);
27
+ for (let i = 0; i < 500; i++)
28
+ rl.take(`ip-${i}`);
29
+ // No assertion on internals — the point is that it neither throws nor keeps
30
+ // every key. A limiter that OOMs is a denial of service with extra steps.
31
+ expect(rl.take('ip-final')).toBe(true);
32
+ });
33
+ });
34
+ describe('clientIp', () => {
35
+ test('takes the client, not the proxy chain', () => {
36
+ const h = new Headers({ 'x-forwarded-for': '203.0.113.7, 10.0.0.1, 10.0.0.2' });
37
+ expect(clientIp(h)).toBe('203.0.113.7');
38
+ });
39
+ test('falls back through x-real-ip, then to a constant', () => {
40
+ expect(clientIp(new Headers({ 'x-real-ip': '198.51.100.4' }))).toBe('198.51.100.4');
41
+ // Everything unknown shares one bucket — strictly safer than treating every
42
+ // unidentified caller as brand new.
43
+ expect(clientIp(new Headers())).toBe('unknown');
44
+ });
45
+ });
46
+ //# sourceMappingURL=rate-limit.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.test.js","sourceRoot":"","sources":["../../src/__tests__/rate-limit.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=stock.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stock.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/stock.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,109 @@
1
+ // The picker's stock rule, tested away from the card.
2
+ //
3
+ // It decides which prescriptions a buyer is allowed to pick, and it was written
4
+ // because the first version let them pick any of 26x26 and told them "sold out"
5
+ // on almost every one — at the real store 674 of the 676 Matcha Green pairs do
6
+ // not exist. A rule that load-bearing should not only be checkable by clicking
7
+ // through a live store, so it lives in its own module and is exercised here.
8
+ import { describe, expect, test } from 'vitest';
9
+ import { offeredValue, pruneChoices, stockOf } from '../stock.js';
10
+ // What the real store has for Matcha Green: two pairs out of 676.
11
+ const MATCHA = [
12
+ { Left: '-5.75', Right: '-5.50' },
13
+ { Left: '-2.00', Right: '-2.00' },
14
+ ];
15
+ describe('offeredValue', () => {
16
+ test('a value no stocked combination has is not offered', () => {
17
+ expect(offeredValue(MATCHA, {}, 'Left', '-3.00')).toBe(false);
18
+ expect(offeredValue(MATCHA, {}, 'Left', '-5.75')).toBe(true);
19
+ });
20
+ // The point of the whole rule: one eye narrows the other. Right -2.00 exists
21
+ // in the store, but NOT alongside Left -5.75 — and a picker that offered it
22
+ // would take the buyer straight to a sold-out checkout.
23
+ test('choosing one eye narrows what the other may be', () => {
24
+ expect(offeredValue(MATCHA, { Left: '-5.75' }, 'Right', '-5.50')).toBe(true);
25
+ expect(offeredValue(MATCHA, { Left: '-5.75' }, 'Right', '-2.00')).toBe(false);
26
+ });
27
+ // Otherwise, once Left is chosen, no other Left could ever be chosen again —
28
+ // the group would freeze on the first pick.
29
+ test('a group does not lock itself: its own current pick is excluded', () => {
30
+ expect(offeredValue(MATCHA, { Left: '-5.75' }, 'Left', '-2.00')).toBe(true);
31
+ });
32
+ // An empty list means the server truncated it, not that the store is empty.
33
+ // Filtering on a partial list would HIDE prescriptions the store has, and an
34
+ // invisible option cannot be argued with — a sold-out message at least can.
35
+ test('no stock information offers everything rather than hiding it', () => {
36
+ expect(offeredValue([], { Left: '-5.75' }, 'Right', '-9.99')).toBe(true);
37
+ });
38
+ });
39
+ describe('pruneChoices', () => {
40
+ test('a pick that contradicts the new one is dropped, not silently kept', () => {
41
+ // Left -5.75 with Right -2.00 is not a pair this store has. Keeping both
42
+ // would light up Buy over a combination that cannot be bought.
43
+ expect(pruneChoices(MATCHA, { Left: '-5.75', Right: '-2.00' })).not.toEqual({
44
+ Left: '-5.75',
45
+ Right: '-2.00',
46
+ });
47
+ });
48
+ test('a combination the store really has survives intact', () => {
49
+ expect(pruneChoices(MATCHA, { Left: '-5.75', Right: '-5.50' })).toEqual({
50
+ Left: '-5.75',
51
+ Right: '-5.50',
52
+ });
53
+ expect(pruneChoices(MATCHA, { Left: '-2.00', Right: '-2.00' })).toEqual({
54
+ Left: '-2.00',
55
+ Right: '-2.00',
56
+ });
57
+ });
58
+ test('without stock information nothing is pruned', () => {
59
+ expect(pruneChoices([], { Left: '-9.99' })).toEqual({ Left: '-9.99' });
60
+ });
61
+ test('a single-option product keeps a stocked value and drops an unstocked one', () => {
62
+ const single = [{ Diopter: '-1.00' }, { Diopter: '-2.00' }];
63
+ expect(pruneChoices(single, { Diopter: '-2.00' })).toEqual({ Diopter: '-2.00' });
64
+ expect(pruneChoices(single, { Diopter: '-3.00' })).toEqual({});
65
+ });
66
+ });
67
+ // The bug this function exists to prevent, from the real payloads.
68
+ //
69
+ // `get_moody_lens` nests everything about the lens under `product`, while
70
+ // `needs-options` keeps the answer to "what is still missing" at the top level.
71
+ // The card read only the top level, so on the product view the stock list was
72
+ // always empty — and empty means "we do not know", which offers everything. At a
73
+ // store with 2 of 676 in stock, every prescription looked available and nothing
74
+ // was struck through. Nothing threw; the buyer was just quietly told they could
75
+ // have anything.
76
+ describe('stockOf', () => {
77
+ test('finds it on the product view, where it is nested under `product`', () => {
78
+ const sc = {
79
+ view: 'product',
80
+ product: { inStock: MATCHA, inStockTruncated: false, inStockCount: 2 },
81
+ };
82
+ expect(stockOf(sc)).toEqual(MATCHA);
83
+ });
84
+ test('finds it on needs-options, where it sits at the top level', () => {
85
+ const sc = { view: 'needs-options', inStock: MATCHA, inStockTruncated: false, product: {} };
86
+ expect(stockOf(sc)).toEqual(MATCHA);
87
+ });
88
+ // Filtering on a partial list would HIDE prescriptions the store has, which is
89
+ // worse than the sold-out message: an invisible option cannot be argued with.
90
+ test('a truncated list turns filtering off, wherever it was found', () => {
91
+ expect(stockOf({ product: { inStock: MATCHA, inStockTruncated: true } })).toEqual([]);
92
+ expect(stockOf({ inStock: MATCHA, inStockTruncated: true })).toEqual([]);
93
+ });
94
+ test('a payload with no stock information at all is handled, not thrown on', () => {
95
+ expect(stockOf(undefined)).toEqual([]);
96
+ expect(stockOf({})).toEqual([]);
97
+ expect(stockOf({ product: {} })).toEqual([]);
98
+ });
99
+ // Ties the two halves together: what the reader finds must actually drive the
100
+ // rule. A lookup that returns the list but is never consulted is the same bug
101
+ // wearing a different hat.
102
+ test('what it finds is what decides the chips', () => {
103
+ const sc = { product: { inStock: MATCHA, inStockTruncated: false } };
104
+ const stock = stockOf(sc);
105
+ expect(offeredValue(stock, {}, 'Left', '-5.75')).toBe(true);
106
+ expect(offeredValue(stock, {}, 'Left', '-3.50')).toBe(false);
107
+ });
108
+ });
109
+ //# sourceMappingURL=stock.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stock.test.js","sourceRoot":"","sources":["../../src/__tests__/stock.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tenant.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/tenant.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { resolveStorefront } from '../config.js';
3
+ import { explainNoTenant, multiShopResolver, singleShopResolver } from '../tenant.js';
4
+ // The fakes are typed against the reader slices the resolvers declare, so a
5
+ // required member added to either store fails HERE, at compile time, instead of
6
+ // compiling fine and throwing once the suite runs.
7
+ const fakeCatalog = (tag) => ({ tag });
8
+ const token = (shopDomain = 'rqxbft-fw.myshopify.com') => ({
9
+ shopDomain,
10
+ accessToken: 'x',
11
+ scope: 'read_products',
12
+ installedAt: '2026-01-01T00:00:00.000Z',
13
+ });
14
+ const single = (opts) => singleShopResolver({
15
+ store: resolveStorefront({
16
+ id: 'moody',
17
+ server: { name: 'moody', title: 'Moody' },
18
+ shopify: { mode: 'env', shopDomain: 'rqxbft-fw.myshopify.com' },
19
+ urls: { appUrl: 'https://shopify.curless.ai/moody' },
20
+ legal: {},
21
+ }),
22
+ catalog: fakeCatalog('single'),
23
+ tokens: { get: async () => (opts.installed ? token() : null) },
24
+ });
25
+ const multi = (installed) => {
26
+ const started = [];
27
+ const registry = {
28
+ for: (s) => {
29
+ started.push(s);
30
+ return fakeCatalog(s);
31
+ },
32
+ };
33
+ const tokens = {
34
+ get: async (s) => (installed.includes(s) ? token(s) : null),
35
+ };
36
+ const resolve = multiShopResolver({ registry, tokens });
37
+ return { resolve, started };
38
+ };
39
+ describe('branded deployment', () => {
40
+ test('a request naming nothing gets the one shop it serves', async () => {
41
+ const r = await single({ installed: true })();
42
+ expect(r.ok && r.tenant.shopDomain).toBe('rqxbft-fw.myshopify.com');
43
+ });
44
+ test('a request naming the SAME shop is fine', async () => {
45
+ const r = await single({ installed: true })('RQXBFT-FW.myshopify.com');
46
+ expect(r.ok).toBe(true);
47
+ });
48
+ // The shop is fixed, so a `shop` on the request is a claim to check, not a
49
+ // question to answer. Serving the only catalogue we have would answer a
50
+ // question about someone else's store with this store's products.
51
+ test('a request naming a DIFFERENT shop is refused, not quietly served', async () => {
52
+ const r = await single({ installed: true })('someone-else.myshopify.com');
53
+ expect(r).toEqual({ ok: false, reason: 'bad-shop', given: 'someone-else.myshopify.com' });
54
+ });
55
+ test('before install, not-installed rather than an empty catalogue', async () => {
56
+ const r = await single({ installed: false })();
57
+ expect(r.ok).toBe(false);
58
+ expect(r.ok === false && r.reason).toBe('not-installed');
59
+ });
60
+ });
61
+ describe('listed app', () => {
62
+ test('each shop resolves to its own catalogue', async () => {
63
+ const { resolve, started } = multi(['a.myshopify.com', 'b.myshopify.com']);
64
+ const a = await resolve('a.myshopify.com');
65
+ const b = await resolve('b.myshopify.com');
66
+ expect(a.ok && b.ok).toBe(true);
67
+ expect(started).toEqual(['a.myshopify.com', 'b.myshopify.com']);
68
+ });
69
+ // There is no default shop. Picking one would answer as whichever merchant
70
+ // happened to be first, which is the failure this whole layer exists for.
71
+ test('no shop named is refused, never defaulted', async () => {
72
+ const { resolve, started } = multi(['a.myshopify.com']);
73
+ expect(await resolve()).toEqual({ ok: false, reason: 'no-shop' });
74
+ expect(await resolve(' ')).toEqual({ ok: false, reason: 'no-shop' });
75
+ expect(started).toEqual([]);
76
+ });
77
+ test('a shop we will not name never reaches the token store or the registry', async () => {
78
+ const { resolve, started } = multi(['a.myshopify.com']);
79
+ const r = await resolve('../../etc/passwd');
80
+ expect(r.ok).toBe(false);
81
+ expect(r.ok === false && r.reason).toBe('bad-shop');
82
+ expect(started).toEqual([]);
83
+ });
84
+ test('a real shop that has not installed is ordinary, not an error', async () => {
85
+ const { resolve, started } = multi([]);
86
+ const r = await resolve('c.myshopify.com');
87
+ expect(r.ok === false && r.reason).toBe('not-installed');
88
+ // No catalogue is started for a shop with no token — that would poll
89
+ // Shopify for a store that never installed us.
90
+ expect(started).toEqual([]);
91
+ });
92
+ test('case is folded, so one store is one tenant', async () => {
93
+ const { resolve, started } = multi(['a.myshopify.com']);
94
+ expect((await resolve('A.MyShopify.com')).ok).toBe(true);
95
+ expect(started).toEqual(['a.myshopify.com']);
96
+ });
97
+ });
98
+ describe('what the buyer is told', () => {
99
+ test('each refusal says something actionable and names no internals', () => {
100
+ expect(explainNoTenant({ reason: 'no-shop' })).toMatch(/say which store/i);
101
+ expect(explainNoTenant({ reason: 'bad-shop', given: 'x.com' })).toContain('x.com');
102
+ expect(explainNoTenant({ reason: 'not-installed', shop: 'a.myshopify.com' })).toMatch(/has not installed/i);
103
+ });
104
+ });
105
+ //# sourceMappingURL=tenant.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant.test.js","sourceRoot":"","sources":["../../src/__tests__/tenant.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAChD,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAEjD,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAGtF,4EAA4E;AAC5E,gFAAgF;AAChF,mDAAmD;AACnD,MAAM,WAAW,GAAG,CAAC,GAAW,EAAE,EAAE,CAClC,CAAC,EAAE,GAAG,EAAE,CAAmE,CAAC;AAE9E,MAAM,KAAK,GAAG,CAAC,UAAU,GAAG,yBAAyB,EAAe,EAAE,CAAC,CAAC;IACtE,UAAU;IACV,WAAW,EAAE,GAAG;IAChB,KAAK,EAAE,eAAe;IACtB,WAAW,EAAE,0BAA0B;CACxC,CAAC,CAAC;AAEH,MAAM,MAAM,GAAG,CAAC,IAA4B,EAAE,EAAE,CAC9C,kBAAkB,CAAC;IACjB,KAAK,EAAE,iBAAiB,CAAC;QACvB,EAAE,EAAE,OAAO;QACX,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE;QACzC,OAAO,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,UAAU,EAAE,yBAAyB,EAAE;QAC/D,IAAI,EAAE,EAAE,MAAM,EAAE,kCAAkC,EAAE;QACpD,KAAK,EAAE,EAAE;KACV,CAAC;IACF,OAAO,EAAE,WAAW,CAAC,QAAQ,CAAC;IAC9B,MAAM,EAAE,EAAE,GAAG,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAwB;CACrF,CAAC,CAAC;AAEL,MAAM,KAAK,GAAG,CAAC,SAAmB,EAAE,EAAE;IACpC,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,MAAM,QAAQ,GAAkB;QAC9B,GAAG,EAAE,CAAC,CAAS,EAAE,EAAE;YACjB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAChB,OAAO,WAAW,CAAC,CAAC,CAAC,CAAC;QACxB,CAAC;KACF,CAAC;IACF,MAAM,MAAM,GAAqB;QAC/B,GAAG,EAAE,KAAK,EAAE,CAAS,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;KACpE,CAAC;IACF,MAAM,OAAO,GAAG,iBAAiB,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;IACxD,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AAC9B,CAAC,CAAC;AAEF,QAAQ,CAAC,oBAAoB,EAAE,GAAG,EAAE;IAClC,IAAI,CAAC,sDAAsD,EAAE,KAAK,IAAI,EAAE;QACtE,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QAC9C,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;IACtE,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,wCAAwC,EAAE,KAAK,IAAI,EAAE;QACxD,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,yBAAyB,CAAC,CAAC;QACvE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC,CAAC,CAAC;IAEH,2EAA2E;IAC3E,wEAAwE;IACxE,kEAAkE;IAClE,IAAI,CAAC,kEAAkE,EAAE,KAAK,IAAI,EAAE;QAClF,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,4BAA4B,CAAC,CAAC;QAC1E,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,4BAA4B,EAAE,CAAC,CAAC;IAC5F,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8DAA8D,EAAE,KAAK,IAAI,EAAE;QAC9E,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;QAC/C,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACzB,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;IAC3D,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,YAAY,EAAE,GAAG,EAAE;IAC1B,IAAI,CAAC,yCAAyC,EAAE,KAAK,IAAI,EAAE;QACzD,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,KAAK,CAAC,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,CAAC,CAAC;QAC3E,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,iBAAiB,CAAC,CAAC;QAC3C,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,iBAAiB,CAAC,CAAC;QAC3C,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChC,MAAM,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,CAAC,CAAC;IAClE,CAAC,CAAC,CAAC;IAEH,2EAA2E;IAC3E,0EAA0E;IAC1E,IAAI,CAAC,2CAA2C,EAAE,KAAK,IAAI,EAAE;QAC3D,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,KAAK,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC;QACxD,MAAM,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;QAClE,MAAM,CAAC,MAAM,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;QACvE,MAAM,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,uEAAuE,EAAE,KAAK,IAAI,EAAE;QACvF,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,KAAK,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC;QACxD,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,kBAAkB,CAAC,CAAC;QAC5C,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACzB,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACpD,MAAM,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8DAA8D,EAAE,KAAK,IAAI,EAAE;QAC9E,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,KAAK,CAAC,EAAE,CAAC,CAAC;QACvC,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,iBAAiB,CAAC,CAAC;QAC3C,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;QACzD,qEAAqE;QACrE,+CAA+C;QAC/C,MAAM,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,4CAA4C,EAAE,KAAK,IAAI,EAAE;QAC5D,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,KAAK,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC;QACxD,MAAM,CAAC,CAAC,MAAM,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzD,MAAM,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,IAAI,CAAC,+DAA+D,EAAE,GAAG,EAAE;QACzE,MAAM,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC;QAC3E,MAAM,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QACnF,MAAM,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,eAAe,EAAE,IAAI,EAAE,iBAAiB,EAAE,CAAC,CAAC,CAAC,OAAO,CACnF,oBAAoB,CACrB,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tools.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/tools.test.ts"],"names":[],"mappings":""}