@forgecart/cli 2.202610052143.0 → 2.202610060357.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 (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. package/templates/template-set.json +10 -0
@@ -0,0 +1,58 @@
1
+ import 'server-only';
2
+
3
+ import { randomBytes } from 'node:crypto';
4
+
5
+ import { Injectable } from '@nestjs/common';
6
+
7
+ import { BackendMethod } from '../../backend-method.decorator';
8
+ import { BackendError } from '../../types';
9
+ import type { BackendSession } from '../../types';
10
+
11
+ import type {
12
+ AssignCustomerHashInput,
13
+ AssignCustomerHashResult,
14
+ } from '../type/customer-extras.types';
15
+
16
+ /** ACF definition this store keeps its per-customer extras under. */
17
+ const DEFINITION_CODE = 'customer-extras';
18
+
19
+ /**
20
+ * The worked example for the `sdk.backend` gate: mint a random hash for the
21
+ * signed-in customer and persist it as a custom field — an ACF entry under
22
+ * the `customer-extras` definition. That is an ADMIN-authority write the
23
+ * shop API has no surface for, which is exactly what the gate exists to
24
+ * unlock.
25
+ *
26
+ * Precondition: the channel has a `customer-extras` ACF definition with a
27
+ * `customerId` id field and a `referralHash` string field (Dashboard →
28
+ * Custom Fields). Without it the createEntry call fails and the typed
29
+ * envelope carries the API's error to the caller — nothing crashes.
30
+ */
31
+ @Injectable()
32
+ export class CustomerExtrasService {
33
+ @BackendMethod()
34
+ async assignCustomerHash(
35
+ session: BackendSession,
36
+ _input: AssignCustomerHashInput,
37
+ ): Promise<AssignCustomerHashResult> {
38
+ const { activeCustomer } = await session.shop.customer.activeCustomer();
39
+ if (!activeCustomer) {
40
+ // Shopper-invocable ≠ anonymous-invocable: this method is per-customer
41
+ // by definition, so an anonymous session is a typed business error.
42
+ throw new BackendError('BACKEND_CUSTOMER_SESSION_REQUIRED');
43
+ }
44
+
45
+ const referralHash = randomBytes(16).toString('hex');
46
+ await session.admin.acf.entry.acfCreateEntry({
47
+ definitionCode: DEFINITION_CODE,
48
+ input: {
49
+ fields: [
50
+ { name: 'customerId', idValue: activeCustomer.id },
51
+ { name: 'referralHash', stringValue: referralHash },
52
+ ],
53
+ },
54
+ });
55
+
56
+ return { customerId: activeCustomer.id, referralHash };
57
+ }
58
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The `assignCustomerHash` backend-method contract (the sdk.backend worked
3
+ * example). Input is deliberately empty — the method derives everything from
4
+ * the caller's session; the result echoes the customer and the minted hash.
5
+ */
6
+ export type AssignCustomerHashInput = Record<string, never>;
7
+
8
+ export interface AssignCustomerHashResult {
9
+ customerId: string;
10
+ referralHash: string;
11
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The revision this server is SERVING, for the dev-only error beacon (#847).
3
+ *
4
+ * A browser error report used to be stamped with whatever revision was live at
5
+ * the moment the pod RECEIVED it (`storefront-beacon.service.ts`, `atRevision:
6
+ * this.target.getLiveRevision()`). A promote that advances between serve and
7
+ * throw therefore made the report blame the NEW revision for the OLD one's
8
+ * crash, and the receiver's same-revision fold then marked a healthy tree
9
+ * DEGRADED. The cure is to resolve the revision where the page is RENDERED —
10
+ * here — stamp it into the HTML, and let the report carry it back.
11
+ *
12
+ * WHERE THE RAW VALUE COMES FROM (settled by ruling ANSWER-0425Z-agent-004):
13
+ * the pod's supervisor publishes the serving sha to a DERIVED, read-only file
14
+ * inside the workspace's `.forge/` island by the same act that re-publishes its
15
+ * runtime state, and this module reads that file per request. Explicitly NOT a
16
+ * beacon hop on the render path, and explicitly NOT an environment variable —
17
+ * one dev-server process outlives many promotes, so a value handed to it at
18
+ * spawn time could only ever name the revision it BOOTED at, which is this
19
+ * module's own bug reintroduced one layer down. The retired
20
+ * `.forge/liveRevision` sentinel (appendix design law 5, §7.3) is not that file
21
+ * and is not read here; see `FORGE_SERVING_REVISION_FILE` below.
22
+ *
23
+ * The split is still the shape of the feature:
24
+ *
25
+ * - `parseLiveRevision` is PURE and fully specified by vectors. It owns the
26
+ * whole question of what counts as a revision, independently of where the
27
+ * raw value came from.
28
+ * - `readLiveRevisionSource` is the ONE impure line — the file read, and
29
+ * nothing else in the template touches the filesystem for this.
30
+ *
31
+ * FAIL OPEN, ALWAYS. Every unresolvable case answers `null`, which means no
32
+ * meta tag, which means an untagged report, which means the receiver keeps its
33
+ * existing behaviour (confirm probe included). A page must never fail to render
34
+ * because the revision is unknown, so there is no throw on this path: an
35
+ * unknown revision is an ordinary, expected state — every non-storefront pod,
36
+ * and every pod before its bootstrap primes the ref — not a fault.
37
+ */
38
+
39
+ import { readFileSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+
42
+ /**
43
+ * The meta tag's `name`. The CONTRACT between this module (which resolves the
44
+ * value), `app/layout.tsx` (which emits the tag) and
45
+ * `components/ForgeErrorBeacon.tsx` (which reads it back out of the DOM at send
46
+ * time). The beacon is a CLIENT component and must not import a `src/server/`
47
+ * module, so it repeats the literal with a comment pointing here — the same way
48
+ * the `/__forge_beacon` URL is repeated across this feature's three producers
49
+ * rather than shared through an import.
50
+ */
51
+ export const FORGE_REVISION_META = 'forge-revision';
52
+
53
+ /**
54
+ * The workspace-relative path the pod's supervisor publishes the serving sha to.
55
+ *
56
+ * MIRRORS `SERVING_REVISION_FILE` in
57
+ * `app/workspace-manager/src/supervisor/workspace-supervisor.service.ts`, whose
58
+ * doc carries the full reasoning for the path — `.forge/` because that island is
59
+ * the board repo's ignore authority and a file anywhere else would dirty the
60
+ * serving tree on every publish, and NEITHER retired legacy path, both of which
61
+ * the pod's own bootstrap deletes at boot. It is repeated here instead of
62
+ * imported because this template is scaffolding DATA — copied verbatim into a
63
+ * merchant's project, with its own `package.json` and toolchain, outside that
64
+ * monorepo's build graph — exactly like the `/__forge_beacon` URL. Nothing in a
65
+ * compiler connects the two copies: a rename on either side would silently stop
66
+ * the file from being found and every report would go back to untagged, so
67
+ * `tool/storefront-template-spec/src/live-revision.spec.ts` compares them.
68
+ *
69
+ * Resolved against `process.cwd()`, which IS the workspace root the supervisor
70
+ * writes into: it spawns `npm run dev` with `cwd` = the configured workspace
71
+ * path (resolved in `process-supervisor.service.ts`, handed to the child in
72
+ * `managed-process.ts`), and `npm` runs the script from the package root without
73
+ * moving it. `lib/seo/scaffolded-routes.ts` already reads a workspace-relative
74
+ * path this way on a request path.
75
+ */
76
+ export const FORGE_SERVING_REVISION_FILE = '.forge/serving-revision';
77
+
78
+ /**
79
+ * A revision is a full git commit sha and nothing else: exactly 40 hex digits,
80
+ * case-insensitive, surrounding whitespace trimmed. That is precisely what the
81
+ * supervisor's source produces — `git rev-parse --verify refs/heads/serving^{commit}`
82
+ * (`board-git.service.ts`) — so anything else reaching here is not a revision
83
+ * and must not be stamped.
84
+ *
85
+ * The rejections are the load-bearing half, because a stamped non-sha is worse
86
+ * than no stamp at all: the receiver compares the payload's value to the live
87
+ * revision by EQUALITY to decide whether to fold without probing, so a
88
+ * placeholder that could also BE the live value would skip the probe on
89
+ * evidence that means nothing. `'unversioned'` is the concrete instance of that
90
+ * hazard — it is literally what the supervisor reports for a pod with no board
91
+ * repo, so an unversioned pod stamping `'unversioned'` would match itself and
92
+ * defeat the guard. Rejecting it here is what makes the receiver's equality
93
+ * check safe.
94
+ *
95
+ * @param raw the candidate revision, exactly as obtained from the source
96
+ * @returns the normalised lowercase sha, or `null` when `raw` is not one
97
+ */
98
+ export function parseLiveRevision(raw: string): string | null {
99
+ const trimmed = raw.trim();
100
+ if (!/^[0-9a-f]{40}$/i.test(trimmed)) return null;
101
+ return trimmed.toLowerCase();
102
+ }
103
+
104
+ /**
105
+ * Obtain the raw revision value — THE ONE impure line in this module.
106
+ *
107
+ * A plain read of {@link FORGE_SERVING_REVISION_FILE}, the file the pod's
108
+ * supervisor re-publishes on every serving advance. There is deliberately NO
109
+ * environment fallback: the ruling excluded one, and an env value read here
110
+ * would be strictly worse than nothing, because the only process that could
111
+ * supply it is the dev-server's own spawn — which happens once and then serves
112
+ * every later promote under the revision it started at.
113
+ *
114
+ * Read per CALL, never captured at module scope, for the same reason:
115
+ * a value snapshotted at first import would keep stamping the boot revision
116
+ * forever. `lib/seo/sitemap-cache.ts` carries the same getter-not-a-constant
117
+ * reasoning.
118
+ *
119
+ * SYNCHRONOUS, unlike `lib/seo/scaffolded-routes.ts`, which reads on a request
120
+ * path asynchronously and explains why. That reasoning does not transfer: it
121
+ * fans out over a directory of arbitrarily many JSON fragments, whereas this is
122
+ * ONE page-cached read of at most 41 bytes, sitting beside an awaited channel
123
+ * read in the same `generateMetadata` that dwarfs it. Going async would cost
124
+ * `resolveLiveRevision` its synchronous signature for nothing measurable.
125
+ *
126
+ * Every failure answers `null`, and the ordinary case IS a failure: the file is
127
+ * absent on every pod before its first publish, on a non-storefront workspace
128
+ * that publishes none, and in a merchant's own checkout of this template, where
129
+ * no supervisor exists at all. Absent, unreadable and garbage all mean the same
130
+ * thing here — no tag, an untagged report, the receiver's existing behaviour —
131
+ * so there is nothing worth distinguishing between them and nothing worth
132
+ * throwing over on a render path.
133
+ */
134
+ export function readLiveRevisionSource(): string | null {
135
+ try {
136
+ return readFileSync(join(process.cwd(), FORGE_SERVING_REVISION_FILE), 'utf8');
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The revision to stamp into this response, or `null` when it cannot be
144
+ * resolved. Composes the source and the acceptance rule and holds no policy of
145
+ * its own, so the ruling can move the source without touching any caller.
146
+ *
147
+ * The DEV-ONLY gate lives at the call site (`app/layout.tsx`), not here — the
148
+ * placement every other file in this feature uses (`ForgeErrorBeacon.tsx`,
149
+ * `app/%5F%5Fforge_beacon/route.ts` and `instrumentation.ts` each check
150
+ * `NODE_ENV` at their own top), which is what lets a production build
151
+ * dead-code-eliminate the whole path rather than merely reach a function that
152
+ * answers `null`.
153
+ */
154
+ export function resolveLiveRevision(): string | null {
155
+ const raw = readLiveRevisionSource();
156
+ if (raw === null) return null;
157
+ return parseLiveRevision(raw);
158
+ }
@@ -0,0 +1,69 @@
1
+ import 'server-only';
2
+
3
+ import { ForgeCartAdminClient, ForgeCartShopClient } from '@forgecart/sdk';
4
+ import { Injectable } from '@nestjs/common';
5
+
6
+ const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
7
+ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
8
+ const ADMIN_SECRET = process.env.FORGECART_ADMIN_SECRET ?? '';
9
+ /** The admin API is the shop API's sibling endpoint on the same host. */
10
+ const ADMIN_API_URL = SHOP_API_URL.replace(/\/shop-api$/, '/admin-api');
11
+
12
+ /**
13
+ * SDK clients for backend methods. The generated SDK is graphql-ws, so
14
+ * every client is a websocket. Two lifetimes on purpose: the ADMIN client is
15
+ * a process-long singleton (channel-scoped, secret auth); the SESSION shop
16
+ * client is per-invocation (shopper-scoped authentication — see buildShop).
17
+ */
18
+ @Injectable()
19
+ export class ForgeCartClientFactory {
20
+ private admin: ForgeCartAdminClient | null = null;
21
+
22
+ /**
23
+ * Whether the backend gate is fully configured: `forgecart init` wrote all
24
+ * three env values AND the admin endpoint is derivable from the shop URL.
25
+ * False during the image-build pre-warm (which renders with no env) — the
26
+ * runner then answers with a typed envelope instead of driving doomed
27
+ * clients, keeping the pre-warm contract (serve, never crash) intact.
28
+ */
29
+ isConfigured(): boolean {
30
+ return Boolean(SHOP_API_URL && CHANNEL_TOKEN && ADMIN_SECRET && ADMIN_API_URL !== SHOP_API_URL);
31
+ }
32
+
33
+ /**
34
+ * The ADMIN socket is a long-lived singleton: admin operations are
35
+ * channel-scoped (secret auth, no shopper session), so one connection
36
+ * serves every invocation for the life of the server process — never
37
+ * disposed per invocation. Session-scoped sockets are the opposite case:
38
+ * they belong to the shopper's browser, and the ONE server-side exception
39
+ * is {@link buildShop} below.
40
+ */
41
+ getAdmin(): ForgeCartAdminClient {
42
+ if (!this.admin) {
43
+ this.admin = new ForgeCartAdminClient({
44
+ endpoint: ADMIN_API_URL,
45
+ channelToken: CHANNEL_TOKEN,
46
+ adminSecret: ADMIN_SECRET,
47
+ });
48
+ }
49
+ return this.admin;
50
+ }
51
+
52
+ /**
53
+ * Per-invocation SESSION shop client — the deliberate server-side
54
+ * exception: a backend method must authenticate WHO the shopper is from
55
+ * the httpOnly session cookie before acting with admin authority (the
56
+ * browser cannot be trusted to claim an identity for an admin-gated op).
57
+ * Short-lived by design; the runner disposes it with the invocation.
58
+ */
59
+ buildShop(sessionToken: string | undefined): ForgeCartShopClient {
60
+ const client = new ForgeCartShopClient({
61
+ endpoint: SHOP_API_URL,
62
+ channelToken: CHANNEL_TOKEN,
63
+ });
64
+ if (sessionToken) {
65
+ client.setAuthToken(sessionToken);
66
+ }
67
+ return client;
68
+ }
69
+ }
@@ -0,0 +1,9 @@
1
+ import 'server-only';
2
+
3
+ import { Module } from '@nestjs/common';
4
+
5
+ import { ForgeCartClientFactory } from './forgecart-client.factory';
6
+
7
+ /** Provides the SDK client factory to the backend's module graph. */
8
+ @Module({ providers: [ForgeCartClientFactory], exports: [ForgeCartClientFactory] })
9
+ export class ForgeCartModule {}
@@ -0,0 +1,90 @@
1
+ import 'server-only';
2
+
3
+ import { extractError, type ExtractedError } from '@forgecart/sdk';
4
+ import { cookies } from 'next/headers';
5
+
6
+ import { UNREACHABLE_ERROR, type ActionResult } from '../lib/action-result';
7
+ import { SESSION_COOKIE } from '../lib/session-cookies';
8
+ import { getBackend } from './bootstrap';
9
+ import { ForgeCartClientFactory } from './forgecart/forgecart-client.factory';
10
+ import { BackendError } from './types';
11
+ import type { BackendSession } from './types';
12
+
13
+ /** The gate has no env yet (image pre-warm / before `forgecart init`). */
14
+ const NOT_CONFIGURED: ExtractedError = {
15
+ code: 'BACKEND_NOT_CONFIGURED',
16
+ variables: {},
17
+ classification: 'INTERNAL_ERROR',
18
+ };
19
+
20
+ /**
21
+ * Dispatch one `sdk.backend` invocation: resolve the booted registry, build
22
+ * the per-invocation SDK clients, run the method, translate every failure
23
+ * into the in-band envelope, and dispose both client websockets with the
24
+ * invocation — the exact contract of `lib/shop-action.ts`, extended with the
25
+ * admin gate.
26
+ *
27
+ * Fail-closed rules: an unknown name (type-cast bypass, or a STALE browser
28
+ * bundle calling a method a newer deploy removed) answers
29
+ * BACKEND_METHOD_NOT_FOUND; a boot failure (the api-map drift check)
30
+ * answers BACKEND_BOOT_FAILED carrying the boot error's message so the
31
+ * agent sees the actionable text in the envelope too, not only in the logs.
32
+ */
33
+ export async function invokeBackendMethod(
34
+ method: string,
35
+ input: unknown,
36
+ ): Promise<ActionResult<unknown>> {
37
+ let registry: ReadonlyMap<string, (session: BackendSession, input: unknown) => Promise<unknown>>;
38
+ let factory: ForgeCartClientFactory;
39
+ try {
40
+ const runtime = await getBackend();
41
+ registry = runtime.registry;
42
+ factory = runtime.ctx.get(ForgeCartClientFactory, { strict: false });
43
+ } catch (error) {
44
+ return {
45
+ ok: false,
46
+ error: {
47
+ code: 'BACKEND_BOOT_FAILED',
48
+ variables: { reason: error instanceof Error ? error.message : String(error) },
49
+ classification: 'INTERNAL_ERROR',
50
+ },
51
+ };
52
+ }
53
+
54
+ const fn = registry.get(method);
55
+ if (!fn) {
56
+ return {
57
+ ok: false,
58
+ error: {
59
+ code: 'BACKEND_METHOD_NOT_FOUND',
60
+ variables: { method },
61
+ classification: 'BAD_USER_INPUT',
62
+ },
63
+ };
64
+ }
65
+ if (!factory.isConfigured()) {
66
+ return { ok: false, error: NOT_CONFIGURED };
67
+ }
68
+
69
+ const store = await cookies();
70
+ const sessionToken = store.get(SESSION_COOKIE)?.value;
71
+ const admin = factory.getAdmin();
72
+ const shop = factory.buildShop(sessionToken);
73
+ const session: BackendSession = { admin, shop, hasSession: Boolean(sessionToken) };
74
+ try {
75
+ const data = await fn(session, input);
76
+ return { ok: true, data };
77
+ } catch (error) {
78
+ if (error instanceof BackendError) {
79
+ return {
80
+ ok: false,
81
+ error: { code: error.code, variables: error.variables, classification: 'BAD_USER_INPUT' },
82
+ };
83
+ }
84
+ return { ok: false, error: extractError(error) ?? UNREACHABLE_ERROR };
85
+ } finally {
86
+ // Only the session client dies with the invocation — the admin socket is
87
+ // the factory's process-long singleton.
88
+ shop.dispose();
89
+ }
90
+ }
@@ -0,0 +1,36 @@
1
+ import 'server-only';
2
+
3
+ import type { ForgeCartAdminClient, ForgeCartShopClient } from '@forgecart/sdk';
4
+
5
+ /**
6
+ * The per-invocation context every backend method receives as its first
7
+ * argument. Both SDK gates are built fresh for THIS invocation by the runner
8
+ * and disposed with it (they are per-invocation websockets — same discipline
9
+ * as `lib/shop-action.ts`), so methods never construct or dispose clients.
10
+ *
11
+ * `admin` carries FULL channel authority (FORGECART_ADMIN_SECRET). A backend
12
+ * method is shopper-invocable, so nothing upstream gates what the admin
13
+ * client is used for — the method body owns that decision.
14
+ */
15
+ export interface BackendSession {
16
+ admin: ForgeCartAdminClient;
17
+ shop: ForgeCartShopClient;
18
+ /** True when the request carried a shopper session cookie. */
19
+ hasSession: boolean;
20
+ }
21
+
22
+ /**
23
+ * Typed failure a backend method throws for its own business rules
24
+ * (missing login, invalid input, unmet precondition). The runner translates
25
+ * it into the standard `ActionResult` envelope exactly like an SDK error,
26
+ * so the client renders `code`/`variables` through the same error spine.
27
+ */
28
+ export class BackendError extends Error {
29
+ constructor(
30
+ readonly code: string,
31
+ readonly variables: Record<string, string> = {},
32
+ ) {
33
+ super(code);
34
+ this.name = 'BackendError';
35
+ }
36
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "ES2022"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "experimentalDecorators": true,
10
+ "emitDecoratorMetadata": true,
11
+ "esModuleInterop": true,
12
+ "module": "esnext",
13
+ "moduleResolution": "bundler",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "jsx": "preserve",
17
+ "incremental": true,
18
+ "plugins": [{ "name": "next" }],
19
+ "paths": {
20
+ "@/*": ["./src/*"]
21
+ }
22
+ },
23
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
24
+ "exclude": ["node_modules"]
25
+ }