@vendure-platform/create 0.5.1 → 1.0.0-major.202609160652.120.4

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 (176) hide show
  1. package/README.md +48 -36
  2. package/dist/add-to-existing.d.ts +20 -14
  3. package/dist/add-to-existing.js +1 -1
  4. package/dist/assets/demo-data/assets/branding/logo.svg +4 -0
  5. package/dist/assets/demo-data/assets/images/absorbent-pad.webp +0 -0
  6. package/dist/assets/demo-data/assets/images/adjustable-wrench.webp +0 -0
  7. package/dist/assets/demo-data/assets/images/anchor-bolt.webp +0 -0
  8. package/dist/assets/demo-data/assets/images/angle-grinder.webp +0 -0
  9. package/dist/assets/demo-data/assets/images/barrier-tape.webp +0 -0
  10. package/dist/assets/demo-data/assets/images/bench-grinder.webp +0 -0
  11. package/dist/assets/demo-data/assets/images/bracket.webp +0 -0
  12. package/dist/assets/demo-data/assets/images/broom.webp +0 -0
  13. package/dist/assets/demo-data/assets/images/bucket.webp +0 -0
  14. package/dist/assets/demo-data/assets/images/cable-gland.webp +0 -0
  15. package/dist/assets/demo-data/assets/images/cable-tie.webp +0 -0
  16. package/dist/assets/demo-data/assets/images/cable-tray.webp +0 -0
  17. package/dist/assets/demo-data/assets/images/cable.webp +0 -0
  18. package/dist/assets/demo-data/assets/images/chimney-base-kit.webp +0 -0
  19. package/dist/assets/demo-data/assets/images/chimney-cowl-bird-guard.webp +0 -0
  20. package/dist/assets/demo-data/assets/images/chimney-cowl-open.webp +0 -0
  21. package/dist/assets/demo-data/assets/images/chimney-storm-collar.webp +0 -0
  22. package/dist/assets/demo-data/assets/images/chimney-wall-bracket.webp +0 -0
  23. package/dist/assets/demo-data/assets/images/circular-saw.webp +0 -0
  24. package/dist/assets/demo-data/assets/images/cleaning-spray.webp +0 -0
  25. package/dist/assets/demo-data/assets/images/conduit.webp +0 -0
  26. package/dist/assets/demo-data/assets/images/cordless-drill.webp +0 -0
  27. package/dist/assets/demo-data/assets/images/drill-bits.webp +0 -0
  28. package/dist/assets/demo-data/assets/images/dust-mask.webp +0 -0
  29. package/dist/assets/demo-data/assets/images/ear-defenders.webp +0 -0
  30. package/dist/assets/demo-data/assets/images/ear-plugs.webp +0 -0
  31. package/dist/assets/demo-data/assets/images/extension-lead.webp +0 -0
  32. package/dist/assets/demo-data/assets/images/eye-wash-station.webp +0 -0
  33. package/dist/assets/demo-data/assets/images/face-shield.webp +0 -0
  34. package/dist/assets/demo-data/assets/images/file-set.webp +0 -0
  35. package/dist/assets/demo-data/assets/images/first-aid-kit.webp +0 -0
  36. package/dist/assets/demo-data/assets/images/floor-sign.webp +0 -0
  37. package/dist/assets/demo-data/assets/images/flue-bend-45.webp +0 -0
  38. package/dist/assets/demo-data/assets/images/flue-bend-90.webp +0 -0
  39. package/dist/assets/demo-data/assets/images/flue-section-1000mm.webp +0 -0
  40. package/dist/assets/demo-data/assets/images/flue-section-250mm.webp +0 -0
  41. package/dist/assets/demo-data/assets/images/flue-section-500mm.webp +0 -0
  42. package/dist/assets/demo-data/assets/images/fuse.webp +0 -0
  43. package/dist/assets/demo-data/assets/images/glove-dispenser.webp +0 -0
  44. package/dist/assets/demo-data/assets/images/grinding-disc.webp +0 -0
  45. package/dist/assets/demo-data/assets/images/hacksaw.webp +0 -0
  46. package/dist/assets/demo-data/assets/images/hammer.webp +0 -0
  47. package/dist/assets/demo-data/assets/images/hand-sanitiser.webp +0 -0
  48. package/dist/assets/demo-data/assets/images/head-torch.webp +0 -0
  49. package/dist/assets/demo-data/assets/images/heat-gun.webp +0 -0
  50. package/dist/assets/demo-data/assets/images/hex-bolt.webp +0 -0
  51. package/dist/assets/demo-data/assets/images/hex-key-set.webp +0 -0
  52. package/dist/assets/demo-data/assets/images/hex-nut.webp +0 -0
  53. package/dist/assets/demo-data/assets/images/hi-vis-jacket.webp +0 -0
  54. package/dist/assets/demo-data/assets/images/hi-vis-vest.webp +0 -0
  55. package/dist/assets/demo-data/assets/images/hose-clamp.webp +0 -0
  56. package/dist/assets/demo-data/assets/images/impact-driver.webp +0 -0
  57. package/dist/assets/demo-data/assets/images/jigsaw.webp +0 -0
  58. package/dist/assets/demo-data/assets/images/junction-box.webp +0 -0
  59. package/dist/assets/demo-data/assets/images/knee-pads.webp +0 -0
  60. package/dist/assets/demo-data/assets/images/machine-screw.webp +0 -0
  61. package/dist/assets/demo-data/assets/images/made-to-order-configurable-chimney.webp +0 -0
  62. package/dist/assets/demo-data/assets/images/mallet.webp +0 -0
  63. package/dist/assets/demo-data/assets/images/mcb.webp +0 -0
  64. package/dist/assets/demo-data/assets/images/mop.webp +0 -0
  65. package/dist/assets/demo-data/assets/images/paper-towels.webp +0 -0
  66. package/dist/assets/demo-data/assets/images/pipe-wrench.webp +0 -0
  67. package/dist/assets/demo-data/assets/images/pliers-set.webp +0 -0
  68. package/dist/assets/demo-data/assets/images/pry-bar.webp +0 -0
  69. package/dist/assets/demo-data/assets/images/rcd.webp +0 -0
  70. package/dist/assets/demo-data/assets/images/reciprocating-saw.webp +0 -0
  71. package/dist/assets/demo-data/assets/images/rivet.webp +0 -0
  72. package/dist/assets/demo-data/assets/images/roof-flashing-high-pitch.webp +0 -0
  73. package/dist/assets/demo-data/assets/images/roof-flashing-low-pitch.webp +0 -0
  74. package/dist/assets/demo-data/assets/images/safety-boots.webp +0 -0
  75. package/dist/assets/demo-data/assets/images/safety-glasses.webp +0 -0
  76. package/dist/assets/demo-data/assets/images/safety-harness.webp +0 -0
  77. package/dist/assets/demo-data/assets/images/safety-helmet.webp +0 -0
  78. package/dist/assets/demo-data/assets/images/safety-sign.webp +0 -0
  79. package/dist/assets/demo-data/assets/images/sanding-disc.webp +0 -0
  80. package/dist/assets/demo-data/assets/images/saw-blade.webp +0 -0
  81. package/dist/assets/demo-data/assets/images/screwdriver-set.webp +0 -0
  82. package/dist/assets/demo-data/assets/images/screwdriver.webp +0 -0
  83. package/dist/assets/demo-data/assets/images/self-tapping-screw.webp +0 -0
  84. package/dist/assets/demo-data/assets/images/socket-set.webp +0 -0
  85. package/dist/assets/demo-data/assets/images/socket.webp +0 -0
  86. package/dist/assets/demo-data/assets/images/spanner-set.webp +0 -0
  87. package/dist/assets/demo-data/assets/images/spanner.webp +0 -0
  88. package/dist/assets/demo-data/assets/images/spill-kit.webp +0 -0
  89. package/dist/assets/demo-data/assets/images/spirit-level.webp +0 -0
  90. package/dist/assets/demo-data/assets/images/switch.webp +0 -0
  91. package/dist/assets/demo-data/assets/images/tape-measure.webp +0 -0
  92. package/dist/assets/demo-data/assets/images/terminal-block.webp +0 -0
  93. package/dist/assets/demo-data/assets/images/threaded-rod.webp +0 -0
  94. package/dist/assets/demo-data/assets/images/torque-wrench.webp +0 -0
  95. package/dist/assets/demo-data/assets/images/utility-knife.webp +0 -0
  96. package/dist/assets/demo-data/assets/images/wall-pass-sleeve.webp +0 -0
  97. package/dist/assets/demo-data/assets/images/wall-plug.webp +0 -0
  98. package/dist/assets/demo-data/assets/images/washer.webp +0 -0
  99. package/dist/assets/demo-data/assets/images/waste-bin.webp +0 -0
  100. package/dist/assets/demo-data/assets/images/wire-connector.webp +0 -0
  101. package/dist/assets/demo-data/assets/images/wire-cutters.webp +0 -0
  102. package/dist/assets/demo-data/assets/images/wood-screw.webp +0 -0
  103. package/dist/assets/demo-data/assets/images/work-gloves.webp +0 -0
  104. package/dist/assets/demo-data/assets/images/work-light.webp +0 -0
  105. package/dist/assets/demo-data/assets/initial-data.json +667 -0
  106. package/dist/assets/demo-data/assets/products.csv +779 -0
  107. package/dist/assets/storefront/.env.example +7 -0
  108. package/dist/assets/storefront/next-env.d.ts +2 -1
  109. package/dist/assets/storefront/next.config.ts +14 -13
  110. package/dist/assets/storefront/package.json +3 -2
  111. package/dist/assets/storefront/src/app/[locale]/account/addresses/page.tsx +2 -0
  112. package/dist/assets/storefront/src/app/[locale]/account/approvals/page.tsx +2 -0
  113. package/dist/assets/storefront/src/app/[locale]/account/company/business-units/page.tsx +2 -0
  114. package/dist/assets/storefront/src/app/[locale]/account/company/page.tsx +2 -0
  115. package/dist/assets/storefront/src/app/[locale]/account/company/payment-methods/page.tsx +2 -0
  116. package/dist/assets/storefront/src/app/[locale]/account/company/roles/page.tsx +2 -0
  117. package/dist/assets/storefront/src/app/[locale]/account/company/users/page.tsx +2 -0
  118. package/dist/assets/storefront/src/app/[locale]/account/profile/page.tsx +2 -0
  119. package/dist/assets/storefront/src/app/[locale]/account/quick-order/page.tsx +2 -0
  120. package/dist/assets/storefront/src/app/[locale]/account/quotes/[id]/page.tsx +10 -6
  121. package/dist/assets/storefront/src/app/[locale]/cart/order-summary.tsx +8 -5
  122. package/dist/assets/storefront/src/app/[locale]/cart/promotion-code.tsx +9 -6
  123. package/dist/assets/storefront/src/app/[locale]/checkout/page.tsx +2 -0
  124. package/dist/assets/storefront/src/app/[locale]/layout.tsx +3 -3
  125. package/dist/assets/storefront/src/app/[locale]/order-confirmation/[code]/order-confirmation.tsx +2 -60
  126. package/dist/assets/storefront/src/components/commerce/product-card-list.tsx +4 -2
  127. package/dist/assets/storefront/src/components/commerce/product-card.tsx +4 -2
  128. package/dist/assets/storefront/src/components/commerce/quote-action-buttons.tsx +31 -14
  129. package/dist/assets/storefront/src/config/capabilities.ts +49 -0
  130. package/dist/assets/storefront/src/config/scenario.ts +10 -0
  131. package/dist/assets/storefront/src/graphql-env.d.ts +47 -41
  132. package/dist/assets/storefront/src/i18n/server.ts +33 -5
  133. package/dist/assets/storefront/src/lib/backend-health.ts +11 -3
  134. package/dist/assets/storefront/src/lib/vendure/api.ts +7 -4
  135. package/dist/assets/storefront/src/lib/vendure/capability-documents.ts +252 -0
  136. package/dist/assets/storefront/src/lib/vendure/capability-drift.ts +187 -0
  137. package/dist/assets/storefront/src/lib/vendure/capability-schema.ts +358 -0
  138. package/dist/assets/storefront/src/lib/vendure/capability-ui.ts +15 -0
  139. package/dist/assets/storefront/src/lib/vendure/mutations.ts +835 -532
  140. package/dist/assets/storefront/src/lib/vendure/queries.ts +862 -519
  141. package/dist/assets/storefront/src/proxy.ts +11 -0
  142. package/dist/assets/storefront/src/schema.graphql +337 -243
  143. package/dist/assets/templates/CLAUDE.md.hbs +1 -0
  144. package/dist/assets/templates/README.md.hbs +24 -1
  145. package/dist/assets/templates/docker-compose.yml.hbs +4 -0
  146. package/dist/assets/templates/env.hbs +34 -9
  147. package/dist/assets/templates/environment.d.ts.hbs +3 -0
  148. package/dist/assets/templates/gitignore.hbs +7 -4
  149. package/dist/assets/templates/index.ts.hbs +1 -1
  150. package/dist/assets/templates/migrate.ts.hbs +55 -0
  151. package/dist/assets/templates/npmrc.hbs +7 -2
  152. package/dist/assets/templates/platform-edition.ts.hbs +23 -0
  153. package/dist/assets/templates/populate.ts.hbs +3 -3
  154. package/dist/assets/templates/server-package.json.hbs +15 -8
  155. package/dist/assets/templates/vendure-config.ts.hbs +25 -2
  156. package/dist/assets/templates/vite.config.mts.hbs +5 -0
  157. package/dist/bin/run.js +1 -1
  158. package/dist/cli.js +1 -1
  159. package/dist/console-onboarding.d.ts +44 -0
  160. package/dist/console-onboarding.js +1 -0
  161. package/dist/hub-projects.d.ts +48 -0
  162. package/dist/hub-projects.js +1 -0
  163. package/dist/plugin-registry.d.ts +65 -5
  164. package/dist/plugin-registry.js +1 -1
  165. package/dist/registry.d.ts +16 -13
  166. package/dist/registry.js +1 -1
  167. package/dist/scaffold-shared.js +1 -1
  168. package/dist/scaffold-storefront-only.js +1 -1
  169. package/dist/scaffolder.js +1 -1
  170. package/dist/storefront-copier.d.ts +9 -2
  171. package/dist/storefront-copier.js +1 -1
  172. package/dist/template-renderer.d.ts +31 -9
  173. package/dist/template-renderer.js +1 -1
  174. package/dist/types.d.ts +75 -24
  175. package/package.json +14 -2
  176. package/dist/assets/storefront/src/middleware.ts +0 -11
@@ -1,16 +1,44 @@
1
1
  import { hasLocale } from 'next-intl';
2
- import { getLocale } from 'next-intl/server';
2
+ import { getLocale, setRequestLocale } from 'next-intl/server';
3
+ import { locale as rootLocale } from 'next/root-params';
3
4
  import { routing, type Locale } from './routing';
4
5
 
5
6
  /**
6
- * Resolve the current locale from request context with validation.
7
+ * Resolve the current locale from the `[locale]` root param, with validation.
8
+ * Falls back to defaultLocale if the segment isn't in routing config.
9
+ *
10
+ * This is the default on the server, including 'use cache' scopes. Root params
11
+ * carry no request state, so a component that resolves the locale this way
12
+ * still prerenders.
13
+ */
14
+ export async function getRootLocale(): Promise<Locale> {
15
+ const loc = await rootLocale();
16
+ return hasLocale(routing.locales, loc) ? (loc as Locale) : routing.defaultLocale;
17
+ }
18
+
19
+ /**
20
+ * Resolve the current locale from request context, with validation.
7
21
  * Falls back to defaultLocale if the resolved locale isn't in routing config.
8
22
  *
9
- * Use this in non-cached server components and server actions.
10
- * For 'use cache' scopes or route files (layout/page), prefer rootLocale()
11
- * from 'next/root-params' directly (the SWC transform only works in route files).
23
+ * Only for scopes root params cannot reach: Client Components, Server Actions
24
+ * and Route Handlers, plus server code that already renders dynamically.
25
+ * Reading request state blocks prerendering, so a Server Component that calls
26
+ * this serves no static shell — call `getRootLocale()` there instead.
12
27
  */
13
28
  export async function getRouteLocale(): Promise<Locale> {
14
29
  const loc = await getLocale();
15
30
  return hasLocale(routing.locales, loc) ? (loc as Locale) : routing.defaultLocale;
16
31
  }
32
+
33
+ /**
34
+ * Pin the `[locale]` root param as next-intl's request locale.
35
+ *
36
+ * Call this before the first next-intl call in a Server Component that does not
37
+ * already pin the locale from `params`. Without it, `getTranslations()` resolves
38
+ * the locale through next-intl's request scope, which under `cacheComponents` is
39
+ * uncached request state: the route then fails `next build --debug-prerender`
40
+ * and serves no static shell.
41
+ */
42
+ export async function pinRootLocale(): Promise<void> {
43
+ setRequestLocale(await getRootLocale());
44
+ }
@@ -10,12 +10,20 @@ const HEALTH_CHECK_TIMEOUT_MS = 3000;
10
10
  * on Vercel) but every API call fails, leaving the user with a branded but
11
11
  * empty shell. When this returns false we render a "Demo Paused" page instead.
12
12
  *
13
- * Cached for 15s (cacheLife('seconds')) so a stop/start of the backend is
14
- * reflected quickly without hammering the API on every request.
13
+ * This is cacheLife('seconds') with one field changed: `expire` goes from 60 to
14
+ * 300. The preset's 60s expire is too short for Next 16.3's build-time cache,
15
+ * which drops the entry between the cache-warming phase and the final prerender
16
+ * phase, and every prerendered page that mounts this gate then logs an
17
+ * "Unexpected cache miss" during `next build`. Measured on 16.3: expire 300 is
18
+ * retained, 120 is not.
19
+ *
20
+ * `stale` and `revalidate` keep the preset's values, so how fast a stop/start of
21
+ * the backend is picked up is unchanged. `expire` only bounds how long a stale
22
+ * value may be served when revalidation never runs.
15
23
  */
16
24
  export async function isBackendReachable(): Promise<boolean> {
17
25
  'use cache';
18
- cacheLife('seconds');
26
+ cacheLife({ stale: 30, revalidate: 1, expire: 300 });
19
27
 
20
28
  const apiUrl = process.env.VENDURE_SHOP_API_URL || process.env.NEXT_PUBLIC_VENDURE_SHOP_API_URL;
21
29
  if (!apiUrl) return true;
@@ -1,6 +1,8 @@
1
1
  import type { RequestMiddleware } from '@vendure-platform/storefront-client';
2
2
  import { createClient } from '@vendure-platform/storefront-client';
3
3
 
4
+ import { capabilityDriftMiddleware } from './capability-drift';
5
+
4
6
  const DRAFT_MODE_SECRET = process.env.DRAFT_MODE_SECRET;
5
7
 
6
8
  /**
@@ -119,7 +121,7 @@ const currencyMiddleware: RequestMiddleware = async (req, next) => {
119
121
  return next(req);
120
122
  };
121
123
 
122
- const middleware = [draftModeMiddleware, currencyMiddleware];
124
+ const middleware = [draftModeMiddleware, currencyMiddleware, capabilityDriftMiddleware];
123
125
 
124
126
  export const client = createClient({
125
127
  ...sharedConfig,
@@ -138,12 +140,13 @@ export const client = createClient({
138
140
  });
139
141
 
140
142
  /**
141
- * Client without providers or middleware — for 'use cache' functions that fetch public data.
143
+ * Client without providers — for 'use cache' functions that fetch public data.
142
144
  * Next.js 'use cache' forbids Dynamic APIs (cookies/headers), so this client must not
143
- * access cookies, headers, or draftMode. Pass languageCode and currencyCode explicitly
145
+ * access cookies, headers, or draftMode. Its one middleware, the capability drift
146
+ * check, reads only the response. Pass languageCode and currencyCode explicitly
144
147
  * as query options from the calling function.
145
148
  */
146
- export const publicClient = createClient({ ...sharedConfig });
149
+ export const publicClient = createClient({ ...sharedConfig, middleware: [capabilityDriftMiddleware] });
147
150
 
148
151
  /**
149
152
  * Pre-resolved context for use at cache boundaries where cookies() is not available.
@@ -0,0 +1,252 @@
1
+ import { hasCapability } from '@/config/capabilities';
2
+ import type {
3
+ DefinitionNode,
4
+ DocumentNode,
5
+ FieldNode,
6
+ FragmentDefinitionNode,
7
+ SelectionNode,
8
+ SelectionSetNode,
9
+ } from 'graphql';
10
+ import { visit } from 'graphql';
11
+
12
+ /**
13
+ * A selection to drop, written as the path of field names from the root of the
14
+ * operation or fragment definition that holds it: for example
15
+ * `['product', 'customFields', 'configuratorSystemCode']` in an operation, or
16
+ * `['isPublic']` inside `fragment ShoppingListFields`. Every path is tried on
17
+ * every definition, so a path only matches in the definitions it starts in.
18
+ */
19
+ export type FieldPath = readonly string[];
20
+
21
+ export interface CapabilityDocumentRule {
22
+ capability: string;
23
+ paths: readonly FieldPath[];
24
+ }
25
+
26
+ export interface CapabilityDocumentRegistration<T extends DocumentNode = DocumentNode> {
27
+ name: string;
28
+ fullDocument: T;
29
+ rules: readonly CapabilityDocumentRule[];
30
+ }
31
+
32
+ /**
33
+ * Capability-owned storefront documents, used by the schema composition tests
34
+ * and by the drift check in `capability-drift.ts`.
35
+ */
36
+ export const STOREFRONT_CAPABILITY_DOCUMENTS: CapabilityDocumentRegistration[] = [];
37
+
38
+ function isField(selection: SelectionNode): selection is FieldNode {
39
+ return selection.kind === 'Field';
40
+ }
41
+
42
+ function fieldName(field: FieldNode): string {
43
+ return field.name.value;
44
+ }
45
+
46
+ function addOperationFallback<T extends DocumentNode>(document: T): T {
47
+ return {
48
+ ...document,
49
+ definitions: document.definitions.map((definition) => {
50
+ if (definition.kind !== 'OperationDefinition') return definition;
51
+ if (
52
+ definition.selectionSet.selections.some(
53
+ (selection) => isField(selection) && fieldName(selection) === '__typename',
54
+ )
55
+ ) {
56
+ return definition;
57
+ }
58
+ return {
59
+ ...definition,
60
+ selectionSet: {
61
+ ...definition.selectionSet,
62
+ selections: [
63
+ ...definition.selectionSet.selections,
64
+ { kind: 'Field', name: { kind: 'Name', value: '__typename' } },
65
+ ],
66
+ },
67
+ };
68
+ }),
69
+ } as T;
70
+ }
71
+
72
+ function fragmentSpreads(selectionSet: SelectionSetNode): string[] {
73
+ const names: string[] = [];
74
+ for (const selection of selectionSet.selections) {
75
+ if (selection.kind === 'FragmentSpread') {
76
+ names.push(selection.name.value);
77
+ } else if (selection.kind === 'Field' && selection.selectionSet) {
78
+ names.push(...fragmentSpreads(selection.selectionSet));
79
+ } else if (selection.kind === 'InlineFragment') {
80
+ names.push(...fragmentSpreads(selection.selectionSet));
81
+ }
82
+ }
83
+ return names;
84
+ }
85
+
86
+ function removeUnusedFragments(definitions: readonly DefinitionNode[]): readonly DefinitionNode[] {
87
+ const fragments = new Map<string, FragmentDefinitionNode>();
88
+ for (const definition of definitions) {
89
+ if (definition.kind === 'FragmentDefinition') {
90
+ fragments.set(definition.name.value, definition);
91
+ }
92
+ }
93
+
94
+ const used = new Set<string>();
95
+ const pending = definitions
96
+ .filter((definition) => definition.kind === 'OperationDefinition')
97
+ .flatMap((definition) => fragmentSpreads(definition.selectionSet));
98
+ while (pending.length > 0) {
99
+ const name = pending.pop()!;
100
+ if (used.has(name)) continue;
101
+ used.add(name);
102
+ const fragment = fragments.get(name);
103
+ if (fragment) pending.push(...fragmentSpreads(fragment.selectionSet));
104
+ }
105
+
106
+ return definitions.filter(
107
+ (definition) => definition.kind !== 'FragmentDefinition' || used.has(definition.name.value),
108
+ );
109
+ }
110
+
111
+ function removeUnusedVariables(definitions: readonly DefinitionNode[]): readonly DefinitionNode[] {
112
+ const used = new Set<string>();
113
+ visit({ kind: 'Document', definitions } as DocumentNode, {
114
+ VariableDefinition() {
115
+ return false;
116
+ },
117
+ Variable(node) {
118
+ used.add(node.name.value);
119
+ },
120
+ });
121
+
122
+ return definitions.map((definition) => {
123
+ if (definition.kind !== 'OperationDefinition' || !definition.variableDefinitions) {
124
+ return definition;
125
+ }
126
+ return {
127
+ ...definition,
128
+ variableDefinitions: definition.variableDefinitions.filter((variable) =>
129
+ used.has(variable.variable.name.value),
130
+ ),
131
+ };
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Returns `selectionSet` with `path` removed, or `null` when removing it would
137
+ * leave an empty selection set — GraphQL has no syntax for one, so the caller
138
+ * drops the parent field instead.
139
+ */
140
+ function stripPath(selectionSet: SelectionSetNode, path: FieldPath): SelectionSetNode | null {
141
+ const [head, ...rest] = path;
142
+ const selections: SelectionNode[] = [];
143
+
144
+ for (const selection of selectionSet.selections) {
145
+ if (!isField(selection) || fieldName(selection) !== head) {
146
+ selections.push(selection);
147
+ continue;
148
+ }
149
+ if (rest.length === 0) {
150
+ // Leaf reached: drop it by not re-adding it.
151
+ continue;
152
+ }
153
+ if (!selection.selectionSet) {
154
+ selections.push(selection);
155
+ continue;
156
+ }
157
+ const nested = stripPath(selection.selectionSet, rest);
158
+ if (nested) {
159
+ selections.push({ ...selection, selectionSet: nested });
160
+ }
161
+ }
162
+
163
+ return selections.length > 0 ? { ...selectionSet, selections } : null;
164
+ }
165
+
166
+ /**
167
+ * Removes capability-gated selections from a GraphQL document.
168
+ *
169
+ * The server composes its schema from the capability modules the deployment is
170
+ * entitled to, so selecting a field owned by an absent module fails validation
171
+ * on every request. Rather than maintaining a hand-written copy of each
172
+ * document per composition, the full document stays the single typed source of
173
+ * truth and the selections a composition cannot serve are pruned from it here.
174
+ *
175
+ * The returned document keeps the input's TypeScript type: a pruned field is
176
+ * `undefined` at runtime, so consumers must treat capability-gated data as
177
+ * optional. That is the same contract they already need for a field the server
178
+ * may legitimately return as null.
179
+ */
180
+ export function stripCapabilityFields<T extends DocumentNode>(document: T, paths: FieldPath[]): T {
181
+ if (paths.length === 0) {
182
+ return document;
183
+ }
184
+ let definitions = document.definitions;
185
+ for (const path of paths) {
186
+ definitions = definitions.map((definition) => {
187
+ if (definition.kind !== 'OperationDefinition' && definition.kind !== 'FragmentDefinition') {
188
+ return definition;
189
+ }
190
+ const stripped = stripPath(definition.selectionSet, path);
191
+ if (!stripped) {
192
+ // Every selection collapsed, which GraphQL cannot express. A
193
+ // prune list that empties a whole operation is a mistake in the
194
+ // capability mapping, not something to paper over by keeping
195
+ // the field that was supposed to be removed.
196
+ throw new Error(
197
+ `Pruning "${path.join('.')}" would leave an empty selection set. ` +
198
+ 'Split the document instead of pruning its only selection.',
199
+ );
200
+ }
201
+ return { ...definition, selectionSet: stripped };
202
+ });
203
+ }
204
+ const usedDefinitions = removeUnusedFragments(definitions);
205
+ return { ...document, definitions: removeUnusedVariables(usedDefinitions) } as T;
206
+ }
207
+
208
+ /**
209
+ * Convenience wrapper: prunes `paths` only when `granted` is false.
210
+ */
211
+ export function whenGranted<T extends DocumentNode>(granted: boolean, document: T, paths: FieldPath[]): T {
212
+ return granted ? document : stripCapabilityFields(addOperationFallback(document), paths);
213
+ }
214
+
215
+ /**
216
+ * Registers and composes one typed storefront document from capability rules.
217
+ * The full typed document remains the source of truth. Missing grants prune
218
+ * only the selections that their modules own.
219
+ */
220
+ export function composeCapabilityDocument<T extends DocumentNode>(
221
+ document: T,
222
+ rules: readonly CapabilityDocumentRule[],
223
+ ): T {
224
+ const operation = document.definitions.find((definition) => definition.kind === 'OperationDefinition');
225
+ const name = operation?.name?.value;
226
+ if (!name) {
227
+ throw new Error('Capability-composed storefront documents must use a named operation.');
228
+ }
229
+ STOREFRONT_CAPABILITY_DOCUMENTS.push({ name, fullDocument: document, rules });
230
+ return composeCapabilityDocumentFor(document, rules, (capability) => hasCapability(capability));
231
+ }
232
+
233
+ /** Compose a registered document for an explicit grant set in tests. */
234
+ export function composeCapabilityDocumentFor<T extends DocumentNode>(
235
+ document: T,
236
+ rules: readonly CapabilityDocumentRule[],
237
+ granted: (capability: string) => boolean,
238
+ ): T {
239
+ return rules.reduce(
240
+ (composed, rule) => whenGranted(granted(rule.capability), composed, [...rule.paths]),
241
+ document,
242
+ );
243
+ }
244
+
245
+ /** Registers a document owned by one capability. */
246
+ export function forCapability<T extends DocumentNode>(
247
+ capability: string,
248
+ document: T,
249
+ paths: readonly FieldPath[],
250
+ ): T {
251
+ return composeCapabilityDocument(document, [{ capability, paths }]);
252
+ }
@@ -0,0 +1,187 @@
1
+ import {
2
+ VendureGraphQLError,
3
+ type GraphQLErrorEntry,
4
+ type RequestMiddleware,
5
+ } from '@vendure-platform/storefront-client';
6
+ import { getLocation, Kind, parse, visit, type ASTNode, type DocumentNode, type FieldNode } from 'graphql';
7
+
8
+ import { hasCapability } from '@/config/capabilities';
9
+
10
+ import {
11
+ STOREFRONT_CAPABILITY_DOCUMENTS,
12
+ type CapabilityDocumentRule,
13
+ type FieldPath,
14
+ } from './capability-documents';
15
+
16
+ /** The code Apollo Server gives every validation failure. It answers them with HTTP 400. */
17
+ const VALIDATION_FAILED = 'GRAPHQL_VALIDATION_FAILED';
18
+
19
+ /** A selection the server rejected that belongs to a capability this build claims. */
20
+ export interface CapabilityDrift {
21
+ capability: string;
22
+ /** From the root of the operation or fragment definition that holds the field. */
23
+ path: FieldPath;
24
+ }
25
+
26
+ interface ErrorLocation {
27
+ line: number;
28
+ column: number;
29
+ }
30
+
31
+ /**
32
+ * Finds the rejected selections that belong to a capability the storefront was
33
+ * built with.
34
+ *
35
+ * Reads structured data only: the validation error code, the error locations,
36
+ * and the document that was sent. Each location is resolved to the field it
37
+ * points at, and that field's path is compared with the capability rules the
38
+ * document was registered with, the same way `stripCapabilityFields` applies
39
+ * them. An error that is not a validation failure, that points at a field whose
40
+ * owning capability the build does not claim, or that comes from an
41
+ * unregistered document is not drift.
42
+ *
43
+ * The check sees one direction only: a build that claims a capability the
44
+ * server does not compose. A build that claims fewer capabilities than the
45
+ * server composes sends valid documents and only hides features, so no response
46
+ * shows it.
47
+ */
48
+ export function findCapabilityDrift(
49
+ query: string,
50
+ errors: readonly GraphQLErrorEntry[],
51
+ granted: (capability: string) => boolean,
52
+ ): CapabilityDrift[] {
53
+ const locations = errors
54
+ .filter(isValidationFailure)
55
+ .flatMap((error) => error.locations as ErrorLocation[]);
56
+ if (locations.length === 0) return [];
57
+
58
+ let document: DocumentNode;
59
+ try {
60
+ document = parse(query);
61
+ } catch {
62
+ return [];
63
+ }
64
+ const operation = document.definitions.find(
65
+ (definition) => definition.kind === Kind.OPERATION_DEFINITION,
66
+ );
67
+ const registration = STOREFRONT_CAPABILITY_DOCUMENTS.find(
68
+ (candidate) => candidate.name === operation?.name?.value,
69
+ );
70
+ if (!registration) return [];
71
+
72
+ const drift: CapabilityDrift[] = [];
73
+ for (const path of rejectedFieldPaths(document, locations)) {
74
+ for (const capability of owners(registration.rules, path)) {
75
+ if (granted(capability)) drift.push({ capability, path });
76
+ }
77
+ }
78
+ return drift;
79
+ }
80
+
81
+ /**
82
+ * The capabilities whose most specific rule path contains `path`.
83
+ *
84
+ * Rules nest: `quote.requests` owns `myQuote` and `quote.negotiation` owns
85
+ * `myQuote.versions`, so a rejected `myQuote.versions` belongs to negotiation
86
+ * only.
87
+ */
88
+ function owners(rules: readonly CapabilityDocumentRule[], path: FieldPath): string[] {
89
+ let longest = 0;
90
+ let found: string[] = [];
91
+ for (const rule of rules) {
92
+ for (const rulePath of rule.paths) {
93
+ if (rulePath.length < longest || !rulePath.every((name, index) => path[index] === name)) continue;
94
+ if (rulePath.length > longest) {
95
+ longest = rulePath.length;
96
+ found = [];
97
+ }
98
+ if (!found.includes(rule.capability)) found.push(rule.capability);
99
+ }
100
+ }
101
+ return found;
102
+ }
103
+
104
+ function isValidationFailure(error: GraphQLErrorEntry): boolean {
105
+ const code = (error.extensions as { code?: unknown } | undefined)?.code;
106
+ return code === VALIDATION_FAILED && Array.isArray(error.locations);
107
+ }
108
+
109
+ /** The field-name path of every field that starts at one of `locations`. */
110
+ function rejectedFieldPaths(document: DocumentNode, locations: readonly ErrorLocation[]): FieldPath[] {
111
+ const paths: FieldPath[] = [];
112
+ visit(document, {
113
+ Field(node, _key, _parent, _path, ancestors) {
114
+ if (!node.loc) return;
115
+ const { line, column } = getLocation(node.loc.source, node.loc.start);
116
+ if (!locations.some((location) => location.line === line && location.column === column)) return;
117
+ const parents = (ancestors as ReadonlyArray<ASTNode | readonly ASTNode[]>).filter(
118
+ (ancestor): ancestor is FieldNode => 'kind' in ancestor && ancestor.kind === Kind.FIELD,
119
+ );
120
+ paths.push([...parents.map((field) => field.name.value), node.name.value]);
121
+ },
122
+ });
123
+ return paths;
124
+ }
125
+
126
+ /**
127
+ * The Shop API rejected a selection because this storefront was built for a
128
+ * capability its server does not compose.
129
+ *
130
+ * Extends the error the client reports for GraphQL errors, and keeps the
131
+ * server's errors unchanged in `errors`.
132
+ */
133
+ export class CapabilityDriftError extends VendureGraphQLError {
134
+ readonly drift: CapabilityDrift[];
135
+
136
+ constructor(errors: GraphQLErrorEntry[], response: Response, drift: CapabilityDrift[]) {
137
+ super(errors, response);
138
+ // The base class types `name` as its own literal, so a field override
139
+ // does not compile. Replace the value it set instead.
140
+ Object.defineProperty(this, 'name', { value: 'CapabilityDriftError' });
141
+ this.drift = drift;
142
+ const capabilities = [...new Set(drift.map((entry) => entry.capability))];
143
+ const fields = drift.map((entry) => entry.path.join('.'));
144
+ const subject = capabilities.length === 1 ? 'that capability' : 'those capabilities';
145
+ const serverErrors = errors.map((error) => error.message).join(', ');
146
+ this.message =
147
+ `Storefront capability drift: this storefront was built with NEXT_PUBLIC_VENDURE_CAPABILITIES ` +
148
+ `granting ${capabilities.join(', ')}, but the Shop API rejected ${fields.join(', ')}. ` +
149
+ `The server does not compose ${subject}. Rebuild the storefront with the capability keys ` +
150
+ `of the server's capability profile. Server errors: ${serverErrors}`;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Reports a validation failure on a claimed capability's field as a
156
+ * {@link CapabilityDriftError}.
157
+ *
158
+ * Apollo Server answers a validation failure with HTTP 400, which the client
159
+ * otherwise reports only as "HTTP error 400". Only those responses are read
160
+ * here. Every other response passes through untouched.
161
+ */
162
+ export const capabilityDriftMiddleware: RequestMiddleware = async (request, next) => {
163
+ const response = await next(request);
164
+ if (response.status !== 400) return response;
165
+
166
+ // A body can be read once. Middleware outside this one gets the same read,
167
+ // including its failure when the body is not JSON.
168
+ const body = response.json();
169
+ const payload = (await body.catch(() => undefined)) as { errors?: GraphQLErrorEntry[] } | undefined;
170
+ const query = sentQuery(request.body);
171
+ const errors = payload?.errors ?? [];
172
+ const drift = query === undefined ? [] : findCapabilityDrift(query, errors, hasCapability);
173
+ if (drift.length > 0) {
174
+ throw new CapabilityDriftError(errors, response.response, drift);
175
+ }
176
+ return { ...response, json: () => body };
177
+ };
178
+
179
+ /** The GraphQL document in a request body, when the body is the JSON the client sends. */
180
+ function sentQuery(body: string): string | undefined {
181
+ try {
182
+ const { query } = JSON.parse(body) as { query?: unknown };
183
+ return typeof query === 'string' ? query : undefined;
184
+ } catch {
185
+ return undefined;
186
+ }
187
+ }