@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.
- package/package.json +1 -1
- package/templates/storefront-shadcn/.forgecartignore +2 -0
- package/templates/storefront-shadcn/Procfile +1 -0
- package/templates/storefront-shadcn/README.md +229 -0
- package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
- package/templates/storefront-shadcn/components.json +21 -0
- package/templates/storefront-shadcn/next.config.js +105 -0
- package/templates/storefront-shadcn/package.json +39 -0
- package/templates/storefront-shadcn/postcss.config.js +5 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/globals.css +156 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
- package/templates/storefront-shadcn/src/app/page.tsx +217 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
- package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
- package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
- package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront-shadcn/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
- package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
- package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
- package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
- package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
- package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
- package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
- package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
- package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
- package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
- package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
- package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
- package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
- package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn/src/middleware.ts +379 -0
- package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
- package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn/src/server/types.ts +36 -0
- package/templates/storefront-shadcn/tsconfig.json +25 -0
- package/templates/storefront-shadcn-sdk-floor.json +1174 -0
- package/templates/template-set.json +10 -0
package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts
ADDED
|
@@ -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
|
+
}
|