@mj-biz-apps/orders-server 5.1.0 → 5.2.1
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/README.md +7 -0
- package/dist/CheckoutServerExtension.d.ts +96 -0
- package/dist/CheckoutServerExtension.d.ts.map +1 -0
- package/dist/CheckoutServerExtension.js +554 -0
- package/dist/CheckoutServerExtension.js.map +1 -0
- package/dist/__tests__/CheckoutServerExtension.test.d.ts +2 -0
- package/dist/__tests__/CheckoutServerExtension.test.d.ts.map +1 -0
- package/dist/__tests__/CheckoutServerExtension.test.js +257 -0
- package/dist/__tests__/CheckoutServerExtension.test.js.map +1 -0
- package/dist/__tests__/checkout-edge-policy.test.d.ts +2 -0
- package/dist/__tests__/checkout-edge-policy.test.d.ts.map +1 -0
- package/dist/__tests__/checkout-edge-policy.test.js +70 -0
- package/dist/__tests__/checkout-edge-policy.test.js.map +1 -0
- package/dist/__tests__/checkout-host-page.test.d.ts +2 -0
- package/dist/__tests__/checkout-host-page.test.d.ts.map +1 -0
- package/dist/__tests__/checkout-host-page.test.js +86 -0
- package/dist/__tests__/checkout-host-page.test.js.map +1 -0
- package/dist/__tests__/server-extensions-manifest.test.d.ts +2 -0
- package/dist/__tests__/server-extensions-manifest.test.d.ts.map +1 -0
- package/dist/__tests__/server-extensions-manifest.test.js +29 -0
- package/dist/__tests__/server-extensions-manifest.test.js.map +1 -0
- package/dist/checkout-edge-policy.d.ts +44 -0
- package/dist/checkout-edge-policy.d.ts.map +1 -0
- package/dist/checkout-edge-policy.js +84 -0
- package/dist/checkout-edge-policy.js.map +1 -0
- package/dist/checkout-host-page.d.ts +43 -0
- package/dist/checkout-host-page.d.ts.map +1 -0
- package/dist/checkout-host-page.js +445 -0
- package/dist/checkout-host-page.js.map +1 -0
- package/dist/generated/generated.d.ts +189 -173
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1152 -1947
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/server-extensions-manifest.d.ts +14 -0
- package/dist/server-extensions-manifest.d.ts.map +1 -0
- package/dist/server-extensions-manifest.js +15 -0
- package/dist/server-extensions-manifest.js.map +1 -0
- package/package.json +30 -13
package/README.md
CHANGED
|
@@ -33,6 +33,13 @@ class will be used instead, and the first symptom is a financial one.
|
|
|
33
33
|
|
|
34
34
|
- `config.ts` — server configuration
|
|
35
35
|
- `src/generated/` — CodeGen's GraphQL resolvers. Do not edit; see the Entities package README.
|
|
36
|
+
- `MJ_SERVER_EXTENSIONS` — webhook + checkout edge declarations. MJ bootstrap collects this
|
|
37
|
+
(or `package.json` `memberjunction.serverExtensions`) from hosts that list this package in
|
|
38
|
+
`dynamicPackages.server[]`. Host `mj.config.cjs` `serverExtensions[]` overlays by DriverClass.
|
|
39
|
+
- `GET /checkout/:slug` — public HTML host for a distribution; POSTs stay on the same edge.
|
|
40
|
+
Pre-auth. Host `serverExtensions[]` can set `Enabled: false` to suppress. Settings:
|
|
41
|
+
`TrustedProxyHops` (default 0 — ignore `X-Forwarded-For`; set to the number of reverse
|
|
42
|
+
proxies that append to it), `RateLimitMax` / `RateLimitMaxGlobal`.
|
|
36
43
|
|
|
37
44
|
## Verifying registration
|
|
38
45
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Application } from 'express';
|
|
2
|
+
import { BaseServerExtension, type ExtensionHealthResult, type ExtensionInitResult, type ServerExtensionConfig } from '@memberjunction/server-extensions-core';
|
|
3
|
+
interface CheckoutEdgeSettings {
|
|
4
|
+
/** Email of the named checkout service principal; falls back to the system user. */
|
|
5
|
+
ServiceUserEmail?: string;
|
|
6
|
+
/** Name of the env var holding the Cloudflare Turnstile secret. */
|
|
7
|
+
TurnstileSecretEnvVar?: string;
|
|
8
|
+
/** Rate-limit window in ms (default 60000). */
|
|
9
|
+
RateLimitWindowMs?: number;
|
|
10
|
+
/** Max requests per window per client key (default 30). */
|
|
11
|
+
RateLimitMax?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Aggregate max per IP per window across all slugs (default 90).
|
|
14
|
+
* Independent of the per-slug cap.
|
|
15
|
+
*/
|
|
16
|
+
RateLimitMaxGlobal?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Number of reverse proxies that append to X-Forwarded-For. 0 (default)
|
|
19
|
+
* ignores XFF and uses the socket address — the leftmost XFF hop is
|
|
20
|
+
* client-supplied and must not key rate limits or Turnstile.
|
|
21
|
+
*/
|
|
22
|
+
TrustedProxyHops?: number;
|
|
23
|
+
/**
|
|
24
|
+
* When true, GET `{root}/element/main.js.map` is registered. Default off —
|
|
25
|
+
* the map is original TypeScript on an unauthenticated payment route.
|
|
26
|
+
* Also honoured via env `CHECKOUT_ELEMENT_SOURCEMAP=1` when the setting is unset.
|
|
27
|
+
*/
|
|
28
|
+
ServeElementSourceMap?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Source maps on the public checkout element are opt-in (dev). Default: do not serve. */
|
|
31
|
+
export declare function shouldServeCheckoutElementSourceMap(settings: Pick<CheckoutEdgeSettings, 'ServeElementSourceMap'>, env?: NodeJS.ProcessEnv): boolean;
|
|
32
|
+
export declare class CheckoutServerExtension extends BaseServerExtension {
|
|
33
|
+
private settings;
|
|
34
|
+
private rateWindows;
|
|
35
|
+
private warnedSystemUserFallback;
|
|
36
|
+
private rootPath;
|
|
37
|
+
private lastReapAt;
|
|
38
|
+
Initialize(app: Application, config: ServerExtensionConfig): Promise<ExtensionInitResult>;
|
|
39
|
+
Shutdown(): Promise<void>;
|
|
40
|
+
HealthCheck(): Promise<ExtensionHealthResult>;
|
|
41
|
+
/** Runs the shared gates (rate limit → origin → turnstile where required) then the handler. */
|
|
42
|
+
private guardAndRun;
|
|
43
|
+
/** OPTIONS preflight: grant CORS only to origins the widget policy allows. */
|
|
44
|
+
private handlePreflight;
|
|
45
|
+
/**
|
|
46
|
+
* Resolves the widget edge policy (allowed origins + turnstile requirement) for this
|
|
47
|
+
* request — by slug on initialize, by session id on the other routes. Returns null when
|
|
48
|
+
* nothing resolves (unknown slug/session): the underlying service will answer with its
|
|
49
|
+
* own refusal.
|
|
50
|
+
*/
|
|
51
|
+
private resolveEdgePolicy;
|
|
52
|
+
/** Enforces the per-widget origin allowlist; grants CORS to allowed origins. */
|
|
53
|
+
private applyOriginGate;
|
|
54
|
+
private originAllowed;
|
|
55
|
+
private setCorsHeaders;
|
|
56
|
+
/** Turnstile applies to the abuse-sensitive endpoints: initialize and complete. */
|
|
57
|
+
private isTurnstileGatedPath;
|
|
58
|
+
/**
|
|
59
|
+
* Verifies the Cloudflare Turnstile token. Returns null on success, or [status, message]
|
|
60
|
+
* on refusal. A widget that REQUIRES Turnstile with no secret configured fails closed
|
|
61
|
+
* with a 503 — a silent pass would advertise protection that is not running.
|
|
62
|
+
*/
|
|
63
|
+
private verifyTurnstile;
|
|
64
|
+
/**
|
|
65
|
+
* `GET /checkout/:slug` — first-party HTML that talks to the POST edge.
|
|
66
|
+
* Unknown/reserved slugs 404; missing service principal is a 503. The page
|
|
67
|
+
* itself is cache-free; initialize still enforces Active widget + distribution.
|
|
68
|
+
*/
|
|
69
|
+
private handleGetHost;
|
|
70
|
+
private sendHostError;
|
|
71
|
+
private applyHostSecurityHeaders;
|
|
72
|
+
private maybeReapExpiredSessions;
|
|
73
|
+
private requestSlug;
|
|
74
|
+
private activeDistributionExists;
|
|
75
|
+
private handleInitialize;
|
|
76
|
+
private handleDraft;
|
|
77
|
+
private handlePaymentIntent;
|
|
78
|
+
private handleComplete;
|
|
79
|
+
/** Fixed-window limiter over a bounded, insertion-ordered map. */
|
|
80
|
+
private rateLimitExceeded;
|
|
81
|
+
/**
|
|
82
|
+
* The client IP for rate limiting and Turnstile. Ignores X-Forwarded-For unless
|
|
83
|
+
* `Settings.TrustedProxyHops` is a positive integer (Nth-from-right hop).
|
|
84
|
+
*/
|
|
85
|
+
private clientIp;
|
|
86
|
+
/**
|
|
87
|
+
* The acting principal for checkout writes: the configured service user when present,
|
|
88
|
+
* else MJ's system user (warned once). Resolved per request — the user cache may not be
|
|
89
|
+
* populated when the extension initializes.
|
|
90
|
+
*/
|
|
91
|
+
private resolveActingUser;
|
|
92
|
+
}
|
|
93
|
+
/** Tree-shaking anchor — call from the server bootstrap so @RegisterClass is retained. */
|
|
94
|
+
export declare function LoadCheckoutServerExtension(): void;
|
|
95
|
+
export {};
|
|
96
|
+
//# sourceMappingURL=CheckoutServerExtension.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CheckoutServerExtension.d.ts","sourceRoot":"","sources":["../src/CheckoutServerExtension.ts"],"names":[],"mappings":"AAmDA,OAAO,KAAK,EAAE,WAAW,EAAmC,MAAM,SAAS,CAAC;AAI5E,OAAO,EACH,mBAAmB,EACnB,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC7B,MAAM,wCAAwC,CAAC;AAuDhD,UAAU,oBAAoB;IAC1B,oFAAoF;IACpF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,mEAAmE;IACnE,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,+CAA+C;IAC/C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,2DAA2D;IAC3D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACnC;AAED,0FAA0F;AAC1F,wBAAgB,mCAAmC,CAC/C,QAAQ,EAAE,IAAI,CAAC,oBAAoB,EAAE,uBAAuB,CAAC,EAC7D,GAAG,GAAE,MAAM,CAAC,UAAwB,GACrC,OAAO,CAQT;AAOD,qBACa,uBAAwB,SAAQ,mBAAmB;IAC5D,OAAO,CAAC,QAAQ,CAA4B;IAC5C,OAAO,CAAC,WAAW,CAA6D;IAChF,OAAO,CAAC,wBAAwB,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,UAAU,CAAK;IAEV,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IA+CzF,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAIzB,WAAW,IAAI,OAAO,CAAC,qBAAqB,CAAC;IAa1D,+FAA+F;YACjF,WAAW;IAyCzB,8EAA8E;YAChE,eAAe;IAe7B;;;;;OAKG;YACW,iBAAiB;IAsD/B,gFAAgF;IAChF,OAAO,CAAC,eAAe;IAevB,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,cAAc;IAQtB,mFAAmF;IACnF,OAAO,CAAC,oBAAoB;IAI5B;;;;OAIG;YACW,eAAe;IA6B7B;;;;OAIG;YACW,aAAa;IA4C3B,OAAO,CAAC,aAAa;IASrB,OAAO,CAAC,wBAAwB;IAMhC,OAAO,CAAC,wBAAwB;IAWhC,OAAO,CAAC,WAAW;YAOL,wBAAwB;YAaxB,gBAAgB;YAahB,WAAW;YAcX,mBAAmB;YAYnB,cAAc;IAc5B,kEAAkE;IAClE,OAAO,CAAC,iBAAiB;IAkBzB;;;OAGG;IACH,OAAO,CAAC,QAAQ;IAIhB;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;CAkB5B;AAED,0FAA0F;AAC1F,wBAAgB,2BAA2B,IAAI,IAAI,CAElD"}
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The anonymous checkout edge — the public transport in front of `CheckoutSessionService`.
|
|
9
|
+
*
|
|
10
|
+
* Until this extension existed the checkout service had no edge at all: nothing exposed
|
|
11
|
+
* InitializeSession / UpdateDraft / CompleteCheckout to a browser, so the widget could not be
|
|
12
|
+
* driven end-to-end. Like the payment webhook, the edge mounts through `serverExtensions[]`
|
|
13
|
+
* BEFORE MJServer installs its auth middleware — an anonymous buyer presents no bearer token;
|
|
14
|
+
* the distribution slug plus the session id + client session key (re-verified inside the
|
|
15
|
+
* service on every mutating call) are the credentials.
|
|
16
|
+
*
|
|
17
|
+
* `GET {RootPath}/:slug` is the first-party public page. It hosts the reusable
|
|
18
|
+
* Angular `<mj-checkout-widget>` (as `<mj-orders-checkout>`) when the element
|
|
19
|
+
* bundle is built; otherwise a vanilla fallback. Extension fields come from
|
|
20
|
+
* ProductType.OrderLineExtensionEntity metadata — not a hard-coded event form.
|
|
21
|
+
* MJ auto-loads this extension from `@mj-biz-apps/orders-server`'s `MJ_SERVER_EXTENSIONS`
|
|
22
|
+
* when the package is listed in the host `dynamicPackages.server[]`.
|
|
23
|
+
*
|
|
24
|
+
* ═══ THE GATE SEQUENCE, in order, all fail-closed ═══
|
|
25
|
+
*
|
|
26
|
+
* 1. BODY CAP — `express.json()` scoped to these routes with a small limit. Checkout inputs
|
|
27
|
+
* are tiny; anything large is abuse.
|
|
28
|
+
* 2. RATE LIMIT — fixed-window, per client IP (and per IP+slug on initialize), in-memory.
|
|
29
|
+
* Session initialization is a row-insert primitive and Person resolution touches the
|
|
30
|
+
* database; both must be bounded per caller.
|
|
31
|
+
* 3. ORIGIN ALLOWLIST — when the widget's admin-authored Configuration sets
|
|
32
|
+
* `allowedOrigins`, a browser request from any other origin is refused AND receives no
|
|
33
|
+
* CORS grant (so the browser blocks the response either way). With no allowlist
|
|
34
|
+
* configured, any origin is allowed — the distribution slug remains the access control.
|
|
35
|
+
* 4. TURNSTILE — when the widget sets `requireTurnstile`, session initialization and
|
|
36
|
+
* completion demand a Cloudflare Turnstile token, verified server-side against the secret
|
|
37
|
+
* named by `Settings.TurnstileSecretEnvVar`. Required-but-unconfigured verifies as a 503,
|
|
38
|
+
* never as a silent pass.
|
|
39
|
+
*
|
|
40
|
+
* ═══ THE PRICING-INPUT RULE HOLDS AT THIS BOUNDARY ═══
|
|
41
|
+
* The request bodies accepted here carry NO amount, price, product resolution or provider.
|
|
42
|
+
* `CheckoutLineInput` has no price field; the payment-intent route takes only the session
|
|
43
|
+
* id + key (amount comes from the session's server-priced snapshot; the provider from the
|
|
44
|
+
* widget's Configuration). Anything else in a request body is ignored.
|
|
45
|
+
*
|
|
46
|
+
* ═══ THE ACTING USER ═══
|
|
47
|
+
* Writes run as the service principal named by `Settings.ServiceUserEmail` when configured —
|
|
48
|
+
* a named checkout principal is auditable and permission-scopeable — falling back to MJ's
|
|
49
|
+
* system user (with a logged warning) so a bare install still works. Resolved per request,
|
|
50
|
+
* same reasoning as the payment webhook: the user cache may not be ready at Initialize time.
|
|
51
|
+
*
|
|
52
|
+
* CONNECTS TO:
|
|
53
|
+
* SERVICE: @mj-biz-apps/orders-core-entities-server → CheckoutSessionService
|
|
54
|
+
* CONFIG: mj.config.cjs → serverExtensions[] (DriverClass 'OrdersCheckoutEdge')
|
|
55
|
+
* PLAN: aidp-next plans/aidp-unified-transactions-plan.md §3.2
|
|
56
|
+
*/
|
|
57
|
+
import BodyParser from 'body-parser';
|
|
58
|
+
import { LogError, LogStatus, RunView } from '@memberjunction/core';
|
|
59
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
60
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
61
|
+
import { BaseServerExtension, } from '@memberjunction/server-extensions-core';
|
|
62
|
+
import { CheckoutSessionService, EscapeText } from '@mj-biz-apps/orders-core-entities-server';
|
|
63
|
+
import { randomBytes } from 'node:crypto';
|
|
64
|
+
import { existsSync } from 'node:fs';
|
|
65
|
+
import { createRequire } from 'node:module';
|
|
66
|
+
import path from 'node:path';
|
|
67
|
+
import { fileURLToPath } from 'node:url';
|
|
68
|
+
import { isValidCheckoutSlug, originAllowed as originIsAllowed, resolveClientIp } from './checkout-edge-policy.js';
|
|
69
|
+
import { checkoutHostSecurityHeaders, renderCheckoutHostErrorPage, renderCheckoutHostPage } from './checkout-host-page.js';
|
|
70
|
+
/** Checkout request bodies are small; anything larger is abuse, not commerce. */
|
|
71
|
+
const MAX_BODY = '256kb';
|
|
72
|
+
/** Fixed-window rate limit defaults (overridable via extension Settings). */
|
|
73
|
+
const DEFAULT_RATE_WINDOW_MS = 60_000;
|
|
74
|
+
const DEFAULT_RATE_MAX_PER_WINDOW = 30;
|
|
75
|
+
/** Aggregate cap per IP across all slugs — sits on top of the per-slug window. */
|
|
76
|
+
const DEFAULT_RATE_MAX_GLOBAL = 90;
|
|
77
|
+
const REAP_INTERVAL_MS = 60_000;
|
|
78
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
79
|
+
/** Directory of the `<mj-orders-checkout>` Angular Element bundle, if it was built. */
|
|
80
|
+
function resolveCheckoutElementDir() {
|
|
81
|
+
const candidates = [];
|
|
82
|
+
try {
|
|
83
|
+
const entry = requireFromHere.resolve('@mj-biz-apps/orders-ng');
|
|
84
|
+
candidates.push(path.join(path.dirname(entry), 'checkout-element'));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* package not resolvable */
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
91
|
+
candidates.push(path.resolve(here, '../../Angular/dist/checkout-element'));
|
|
92
|
+
candidates.push(path.resolve(here, '../../../Angular/dist/checkout-element'));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* non-file URL */
|
|
96
|
+
}
|
|
97
|
+
for (const dir of candidates) {
|
|
98
|
+
if (existsSync(path.join(dir, 'main.js'))) {
|
|
99
|
+
return dir;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/** Bounded size of the rate-limit map — oldest windows evict first. */
|
|
105
|
+
const RATE_CACHE_MAX = 50_000;
|
|
106
|
+
const CHECKOUT_SESSION_ENTITY = 'MJ_BizApps_Orders: Checkout Sessions';
|
|
107
|
+
const CHECKOUT_DISTRIBUTION_ENTITY = 'MJ_BizApps_Orders: Checkout Widget Distributions';
|
|
108
|
+
const CHECKOUT_WIDGET_ENTITY = 'MJ_BizApps_Orders: Checkout Widgets';
|
|
109
|
+
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
110
|
+
/** Source maps on the public checkout element are opt-in (dev). Default: do not serve. */
|
|
111
|
+
export function shouldServeCheckoutElementSourceMap(settings, env = process.env) {
|
|
112
|
+
if (settings.ServeElementSourceMap === true) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (settings.ServeElementSourceMap === false) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return env.CHECKOUT_ELEMENT_SOURCEMAP === '1';
|
|
119
|
+
}
|
|
120
|
+
let CheckoutServerExtension = class CheckoutServerExtension extends BaseServerExtension {
|
|
121
|
+
constructor() {
|
|
122
|
+
super(...arguments);
|
|
123
|
+
this.settings = {};
|
|
124
|
+
this.rateWindows = new Map();
|
|
125
|
+
this.warnedSystemUserFallback = false;
|
|
126
|
+
this.rootPath = '/checkout';
|
|
127
|
+
this.lastReapAt = 0;
|
|
128
|
+
}
|
|
129
|
+
async Initialize(app, config) {
|
|
130
|
+
this.settings = (config.Settings ?? {});
|
|
131
|
+
const root = config.RootPath.replace(/\/+$/, '') || '/checkout';
|
|
132
|
+
this.rootPath = root;
|
|
133
|
+
const json = BodyParser.json({ limit: MAX_BODY });
|
|
134
|
+
const elementDir = resolveCheckoutElementDir();
|
|
135
|
+
if (elementDir) {
|
|
136
|
+
app.get(`${root}/element/main.js`, (_req, res) => {
|
|
137
|
+
res.type('application/javascript').sendFile(path.join(elementDir, 'main.js'));
|
|
138
|
+
});
|
|
139
|
+
if (shouldServeCheckoutElementSourceMap(this.settings)) {
|
|
140
|
+
app.get(`${root}/element/main.js.map`, (_req, res) => {
|
|
141
|
+
res.type('application/json').sendFile(path.join(elementDir, 'main.js.map'));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
LogStatus(`[Orders] Checkout Angular Element served from ${elementDir} at GET ${root}/element/main.js`);
|
|
145
|
+
}
|
|
146
|
+
const routes = [
|
|
147
|
+
[`${root}/initialize`, (req, res) => this.handleInitialize(req, res)],
|
|
148
|
+
[`${root}/draft`, (req, res) => this.handleDraft(req, res)],
|
|
149
|
+
[`${root}/payment-intent`, (req, res) => this.handlePaymentIntent(req, res)],
|
|
150
|
+
[`${root}/complete`, (req, res) => this.handleComplete(req, res)],
|
|
151
|
+
];
|
|
152
|
+
for (const [path, handler] of routes) {
|
|
153
|
+
// CORS preflight: answered per-widget after the origin gate resolves the policy.
|
|
154
|
+
app.options(path, (req, res) => {
|
|
155
|
+
void this.handlePreflight(req, res);
|
|
156
|
+
});
|
|
157
|
+
app.post(path, json, (req, res, _next) => {
|
|
158
|
+
void this.guardAndRun(req, res, handler);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const hostPath = `${root}/:slug`;
|
|
162
|
+
app.get(hostPath, (req, res) => this.handleGetHost(req, res));
|
|
163
|
+
LogStatus(`[Orders] Checkout edge registered at GET ${hostPath} and POST ${root}/{initialize,draft,payment-intent,complete}`);
|
|
164
|
+
return {
|
|
165
|
+
Success: true,
|
|
166
|
+
Message: 'Orders anonymous checkout edge mounted (public GET host, rate-limited POSTs, origin-gated, optional Turnstile).',
|
|
167
|
+
RegisteredRoutes: [...routes.map(([path]) => `POST ${path}`), `GET ${hostPath}`],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
async Shutdown() {
|
|
171
|
+
this.rateWindows.clear();
|
|
172
|
+
}
|
|
173
|
+
async HealthCheck() {
|
|
174
|
+
const user = this.resolveActingUser();
|
|
175
|
+
return user
|
|
176
|
+
? { Healthy: true, Name: 'OrdersCheckoutEdge' }
|
|
177
|
+
: {
|
|
178
|
+
Healthy: false,
|
|
179
|
+
Name: 'OrdersCheckoutEdge',
|
|
180
|
+
Details: { Reason: 'Neither the configured service user nor the system user resolves; every checkout call will be refused.' },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// ─── Gate pipeline ────────────────────────────────────────────────────────
|
|
184
|
+
/** Runs the shared gates (rate limit → origin → turnstile where required) then the handler. */
|
|
185
|
+
async guardAndRun(req, res, handler) {
|
|
186
|
+
try {
|
|
187
|
+
const ip = this.clientIp(req);
|
|
188
|
+
const slug = typeof req.body?.slug === 'string' ? req.body.slug : '';
|
|
189
|
+
const globalMax = this.settings.RateLimitMaxGlobal ?? DEFAULT_RATE_MAX_GLOBAL;
|
|
190
|
+
if (this.rateLimitExceeded(`${ip}|*`, globalMax) || this.rateLimitExceeded(`${ip}|${slug}`)) {
|
|
191
|
+
res.status(429).json({ Success: false, ErrorMessage: 'Too many requests — slow down and try again shortly.' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const policy = await this.resolveEdgePolicy(req);
|
|
195
|
+
if (policy === null) {
|
|
196
|
+
// Session/slug did not resolve; let the service produce its own not-found
|
|
197
|
+
// message with no origin grant beyond a same-origin default.
|
|
198
|
+
await handler(req, res);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!this.applyOriginGate(req, res, policy)) {
|
|
202
|
+
return; // refused; response already written
|
|
203
|
+
}
|
|
204
|
+
if (policy.requireTurnstile && this.isTurnstileGatedPath(req)) {
|
|
205
|
+
const turnstileFailure = await this.verifyTurnstile(req);
|
|
206
|
+
if (turnstileFailure) {
|
|
207
|
+
const [status, message] = turnstileFailure;
|
|
208
|
+
res.status(status).json({ Success: false, ErrorMessage: message });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await handler(req, res);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
216
|
+
LogError(`[OrdersCheckoutEdge] Unhandled error on ${req.path}: ${msg}`);
|
|
217
|
+
if (!res.headersSent) {
|
|
218
|
+
res.status(500).json({ Success: false, ErrorMessage: 'Checkout is temporarily unavailable.' });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** OPTIONS preflight: grant CORS only to origins the widget policy allows. */
|
|
223
|
+
async handlePreflight(req, res) {
|
|
224
|
+
try {
|
|
225
|
+
const policy = (await this.resolveEdgePolicy(req)) ?? {};
|
|
226
|
+
const origin = req.headers.origin;
|
|
227
|
+
if (origin && this.originAllowed(origin, policy, req)) {
|
|
228
|
+
this.setCorsHeaders(res, origin);
|
|
229
|
+
res.status(204).end();
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
res.status(403).end();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
res.status(403).end();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Resolves the widget edge policy (allowed origins + turnstile requirement) for this
|
|
241
|
+
* request — by slug on initialize, by session id on the other routes. Returns null when
|
|
242
|
+
* nothing resolves (unknown slug/session): the underlying service will answer with its
|
|
243
|
+
* own refusal.
|
|
244
|
+
*/
|
|
245
|
+
async resolveEdgePolicy(req) {
|
|
246
|
+
const user = this.resolveActingUser();
|
|
247
|
+
if (!user) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const rv = new RunView();
|
|
251
|
+
let widgetId = null;
|
|
252
|
+
const slug = this.requestSlug(req);
|
|
253
|
+
const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId.trim() : '';
|
|
254
|
+
if (slug) {
|
|
255
|
+
const distRes = await rv.RunView({
|
|
256
|
+
EntityName: CHECKOUT_DISTRIBUTION_ENTITY,
|
|
257
|
+
Fields: ['ID', 'CheckoutWidgetID'],
|
|
258
|
+
ExtraFilter: `Slug = '${EscapeText(slug)}' AND Status = 'Active'`,
|
|
259
|
+
ResultType: 'simple',
|
|
260
|
+
}, user);
|
|
261
|
+
widgetId = distRes?.Success && distRes.Results?.length ? distRes.Results[0].CheckoutWidgetID : null;
|
|
262
|
+
}
|
|
263
|
+
else if (sessionId && /^[0-9a-f-]{36}$/i.test(sessionId)) {
|
|
264
|
+
const sessRes = await rv.RunView({
|
|
265
|
+
EntityName: CHECKOUT_SESSION_ENTITY,
|
|
266
|
+
Fields: ['ID', 'CheckoutWidgetID'],
|
|
267
|
+
ExtraFilter: `ID = '${EscapeText(sessionId)}'`,
|
|
268
|
+
ResultType: 'simple',
|
|
269
|
+
}, user);
|
|
270
|
+
widgetId = sessRes?.Success && sessRes.Results?.length ? sessRes.Results[0].CheckoutWidgetID : null;
|
|
271
|
+
}
|
|
272
|
+
if (!widgetId) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const widgetRes = await rv.RunView({
|
|
276
|
+
EntityName: CHECKOUT_WIDGET_ENTITY,
|
|
277
|
+
Fields: ['ID', 'Configuration'],
|
|
278
|
+
ExtraFilter: `ID = '${EscapeText(widgetId)}'`,
|
|
279
|
+
ResultType: 'simple',
|
|
280
|
+
}, user);
|
|
281
|
+
const configRaw = widgetRes?.Success && widgetRes.Results?.length ? widgetRes.Results[0].Configuration : null;
|
|
282
|
+
if (!configRaw) {
|
|
283
|
+
return {};
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const config = JSON.parse(configRaw);
|
|
287
|
+
return {
|
|
288
|
+
allowedOrigins: Array.isArray(config.allowedOrigins) ? config.allowedOrigins.filter((o) => typeof o === 'string') : undefined,
|
|
289
|
+
requireTurnstile: config.requireTurnstile === true,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Enforces the per-widget origin allowlist; grants CORS to allowed origins. */
|
|
297
|
+
applyOriginGate(req, res, policy) {
|
|
298
|
+
const origin = req.headers.origin;
|
|
299
|
+
if (!origin) {
|
|
300
|
+
// Non-browser client (no Origin header): nothing to grant, nothing to block here —
|
|
301
|
+
// the session credentials remain the gate.
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
if (!this.originAllowed(origin, policy, req)) {
|
|
305
|
+
res.status(403).json({ Success: false, ErrorMessage: 'This origin is not allowed to use this checkout.' });
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
this.setCorsHeaders(res, origin);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
originAllowed(origin, policy, req) {
|
|
312
|
+
return originIsAllowed(origin, policy, typeof req.headers.host === 'string' ? req.headers.host : undefined);
|
|
313
|
+
}
|
|
314
|
+
setCorsHeaders(res, origin) {
|
|
315
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
316
|
+
res.setHeader('Vary', 'Origin');
|
|
317
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
318
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
319
|
+
res.setHeader('Access-Control-Max-Age', '600');
|
|
320
|
+
}
|
|
321
|
+
/** Turnstile applies to the abuse-sensitive endpoints: initialize and complete. */
|
|
322
|
+
isTurnstileGatedPath(req) {
|
|
323
|
+
return req.path.endsWith('/initialize') || req.path.endsWith('/complete');
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Verifies the Cloudflare Turnstile token. Returns null on success, or [status, message]
|
|
327
|
+
* on refusal. A widget that REQUIRES Turnstile with no secret configured fails closed
|
|
328
|
+
* with a 503 — a silent pass would advertise protection that is not running.
|
|
329
|
+
*/
|
|
330
|
+
async verifyTurnstile(req) {
|
|
331
|
+
const envVar = this.settings.TurnstileSecretEnvVar;
|
|
332
|
+
const secret = envVar ? process.env[envVar] : undefined;
|
|
333
|
+
if (!secret) {
|
|
334
|
+
LogError(`[OrdersCheckoutEdge] Widget requires Turnstile but no secret is configured (Settings.TurnstileSecretEnvVar${envVar ? `='${envVar}' resolved empty` : ' unset'})`);
|
|
335
|
+
return [503, 'Checkout verification is not configured — contact the site operator.'];
|
|
336
|
+
}
|
|
337
|
+
const token = typeof req.body?.turnstileToken === 'string' ? req.body.turnstileToken : '';
|
|
338
|
+
if (!token) {
|
|
339
|
+
return [403, 'Human verification is required for this checkout.'];
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const params = new URLSearchParams({ secret, response: token, remoteip: this.clientIp(req) });
|
|
343
|
+
const verifyRes = await fetch(TURNSTILE_VERIFY_URL, {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
346
|
+
body: params.toString(),
|
|
347
|
+
});
|
|
348
|
+
const outcome = (await verifyRes.json());
|
|
349
|
+
return outcome?.success === true ? null : [403, 'Human verification failed — please try again.'];
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
353
|
+
LogError(`[OrdersCheckoutEdge] Turnstile verification call failed: ${msg}`);
|
|
354
|
+
return [503, 'Human verification is temporarily unavailable — please try again.'];
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// ─── Public host page ─────────────────────────────────────────────────────
|
|
358
|
+
/**
|
|
359
|
+
* `GET /checkout/:slug` — first-party HTML that talks to the POST edge.
|
|
360
|
+
* Unknown/reserved slugs 404; missing service principal is a 503. The page
|
|
361
|
+
* itself is cache-free; initialize still enforces Active widget + distribution.
|
|
362
|
+
*/
|
|
363
|
+
async handleGetHost(req, res) {
|
|
364
|
+
try {
|
|
365
|
+
const slug = typeof req.params?.slug === 'string' ? req.params.slug.trim() : '';
|
|
366
|
+
if (!isValidCheckoutSlug(slug)) {
|
|
367
|
+
this.sendHostError(res, 404, 'This checkout link is not valid.');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const ip = this.clientIp(req);
|
|
371
|
+
const globalMax = this.settings.RateLimitMaxGlobal ?? DEFAULT_RATE_MAX_GLOBAL;
|
|
372
|
+
if (this.rateLimitExceeded(`${ip}|*`, globalMax) || this.rateLimitExceeded(`${ip}|get|${slug}`)) {
|
|
373
|
+
this.sendHostError(res, 429, 'Too many requests — slow down and try again shortly.');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const user = this.resolveActingUser();
|
|
377
|
+
if (!user) {
|
|
378
|
+
this.sendHostError(res, 503, 'Checkout is not ready — please try again shortly.');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const exists = await this.activeDistributionExists(slug, user);
|
|
382
|
+
if (!exists) {
|
|
383
|
+
this.sendHostError(res, 404, 'This checkout is not available.');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
this.maybeReapExpiredSessions(user);
|
|
387
|
+
const nonce = randomBytes(16).toString('base64url');
|
|
388
|
+
this.applyHostSecurityHeaders(res, nonce);
|
|
389
|
+
res
|
|
390
|
+
.status(200)
|
|
391
|
+
.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
392
|
+
.send(renderCheckoutHostPage({
|
|
393
|
+
slug,
|
|
394
|
+
apiRoot: this.rootPath,
|
|
395
|
+
cspNonce: nonce,
|
|
396
|
+
elementSrc: resolveCheckoutElementDir() ? `${this.rootPath}/element/main.js` : undefined,
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
401
|
+
LogError(`[OrdersCheckoutEdge] Unhandled error on GET host: ${msg}`);
|
|
402
|
+
if (!res.headersSent) {
|
|
403
|
+
this.sendHostError(res, 500, 'Checkout is temporarily unavailable.');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
sendHostError(res, status, message) {
|
|
408
|
+
const nonce = randomBytes(16).toString('base64url');
|
|
409
|
+
this.applyHostSecurityHeaders(res, nonce);
|
|
410
|
+
res
|
|
411
|
+
.status(status)
|
|
412
|
+
.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
413
|
+
.send(renderCheckoutHostErrorPage({ message, cspNonce: nonce }));
|
|
414
|
+
}
|
|
415
|
+
applyHostSecurityHeaders(res, nonce) {
|
|
416
|
+
for (const [name, value] of Object.entries(checkoutHostSecurityHeaders(nonce))) {
|
|
417
|
+
res.setHeader(name, value);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
maybeReapExpiredSessions(user) {
|
|
421
|
+
const now = Date.now();
|
|
422
|
+
if (now - this.lastReapAt < REAP_INTERVAL_MS) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
this.lastReapAt = now;
|
|
426
|
+
void CheckoutSessionService.ReapExpiredOpenSessions(user).catch((err) => {
|
|
427
|
+
LogError(`[OrdersCheckoutEdge] Session reap failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
requestSlug(req) {
|
|
431
|
+
if (typeof req.params?.slug === 'string' && req.params.slug.trim()) {
|
|
432
|
+
return req.params.slug.trim();
|
|
433
|
+
}
|
|
434
|
+
return typeof req.body?.slug === 'string' ? req.body.slug.trim() : '';
|
|
435
|
+
}
|
|
436
|
+
async activeDistributionExists(slug, user) {
|
|
437
|
+
const rv = new RunView();
|
|
438
|
+
const distRes = await rv.RunView({
|
|
439
|
+
EntityName: CHECKOUT_DISTRIBUTION_ENTITY,
|
|
440
|
+
Fields: ['ID'],
|
|
441
|
+
ExtraFilter: `Slug = '${EscapeText(slug)}' AND Status = 'Active'`,
|
|
442
|
+
ResultType: 'simple',
|
|
443
|
+
}, user);
|
|
444
|
+
return !!(distRes?.Success && distRes.Results?.length);
|
|
445
|
+
}
|
|
446
|
+
// ─── Route handlers (thin shells over the service) ───────────────────────
|
|
447
|
+
async handleInitialize(req, res) {
|
|
448
|
+
const user = this.resolveActingUser();
|
|
449
|
+
if (!user) {
|
|
450
|
+
res.status(500).json({ Success: false, ErrorMessage: 'Checkout is not ready — the service principal is unavailable.' });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const slug = typeof req.body?.slug === 'string' ? req.body.slug : '';
|
|
454
|
+
const clientSessionKey = typeof req.body?.clientSessionKey === 'string' ? req.body.clientSessionKey : '';
|
|
455
|
+
this.maybeReapExpiredSessions(user);
|
|
456
|
+
const result = await CheckoutSessionService.InitializeSession(slug, clientSessionKey, user);
|
|
457
|
+
res.status(result.Success ? 200 : 400).json(result);
|
|
458
|
+
}
|
|
459
|
+
async handleDraft(req, res) {
|
|
460
|
+
const user = this.resolveActingUser();
|
|
461
|
+
if (!user) {
|
|
462
|
+
res.status(500).json({ Success: false, ErrorMessage: 'Checkout is not ready — the service principal is unavailable.' });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId : '';
|
|
466
|
+
const clientSessionKey = typeof req.body?.clientSessionKey === 'string' ? req.body.clientSessionKey : '';
|
|
467
|
+
const email = typeof req.body?.email === 'string' ? req.body.email : '';
|
|
468
|
+
const lines = Array.isArray(req.body?.lines) ? req.body.lines : [];
|
|
469
|
+
const result = await CheckoutSessionService.UpdateDraft(sessionId, clientSessionKey, email, lines, user);
|
|
470
|
+
res.status(result.Success ? 200 : 400).json(result);
|
|
471
|
+
}
|
|
472
|
+
async handlePaymentIntent(req, res) {
|
|
473
|
+
const user = this.resolveActingUser();
|
|
474
|
+
if (!user) {
|
|
475
|
+
res.status(500).json({ Success: false, ErrorMessage: 'Checkout is not ready — the service principal is unavailable.' });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId : '';
|
|
479
|
+
const clientSessionKey = typeof req.body?.clientSessionKey === 'string' ? req.body.clientSessionKey : '';
|
|
480
|
+
const result = await CheckoutSessionService.OpenPaymentIntentForSession(sessionId, clientSessionKey, user);
|
|
481
|
+
res.status(result.Success ? 200 : 400).json(result);
|
|
482
|
+
}
|
|
483
|
+
async handleComplete(req, res) {
|
|
484
|
+
const user = this.resolveActingUser();
|
|
485
|
+
if (!user) {
|
|
486
|
+
res.status(500).json({ Success: false, ErrorMessage: 'Checkout is not ready — the service principal is unavailable.' });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId : '';
|
|
490
|
+
const clientSessionKey = typeof req.body?.clientSessionKey === 'string' ? req.body.clientSessionKey : '';
|
|
491
|
+
const result = await CheckoutSessionService.CompleteCheckout(sessionId, clientSessionKey, user);
|
|
492
|
+
res.status(result.Success ? 200 : 409).json(result);
|
|
493
|
+
}
|
|
494
|
+
// ─── Infrastructure ──────────────────────────────────────────────────────
|
|
495
|
+
/** Fixed-window limiter over a bounded, insertion-ordered map. */
|
|
496
|
+
rateLimitExceeded(clientKey, maxPerWindow = this.settings.RateLimitMax ?? DEFAULT_RATE_MAX_PER_WINDOW) {
|
|
497
|
+
const windowMs = this.settings.RateLimitWindowMs ?? DEFAULT_RATE_WINDOW_MS;
|
|
498
|
+
const now = Date.now();
|
|
499
|
+
const entry = this.rateWindows.get(clientKey);
|
|
500
|
+
if (!entry || now - entry.windowStart >= windowMs) {
|
|
501
|
+
if (this.rateWindows.size >= RATE_CACHE_MAX) {
|
|
502
|
+
const oldest = this.rateWindows.keys().next().value;
|
|
503
|
+
if (oldest !== undefined) {
|
|
504
|
+
this.rateWindows.delete(oldest);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
this.rateWindows.set(clientKey, { windowStart: now, count: 1 });
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
entry.count++;
|
|
511
|
+
return entry.count > maxPerWindow;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The client IP for rate limiting and Turnstile. Ignores X-Forwarded-For unless
|
|
515
|
+
* `Settings.TrustedProxyHops` is a positive integer (Nth-from-right hop).
|
|
516
|
+
*/
|
|
517
|
+
clientIp(req) {
|
|
518
|
+
return resolveClientIp(req, this.settings.TrustedProxyHops ?? 0);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* The acting principal for checkout writes: the configured service user when present,
|
|
522
|
+
* else MJ's system user (warned once). Resolved per request — the user cache may not be
|
|
523
|
+
* populated when the extension initializes.
|
|
524
|
+
*/
|
|
525
|
+
resolveActingUser() {
|
|
526
|
+
try {
|
|
527
|
+
const email = this.settings.ServiceUserEmail?.trim().toLowerCase();
|
|
528
|
+
if (email) {
|
|
529
|
+
const named = UserCache.Instance.Users?.find((u) => u.Email?.trim().toLowerCase() === email);
|
|
530
|
+
if (named) {
|
|
531
|
+
return named;
|
|
532
|
+
}
|
|
533
|
+
LogError(`[OrdersCheckoutEdge] Configured ServiceUserEmail '${email}' does not resolve to a user — falling back to the system user.`);
|
|
534
|
+
}
|
|
535
|
+
else if (!this.warnedSystemUserFallback) {
|
|
536
|
+
this.warnedSystemUserFallback = true;
|
|
537
|
+
LogStatus('[OrdersCheckoutEdge] No ServiceUserEmail configured — checkout writes run as the system user. Configure a named checkout principal for auditability.');
|
|
538
|
+
}
|
|
539
|
+
return UserCache.Instance.GetSystemUser() ?? undefined;
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
CheckoutServerExtension = __decorate([
|
|
547
|
+
RegisterClass(BaseServerExtension, 'OrdersCheckoutEdge')
|
|
548
|
+
], CheckoutServerExtension);
|
|
549
|
+
export { CheckoutServerExtension };
|
|
550
|
+
/** Tree-shaking anchor — call from the server bootstrap so @RegisterClass is retained. */
|
|
551
|
+
export function LoadCheckoutServerExtension() {
|
|
552
|
+
// intentionally empty
|
|
553
|
+
}
|
|
554
|
+
//# sourceMappingURL=CheckoutServerExtension.js.map
|