@vendure-platform/create 0.5.1 → 1.0.0-major.202609091053.0.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 (172) hide show
  1. package/README.md +48 -36
  2. package/dist/add-to-existing.d.ts +19 -13
  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/next-env.d.ts +2 -1
  108. package/dist/assets/storefront/next.config.ts +14 -13
  109. package/dist/assets/storefront/package.json +3 -2
  110. package/dist/assets/storefront/src/app/[locale]/account/addresses/page.tsx +2 -0
  111. package/dist/assets/storefront/src/app/[locale]/account/approvals/page.tsx +2 -0
  112. package/dist/assets/storefront/src/app/[locale]/account/company/business-units/page.tsx +2 -0
  113. package/dist/assets/storefront/src/app/[locale]/account/company/page.tsx +2 -0
  114. package/dist/assets/storefront/src/app/[locale]/account/company/payment-methods/page.tsx +2 -0
  115. package/dist/assets/storefront/src/app/[locale]/account/company/roles/page.tsx +2 -0
  116. package/dist/assets/storefront/src/app/[locale]/account/company/users/page.tsx +2 -0
  117. package/dist/assets/storefront/src/app/[locale]/account/profile/page.tsx +2 -0
  118. package/dist/assets/storefront/src/app/[locale]/account/quick-order/page.tsx +2 -0
  119. package/dist/assets/storefront/src/app/[locale]/account/quotes/[id]/page.tsx +10 -6
  120. package/dist/assets/storefront/src/app/[locale]/cart/order-summary.tsx +8 -5
  121. package/dist/assets/storefront/src/app/[locale]/cart/promotion-code.tsx +9 -6
  122. package/dist/assets/storefront/src/app/[locale]/checkout/page.tsx +2 -0
  123. package/dist/assets/storefront/src/app/[locale]/layout.tsx +3 -3
  124. package/dist/assets/storefront/src/app/[locale]/order-confirmation/[code]/order-confirmation.tsx +2 -60
  125. package/dist/assets/storefront/src/components/commerce/product-card-list.tsx +4 -2
  126. package/dist/assets/storefront/src/components/commerce/product-card.tsx +4 -2
  127. package/dist/assets/storefront/src/components/commerce/quote-action-buttons.tsx +31 -14
  128. package/dist/assets/storefront/src/config/capabilities.ts +41 -0
  129. package/dist/assets/storefront/src/config/scenario.ts +10 -0
  130. package/dist/assets/storefront/src/graphql-env.d.ts +47 -41
  131. package/dist/assets/storefront/src/i18n/server.ts +33 -5
  132. package/dist/assets/storefront/src/lib/backend-health.ts +11 -3
  133. package/dist/assets/storefront/src/lib/vendure/capability-documents.ts +246 -0
  134. package/dist/assets/storefront/src/lib/vendure/capability-schema.ts +358 -0
  135. package/dist/assets/storefront/src/lib/vendure/capability-ui.ts +15 -0
  136. package/dist/assets/storefront/src/lib/vendure/mutations.ts +835 -532
  137. package/dist/assets/storefront/src/lib/vendure/queries.ts +862 -519
  138. package/dist/assets/storefront/src/proxy.ts +11 -0
  139. package/dist/assets/storefront/src/schema.graphql +337 -243
  140. package/dist/assets/templates/README.md.hbs +25 -1
  141. package/dist/assets/templates/docker-compose.yml.hbs +4 -0
  142. package/dist/assets/templates/env.hbs +34 -9
  143. package/dist/assets/templates/environment.d.ts.hbs +1 -0
  144. package/dist/assets/templates/gitignore.hbs +7 -4
  145. package/dist/assets/templates/migrate.ts.hbs +55 -0
  146. package/dist/assets/templates/npmrc.hbs +7 -2
  147. package/dist/assets/templates/platform-edition.ts.hbs +21 -0
  148. package/dist/assets/templates/server-package.json.hbs +15 -8
  149. package/dist/assets/templates/vendure-config.ts.hbs +18 -0
  150. package/dist/bin/run.js +1 -1
  151. package/dist/cli.js +1 -1
  152. package/dist/console-onboarding.d.ts +44 -0
  153. package/dist/console-onboarding.js +1 -0
  154. package/dist/hub-projects.d.ts +48 -0
  155. package/dist/hub-projects.js +1 -0
  156. package/dist/index.d.ts +1 -1
  157. package/dist/index.js +1 -1
  158. package/dist/plugin-registry.d.ts +61 -2
  159. package/dist/plugin-registry.js +1 -1
  160. package/dist/registry.d.ts +16 -13
  161. package/dist/registry.js +1 -1
  162. package/dist/scaffold-shared.js +1 -1
  163. package/dist/scaffold-storefront-only.js +1 -1
  164. package/dist/scaffolder.d.ts +11 -0
  165. package/dist/scaffolder.js +1 -1
  166. package/dist/storefront-copier.d.ts +7 -2
  167. package/dist/storefront-copier.js +1 -1
  168. package/dist/template-renderer.d.ts +26 -5
  169. package/dist/template-renderer.js +1 -1
  170. package/dist/types.d.ts +54 -23
  171. package/package.json +14 -2
  172. 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;
@@ -0,0 +1,246 @@
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 operation
14
+ * root, e.g. `['product', 'customFields', 'configuratorSystemCode']`.
15
+ */
16
+ export type FieldPath = readonly string[];
17
+
18
+ export interface CapabilityDocumentRule {
19
+ capability: string;
20
+ paths: readonly FieldPath[];
21
+ }
22
+
23
+ export interface CapabilityDocumentRegistration<T extends DocumentNode = DocumentNode> {
24
+ name: string;
25
+ fullDocument: T;
26
+ rules: readonly CapabilityDocumentRule[];
27
+ }
28
+
29
+ /** Capability-owned storefront documents, used by the schema composition tests. */
30
+ export const STOREFRONT_CAPABILITY_DOCUMENTS: CapabilityDocumentRegistration[] = [];
31
+
32
+ function isField(selection: SelectionNode): selection is FieldNode {
33
+ return selection.kind === 'Field';
34
+ }
35
+
36
+ function fieldName(field: FieldNode): string {
37
+ return field.name.value;
38
+ }
39
+
40
+ function addOperationFallback<T extends DocumentNode>(document: T): T {
41
+ return {
42
+ ...document,
43
+ definitions: document.definitions.map((definition) => {
44
+ if (definition.kind !== 'OperationDefinition') return definition;
45
+ if (
46
+ definition.selectionSet.selections.some(
47
+ (selection) => isField(selection) && fieldName(selection) === '__typename',
48
+ )
49
+ ) {
50
+ return definition;
51
+ }
52
+ return {
53
+ ...definition,
54
+ selectionSet: {
55
+ ...definition.selectionSet,
56
+ selections: [
57
+ ...definition.selectionSet.selections,
58
+ { kind: 'Field', name: { kind: 'Name', value: '__typename' } },
59
+ ],
60
+ },
61
+ };
62
+ }),
63
+ } as T;
64
+ }
65
+
66
+ function fragmentSpreads(selectionSet: SelectionSetNode): string[] {
67
+ const names: string[] = [];
68
+ for (const selection of selectionSet.selections) {
69
+ if (selection.kind === 'FragmentSpread') {
70
+ names.push(selection.name.value);
71
+ } else if (selection.kind === 'Field' && selection.selectionSet) {
72
+ names.push(...fragmentSpreads(selection.selectionSet));
73
+ } else if (selection.kind === 'InlineFragment') {
74
+ names.push(...fragmentSpreads(selection.selectionSet));
75
+ }
76
+ }
77
+ return names;
78
+ }
79
+
80
+ function removeUnusedFragments(definitions: readonly DefinitionNode[]): readonly DefinitionNode[] {
81
+ const fragments = new Map<string, FragmentDefinitionNode>();
82
+ for (const definition of definitions) {
83
+ if (definition.kind === 'FragmentDefinition') {
84
+ fragments.set(definition.name.value, definition);
85
+ }
86
+ }
87
+
88
+ const used = new Set<string>();
89
+ const pending = definitions
90
+ .filter((definition) => definition.kind === 'OperationDefinition')
91
+ .flatMap((definition) => fragmentSpreads(definition.selectionSet));
92
+ while (pending.length > 0) {
93
+ const name = pending.pop()!;
94
+ if (used.has(name)) continue;
95
+ used.add(name);
96
+ const fragment = fragments.get(name);
97
+ if (fragment) pending.push(...fragmentSpreads(fragment.selectionSet));
98
+ }
99
+
100
+ return definitions.filter(
101
+ (definition) => definition.kind !== 'FragmentDefinition' || used.has(definition.name.value),
102
+ );
103
+ }
104
+
105
+ function removeUnusedVariables(definitions: readonly DefinitionNode[]): readonly DefinitionNode[] {
106
+ const used = new Set<string>();
107
+ visit({ kind: 'Document', definitions } as DocumentNode, {
108
+ VariableDefinition() {
109
+ return false;
110
+ },
111
+ Variable(node) {
112
+ used.add(node.name.value);
113
+ },
114
+ });
115
+
116
+ return definitions.map((definition) => {
117
+ if (definition.kind !== 'OperationDefinition' || !definition.variableDefinitions) {
118
+ return definition;
119
+ }
120
+ return {
121
+ ...definition,
122
+ variableDefinitions: definition.variableDefinitions.filter((variable) =>
123
+ used.has(variable.variable.name.value),
124
+ ),
125
+ };
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Returns `selectionSet` with `path` removed, or `null` when removing it would
131
+ * leave an empty selection set — GraphQL has no syntax for one, so the caller
132
+ * drops the parent field instead.
133
+ */
134
+ function stripPath(selectionSet: SelectionSetNode, path: FieldPath): SelectionSetNode | null {
135
+ const [head, ...rest] = path;
136
+ const selections: SelectionNode[] = [];
137
+
138
+ for (const selection of selectionSet.selections) {
139
+ if (!isField(selection) || fieldName(selection) !== head) {
140
+ selections.push(selection);
141
+ continue;
142
+ }
143
+ if (rest.length === 0) {
144
+ // Leaf reached: drop it by not re-adding it.
145
+ continue;
146
+ }
147
+ if (!selection.selectionSet) {
148
+ selections.push(selection);
149
+ continue;
150
+ }
151
+ const nested = stripPath(selection.selectionSet, rest);
152
+ if (nested) {
153
+ selections.push({ ...selection, selectionSet: nested });
154
+ }
155
+ }
156
+
157
+ return selections.length > 0 ? { ...selectionSet, selections } : null;
158
+ }
159
+
160
+ /**
161
+ * Removes capability-gated selections from a GraphQL document.
162
+ *
163
+ * The server composes its schema from the capability modules the deployment is
164
+ * entitled to, so selecting a field owned by an absent module fails validation
165
+ * on every request. Rather than maintaining a hand-written copy of each
166
+ * document per composition, the full document stays the single typed source of
167
+ * truth and the selections a composition cannot serve are pruned from it here.
168
+ *
169
+ * The returned document keeps the input's TypeScript type: a pruned field is
170
+ * `undefined` at runtime, so consumers must treat capability-gated data as
171
+ * optional. That is the same contract they already need for a field the server
172
+ * may legitimately return as null.
173
+ */
174
+ export function stripCapabilityFields<T extends DocumentNode>(document: T, paths: FieldPath[]): T {
175
+ if (paths.length === 0) {
176
+ return document;
177
+ }
178
+ let definitions = document.definitions;
179
+ for (const path of paths) {
180
+ definitions = definitions.map((definition) => {
181
+ if (definition.kind !== 'OperationDefinition' && definition.kind !== 'FragmentDefinition') {
182
+ return definition;
183
+ }
184
+ const stripped = stripPath(definition.selectionSet, path);
185
+ if (!stripped) {
186
+ // Every selection collapsed, which GraphQL cannot express. A
187
+ // prune list that empties a whole operation is a mistake in the
188
+ // capability mapping, not something to paper over by keeping
189
+ // the field that was supposed to be removed.
190
+ throw new Error(
191
+ `Pruning "${path.join('.')}" would leave an empty selection set. ` +
192
+ 'Split the document instead of pruning its only selection.',
193
+ );
194
+ }
195
+ return { ...definition, selectionSet: stripped };
196
+ });
197
+ }
198
+ const usedDefinitions = removeUnusedFragments(definitions);
199
+ return { ...document, definitions: removeUnusedVariables(usedDefinitions) } as T;
200
+ }
201
+
202
+ /**
203
+ * Convenience wrapper: prunes `paths` only when `granted` is false.
204
+ */
205
+ export function whenGranted<T extends DocumentNode>(granted: boolean, document: T, paths: FieldPath[]): T {
206
+ return granted ? document : stripCapabilityFields(addOperationFallback(document), paths);
207
+ }
208
+
209
+ /**
210
+ * Registers and composes one typed storefront document from capability rules.
211
+ * The full typed document remains the source of truth. Missing grants prune
212
+ * only the selections that their modules own.
213
+ */
214
+ export function composeCapabilityDocument<T extends DocumentNode>(
215
+ document: T,
216
+ rules: readonly CapabilityDocumentRule[],
217
+ ): T {
218
+ const operation = document.definitions.find((definition) => definition.kind === 'OperationDefinition');
219
+ const name = operation?.name?.value;
220
+ if (!name) {
221
+ throw new Error('Capability-composed storefront documents must use a named operation.');
222
+ }
223
+ STOREFRONT_CAPABILITY_DOCUMENTS.push({ name, fullDocument: document, rules });
224
+ return composeCapabilityDocumentFor(document, rules, (capability) => hasCapability(capability));
225
+ }
226
+
227
+ /** Compose a registered document for an explicit grant set in tests. */
228
+ export function composeCapabilityDocumentFor<T extends DocumentNode>(
229
+ document: T,
230
+ rules: readonly CapabilityDocumentRule[],
231
+ granted: (capability: string) => boolean,
232
+ ): T {
233
+ return rules.reduce(
234
+ (composed, rule) => whenGranted(granted(rule.capability), composed, [...rule.paths]),
235
+ document,
236
+ );
237
+ }
238
+
239
+ /** Registers a document owned by one capability. */
240
+ export function forCapability<T extends DocumentNode>(
241
+ capability: string,
242
+ document: T,
243
+ paths: readonly FieldPath[],
244
+ ): T {
245
+ return composeCapabilityDocument(document, [{ capability, paths }]);
246
+ }
@@ -0,0 +1,358 @@
1
+ import {
2
+ buildSchema,
3
+ Kind,
4
+ parse,
5
+ print,
6
+ type DefinitionNode,
7
+ type DocumentNode,
8
+ type GraphQLSchema,
9
+ type InputObjectTypeDefinitionNode,
10
+ type InterfaceTypeDefinitionNode,
11
+ type ObjectTypeDefinitionNode,
12
+ } from 'graphql';
13
+
14
+ /** A capability-owned type, or a field on an otherwise shared type. */
15
+ export interface SchemaContribution {
16
+ /** Whole types the module adds to the schema. */
17
+ types?: readonly string[];
18
+ /** Fields the module adds to a type it shares with other modules. */
19
+ fields?: Readonly<Record<string, readonly string[]>>;
20
+ }
21
+
22
+ /**
23
+ * The two configurator schema sections, mirroring
24
+ * `libs/configurator/src/api/api-extensions.ts`. Removing both sections from
25
+ * the full schema reproduces a deployment without the configurator grant.
26
+ */
27
+ export const CONFIGURATOR_FORMS_CONTRIBUTION: SchemaContribution = {
28
+ types: [
29
+ 'FieldType',
30
+ 'ConfiguratorField',
31
+ 'ConfiguratorFieldTranslation',
32
+ 'ConfiguratorFieldOption',
33
+ 'ConfiguratorFieldOptionTranslation',
34
+ 'ConfiguratorModule',
35
+ 'ConfiguratorModuleTranslation',
36
+ 'ConfiguratorSystem',
37
+ 'ConfiguratorSystemTranslation',
38
+ 'ProductConfiguration',
39
+ 'ProductConfigurationFieldValue',
40
+ 'ProductConfigurationEvaluation',
41
+ 'EvaluatedField',
42
+ 'EvaluatedFieldOption',
43
+ 'EvaluatedFieldMessage',
44
+ 'ConfiguratorValueInput',
45
+ 'EvaluateProductConfigurationInput',
46
+ 'AddProductConfigurationToOrderInput',
47
+ 'UpdateProductConfigurationOnOrderInput',
48
+ ],
49
+ fields: {
50
+ Query: ['configuratorSystem', 'configuratorSystems', 'evaluateProductConfiguration'],
51
+ Mutation: [
52
+ 'addProductConfigurationToOrder',
53
+ 'updateProductConfigurationOnOrder',
54
+ 'removeProductConfigurationFromOrder',
55
+ ],
56
+ Order: ['productConfigurations'],
57
+ OrderLineCustomFields: ['productConfiguration', 'productConfigurationId'],
58
+ ProductCustomFields: ['configuratorSystemCode'],
59
+ ProductFilterParameter: ['configuratorSystemCode'],
60
+ ProductSortParameter: ['configuratorSystemCode'],
61
+ },
62
+ };
63
+
64
+ export const CONFIGURATOR_RULES_CONTRIBUTION: SchemaContribution = {
65
+ types: [
66
+ 'ProductConfigurationLine',
67
+ 'EvaluatedProductConfigurationLine',
68
+ 'ProductConfigurationEvaluationTotals',
69
+ 'CalculationError',
70
+ 'ExecutedRule',
71
+ ],
72
+ fields: {
73
+ ProductConfigurationEvaluation: ['lines', 'errors', 'executedRules', 'totals'],
74
+ ProductConfiguration: ['lines'],
75
+ OrderLineCustomFields: ['productConfigurationLine', 'productConfigurationLineId'],
76
+ },
77
+ };
78
+
79
+ /** Shop API fields contributed by capability modules used in storefront documents. */
80
+ export const STOREFRONT_CAPABILITY_CONTRIBUTIONS: Readonly<Record<string, SchemaContribution>> = {
81
+ 'org.accounts': {
82
+ fields: {
83
+ Query: [
84
+ 'activeCompanyUser',
85
+ 'companyAccount',
86
+ 'shopCompanyUsers',
87
+ 'companyRoles',
88
+ 'companyAddresses',
89
+ 'companyPaymentMethods',
90
+ 'companyInvitation',
91
+ 'companyOrders',
92
+ ],
93
+ Mutation: [
94
+ 'registerCompany',
95
+ 'updateCompanyAccount',
96
+ 'createCompanyAddress',
97
+ 'updateCompanyAddress',
98
+ 'deleteCompanyAddress',
99
+ 'inviteCompanyUser',
100
+ 'updateCompanyUser',
101
+ 'removeCompanyUser',
102
+ 'assignCompanyUserRole',
103
+ 'acceptCompanyInvitation',
104
+ ],
105
+ Customer: ['companyUsers'],
106
+ },
107
+ },
108
+ 'org.hierarchy': {
109
+ fields: {
110
+ Query: ['companyBusinessUnits'],
111
+ Mutation: [
112
+ 'createCompanyBusinessUnit',
113
+ 'updateCompanyBusinessUnit',
114
+ 'deleteCompanyBusinessUnit',
115
+ 'switchBusinessUnit',
116
+ ],
117
+ Company: ['businessUnits'],
118
+ CompanyAccount: ['activeBusinessUnit'],
119
+ CompanyUser: ['businessUnits'],
120
+ CompanyAddress: ['businessUnit'],
121
+ CreateCompanyAddressShopInput: ['businessUnitId'],
122
+ UpdateCompanyAddressShopInput: ['businessUnitId'],
123
+ InviteCompanyUserInput: ['businessUnitIds'],
124
+ UpdateCompanyUserShopInput: ['businessUnitIds'],
125
+ },
126
+ },
127
+ 'tax-id.validation': {
128
+ fields: {
129
+ Query: ['taxIds'],
130
+ Mutation: ['addTaxId', 'removeTaxId', 'setDefaultTaxId'],
131
+ },
132
+ },
133
+ 'quote.requests': {
134
+ fields: {
135
+ Query: ['myQuotes', 'myQuote', 'quoteStockCheck'],
136
+ Mutation: [
137
+ 'requestQuote',
138
+ 'requestQuoteFromCart',
139
+ 'submitQuoteRequest',
140
+ 'acceptQuote',
141
+ 'rejectQuote',
142
+ 'acceptAndConvertQuote',
143
+ 'cancelQuoteCheckout',
144
+ ],
145
+ OrderCustomFields: ['sourceQuoteId'],
146
+ OrderFilterParameter: ['sourceQuoteId'],
147
+ OrderSortParameter: ['sourceQuoteId'],
148
+ },
149
+ },
150
+ 'quote.negotiation': {
151
+ fields: {
152
+ Mutation: ['requestQuoteChanges', 'addQuoteComment'],
153
+ Quote: ['comments', 'versions'],
154
+ },
155
+ },
156
+ 'shopping-list.personal': {
157
+ fields: {
158
+ Query: ['activeUserShoppingList', 'activeUserShoppingLists'],
159
+ Mutation: [
160
+ 'addToShoppingList',
161
+ 'removeFromShoppingList',
162
+ 'updateShoppingListItem',
163
+ 'createShoppingList',
164
+ 'updateShoppingList',
165
+ 'deleteShoppingList',
166
+ 'addShoppingListItemsToOrder',
167
+ 'moveShoppingListItems',
168
+ ],
169
+ ProductVariant: ['appearsInShoppingLists'],
170
+ },
171
+ },
172
+ 'shopping-list.managed': {
173
+ fields: {
174
+ Query: ['publicShoppingList'],
175
+ Mutation: ['setShoppingListVisibility'],
176
+ ShoppingList: ['isPublic', 'publicCode', 'customer'],
177
+ ShoppingListItem: ['purchased', 'lastPurchasedAt'],
178
+ },
179
+ },
180
+ 'quick-order': {
181
+ fields: {
182
+ Query: ['quickOrderSearch'],
183
+ Mutation: ['validateQuickOrderItems', 'addQuickOrderItemsToOrder'],
184
+ },
185
+ },
186
+ reorder: {
187
+ fields: {
188
+ Query: ['previewReorder'],
189
+ Mutation: ['addReorderToActiveOrder'],
190
+ },
191
+ },
192
+ 'store-credit.balance': {
193
+ fields: {
194
+ Query: ['myStoreCredits'],
195
+ Mutation: [
196
+ 'applyStoreCreditToOrder',
197
+ 'removeStoreCreditFromOrder',
198
+ 'claimStoreCredit',
199
+ 'addPaymentToOrderWithStoreCredit',
200
+ ],
201
+ Order: ['storeCreditsApplied', 'totalWithTaxAfterStoreCredit'],
202
+ },
203
+ },
204
+ 'store-credit.gift-cards': {
205
+ fields: {
206
+ Query: ['giftCardTemplates'],
207
+ Mutation: ['addGiftCardToOrder'],
208
+ OrderLine: ['giftCardInput'],
209
+ ProductCustomFields: ['isGiftCard'],
210
+ ProductFilterParameter: ['isGiftCard'],
211
+ ProductSortParameter: ['isGiftCard'],
212
+ },
213
+ },
214
+ 'packaging-units': {
215
+ fields: {
216
+ ProductVariant: ['packagingUnits'],
217
+ OrderLineCustomFields: ['packagingUnit', 'packagingUnitConversionFactor'],
218
+ OrderLineCustomFieldsInput: [
219
+ 'packagingUnitId',
220
+ 'packagingUnitConversionFactor',
221
+ 'packagingUnitName',
222
+ ],
223
+ },
224
+ },
225
+ 'shared-carts.collaboration': {
226
+ fields: {
227
+ Query: ['mySharedCarts', 'sharedCart', 'companySharedCarts'],
228
+ Mutation: [
229
+ 'createSharedCart',
230
+ 'updateSharedCart',
231
+ 'deleteSharedCart',
232
+ 'addItemToSharedCart',
233
+ 'adjustSharedCartLine',
234
+ 'removeSharedCartLine',
235
+ 'addSharedCartMember',
236
+ 'removeSharedCartMember',
237
+ 'setSharedCartMemberRole',
238
+ 'transferSharedCartOwnership',
239
+ 'initiateSharedCartCheckout',
240
+ ],
241
+ },
242
+ },
243
+ 'content.history': { fields: { Query: ['contentVersioningProductVariant'] } },
244
+ 'approval.orders': {
245
+ fields: {
246
+ Query: ['myApprovalRequests', 'approvalHistoryForEntity'],
247
+ Mutation: ['submitApprovalDecision', 'batchSubmitApprovalDecisions'],
248
+ },
249
+ },
250
+ 'pricing.contract': {
251
+ fields: {
252
+ ProductVariant: ['priceTiers'],
253
+ ShopProductVariantPackagingUnit: ['priceTiers'],
254
+ },
255
+ },
256
+ 'search.foundation': {
257
+ fields: {
258
+ Query: ['searchCollections'],
259
+ SearchResult: ['highlights'],
260
+ },
261
+ },
262
+ 'search.analytics.clickhouse': {
263
+ fields: {
264
+ Mutation: ['logSearchListViewed', 'logSearchResultClicked'],
265
+ SearchResponse: ['queryId'],
266
+ },
267
+ },
268
+ 'configurator.forms': {
269
+ fields: {
270
+ ...CONFIGURATOR_FORMS_CONTRIBUTION.fields,
271
+ ...CONFIGURATOR_RULES_CONTRIBUTION.fields,
272
+ OrderLineCustomFields: [
273
+ 'productConfiguration',
274
+ 'productConfigurationId',
275
+ 'productConfigurationLine',
276
+ 'productConfigurationLineId',
277
+ ],
278
+ },
279
+ },
280
+ punchout: {
281
+ fields: {
282
+ Query: ['activePunchoutSession'],
283
+ Mutation: ['activatePunchoutSession', 'transferPunchoutCart'],
284
+ AuthenticationInput: ['punchout'],
285
+ ProductCustomFields: ['unspscCode'],
286
+ ProductFilterParameter: ['unspscCode'],
287
+ ProductSortParameter: ['unspscCode'],
288
+ },
289
+ },
290
+ };
291
+
292
+ /** Build the Shop API schema for an explicit grant set. */
293
+ export function buildSchemaForCapabilities(fullSdl: string, grants: ReadonlySet<string>): GraphQLSchema {
294
+ return buildSchemaWithout(
295
+ fullSdl,
296
+ Object.entries(STOREFRONT_CAPABILITY_CONTRIBUTIONS)
297
+ .filter(([capability]) => !grants.has(capability))
298
+ .map(([, contribution]) => contribution),
299
+ );
300
+ }
301
+
302
+ function removeContribution(document: DocumentNode, contribution: SchemaContribution): DocumentNode {
303
+ const droppedTypes = new Set(contribution.types ?? []);
304
+ const droppedFields = contribution.fields ?? {};
305
+
306
+ const definitions: DefinitionNode[] = [];
307
+ for (const definition of document.definitions) {
308
+ if (!('name' in definition) || !definition.name) {
309
+ definitions.push(definition);
310
+ continue;
311
+ }
312
+ const typeName = definition.name.value;
313
+ if (droppedTypes.has(typeName)) {
314
+ continue;
315
+ }
316
+ const fieldsToDrop = droppedFields[typeName];
317
+ if (!fieldsToDrop) {
318
+ definitions.push(definition);
319
+ continue;
320
+ }
321
+ if (
322
+ definition.kind === Kind.OBJECT_TYPE_DEFINITION ||
323
+ definition.kind === Kind.INTERFACE_TYPE_DEFINITION
324
+ ) {
325
+ const node = definition as ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode;
326
+ definitions.push({
327
+ ...node,
328
+ fields: (node.fields ?? []).filter((field) => !fieldsToDrop.includes(field.name.value)),
329
+ });
330
+ continue;
331
+ }
332
+ if (definition.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) {
333
+ const node = definition as InputObjectTypeDefinitionNode;
334
+ definitions.push({
335
+ ...node,
336
+ fields: (node.fields ?? []).filter((field) => !fieldsToDrop.includes(field.name.value)),
337
+ });
338
+ continue;
339
+ }
340
+ definitions.push(definition);
341
+ }
342
+ return { ...document, definitions };
343
+ }
344
+
345
+ /**
346
+ * Builds the Shop API schema a deployment serves when the listed capability
347
+ * contributions are absent.
348
+ */
349
+ export function buildSchemaWithout(
350
+ fullSdl: string,
351
+ contributions: readonly SchemaContribution[],
352
+ ): GraphQLSchema {
353
+ let document = parse(fullSdl);
354
+ for (const contribution of contributions) {
355
+ document = removeContribution(document, contribution);
356
+ }
357
+ return buildSchema(print(document));
358
+ }
@@ -0,0 +1,15 @@
1
+ /** Makes capability-pruned quote activity safe for UI consumers. */
2
+ export function getQuoteNegotiationData<Comment, Version>(quote: {
3
+ comments?: Comment[];
4
+ versions?: Version[];
5
+ }): { comments: Comment[]; versions: Version[] } {
6
+ return {
7
+ comments: quote.comments ?? [],
8
+ versions: quote.versions ?? [],
9
+ };
10
+ }
11
+
12
+ /** Makes capability-pruned search highlights safe for product cards. */
13
+ export function getSearchHighlights<Highlight>(product: { highlights?: Highlight[] }): Highlight[] {
14
+ return product.highlights ?? [];
15
+ }