@tokenoftrust/storefront-runner 4.7.10 → 4.8.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/apps/storefront/astro.config.mjs +2 -1
- package/apps/storefront/dev-plugins/dev-auth-outbox.mjs +181 -0
- package/apps/storefront/src/components/content/storefront/blog/PostGrid.astro +8 -4
- package/apps/storefront/src/lib/access/auth/devAuth.ts +34 -2
- package/apps/storefront/src/lib/access/auth/route.ts +36 -2
- package/apps/storefront/src/lib/access/auth/totAccessClient.ts +6 -18
- package/apps/storefront/src/lib/access/dashboard/staffStoreEntry.ts +2 -1
- package/apps/storefront/src/lib/access/dashboard/staffVendorAccess.ts +6 -10
- package/apps/storefront/src/lib/access/shared/accessExplain.ts +106 -28
- package/apps/storefront/src/lib/access/shared/developerInvites.ts +40 -38
- package/apps/storefront/src/lib/access/shared/invitePreflight.ts +1 -1
- package/apps/storefront/src/lib/access/shared/principalGrants.ts +16 -17
- package/apps/storefront/src/lib/content/shared/homeVisualParity.ts +69 -0
- package/apps/storefront/src/lib/content/storyblok/content-model.ts +21 -0
- package/apps/storefront/src/lib/engagement/messaging/messagesClient.ts +13 -7
- package/apps/storefront/src/lib/observability/diag/failureReason.ts +37 -0
- package/apps/storefront/src/lib/observability/diag/redact.ts +11 -14
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +26 -5
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +121 -22
- package/apps/storefront/src/lib/orders/admin/viewmodels/orders.ts +7 -0
- package/apps/storefront/src/lib/orders/fixtures/orders.ts +2 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +4 -7
- package/apps/storefront/src/pages/api/auth/send.ts +12 -12
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +2 -1
- package/apps/storefront/src/pages/api/auth/verify.ts +7 -3
- package/apps/storefront/src/pages/auth/login.astro +24 -6
- package/apps/storefront/src/pages/auth/magic.astro +4 -7
- package/apps/storefront/src/pages/index.astro +43 -1
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +42 -1
- package/packages/public-runtime/src/declared-tenant-config.d.mts +4 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +40 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
- package/packages/public-runtime/src/extension-contract-values.mjs +6 -1
- package/packages/public-runtime/src/extension-contract.ts +2 -0
- package/packages/public-runtime/src/tenant.ts +6 -0
- package/scripts/tenant/validate.mjs +17 -0
|
@@ -54,9 +54,9 @@
|
|
|
54
54
|
*/
|
|
55
55
|
import { readEnv } from "@/lib/publication/shared/env";
|
|
56
56
|
import { callMcpService } from "@/lib/developer-experience/http/mcpServiceCall";
|
|
57
|
-
import { diagEmail } from "@/lib/observability/diag/redact";
|
|
58
57
|
import { checkStorePreviewReady, type PreflightDecision } from "@/lib/access/shared/invitePreflight";
|
|
59
58
|
import { explainDeveloperAccess, type AccessVerification } from "@/lib/access/shared/accessExplain";
|
|
59
|
+
import { failureReasonForStatus, type FailureReason } from "@/lib/observability/diag/failureReason";
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* The header carrying the storefront-signed staff BROKER ASSERTION to tot20
|
|
@@ -444,8 +444,6 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
444
444
|
// the email-send trace). `signInUrlHost` is the top allowlist suspect.
|
|
445
445
|
logInviteDiag("request", {
|
|
446
446
|
endpoint: `${this.baseUrl}/api/clients/developerInvites`,
|
|
447
|
-
tenant: input.tenant,
|
|
448
|
-
email: diagEmail(input.email.toLowerCase()),
|
|
449
447
|
sendEmail: input.sendEmail ?? "(unset → tot20 default)",
|
|
450
448
|
hasSignInUrl: !!input.signInUrl,
|
|
451
449
|
signInUrlHost: safeHost(input.signInUrl),
|
|
@@ -460,14 +458,12 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
460
458
|
headers,
|
|
461
459
|
body,
|
|
462
460
|
);
|
|
463
|
-
} catch
|
|
461
|
+
} catch {
|
|
464
462
|
return {
|
|
465
463
|
ok: false,
|
|
466
464
|
status: 0,
|
|
467
465
|
reason: "error",
|
|
468
|
-
message:
|
|
469
|
-
"developerInvites: transport failure calling tot20 " +
|
|
470
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
466
|
+
message: "developerInvites: transport failure calling tot20",
|
|
471
467
|
};
|
|
472
468
|
}
|
|
473
469
|
|
|
@@ -486,19 +482,17 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
486
482
|
// DIAGNOSTIC: tot20's decision — the crux is `emailed`. `changed:false, emailed:false`
|
|
487
483
|
// means tot20 authorized + already had the role but did NOT send (the gate we're hunting).
|
|
488
484
|
logInviteDiag("response", {
|
|
489
|
-
tenant: input.tenant,
|
|
490
|
-
email: diagEmail(input.email.toLowerCase()),
|
|
491
485
|
httpStatus: res.status,
|
|
492
486
|
ok: result.ok,
|
|
493
487
|
...(result.ok
|
|
494
488
|
? {
|
|
495
489
|
changed: result.changed,
|
|
496
490
|
emailed: result.emailed,
|
|
497
|
-
|
|
498
|
-
|
|
491
|
+
hasEmailReason: !!result.emailReason,
|
|
492
|
+
hasEmailDetail: !!result.emailDetail,
|
|
499
493
|
signInEligible: result.signInEligible,
|
|
500
494
|
}
|
|
501
|
-
: { reason: result.reason,
|
|
495
|
+
: { reason: result.reason, hasCode: !!result.code }),
|
|
502
496
|
});
|
|
503
497
|
return result;
|
|
504
498
|
}
|
|
@@ -529,14 +523,12 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
529
523
|
`${this.baseUrl}/api/clients/developerInvites?${q.toString()}`,
|
|
530
524
|
headers,
|
|
531
525
|
);
|
|
532
|
-
} catch
|
|
526
|
+
} catch {
|
|
533
527
|
return {
|
|
534
528
|
ok: false,
|
|
535
529
|
status: 0,
|
|
536
530
|
reason: "error",
|
|
537
|
-
message:
|
|
538
|
-
"developerInvites: transport failure listing invites " +
|
|
539
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
531
|
+
message: "developerInvites: transport failure listing invites",
|
|
540
532
|
};
|
|
541
533
|
}
|
|
542
534
|
let parsed: unknown;
|
|
@@ -576,14 +568,12 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
|
|
|
576
568
|
`${this.baseUrl}/api/clients/developerInvites/${encodeURIComponent(inviteId)}`,
|
|
577
569
|
headers,
|
|
578
570
|
);
|
|
579
|
-
} catch
|
|
571
|
+
} catch {
|
|
580
572
|
return {
|
|
581
573
|
ok: false,
|
|
582
574
|
status: 0,
|
|
583
575
|
reason: "error",
|
|
584
|
-
message:
|
|
585
|
-
"developerInvites: transport failure revoking invite " +
|
|
586
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
576
|
+
message: "developerInvites: transport failure revoking invite",
|
|
587
577
|
};
|
|
588
578
|
}
|
|
589
579
|
let parsed: unknown;
|
|
@@ -801,6 +791,29 @@ export function developerInviteHttpStatus(
|
|
|
801
791
|
}
|
|
802
792
|
}
|
|
803
793
|
|
|
794
|
+
/**
|
|
795
|
+
* Client-facing copy for a developer-invite failure. Routes answer with this, never with
|
|
796
|
+
* the result's `message` (which can carry tot20's own text).
|
|
797
|
+
*/
|
|
798
|
+
export function developerInviteFailureMessage(reason: DeveloperInviteFailureReason): string {
|
|
799
|
+
switch (reason) {
|
|
800
|
+
case "notGranted":
|
|
801
|
+
return "storefront isn't authorized to manage developers on this vendor";
|
|
802
|
+
case "notAuthenticated":
|
|
803
|
+
return "storefront's operator credential was rejected";
|
|
804
|
+
case "tenantRequired":
|
|
805
|
+
return "a target vendor is required";
|
|
806
|
+
case "invalidEmail":
|
|
807
|
+
return "a valid developer email is required";
|
|
808
|
+
case "tenantNotFound":
|
|
809
|
+
return "this vendor isn't a resolvable Token of Trust app";
|
|
810
|
+
case "storeNotProvisioned":
|
|
811
|
+
return "this vendor's store repo isn't provisioned on this environment yet";
|
|
812
|
+
default:
|
|
813
|
+
return "Token of Trust couldn't complete the request; try again shortly";
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
804
817
|
function defaultMessage(
|
|
805
818
|
reason: DeveloperInviteFailureReason,
|
|
806
819
|
status: number,
|
|
@@ -1108,9 +1121,9 @@ export async function recordDeveloperAppBinding(
|
|
|
1108
1121
|
export type AcceptInviteResult =
|
|
1109
1122
|
| { accepted: true; changed: boolean }
|
|
1110
1123
|
// Not attempted (seam unconfigured) or no invite to accept — not a hard failure.
|
|
1111
|
-
| { accepted: false; skipped: true; reason:
|
|
1124
|
+
| { accepted: false; skipped: true; reason: "no-tenant-or-email" | "not-configured" | "no-invite-for-email" }
|
|
1112
1125
|
// Attempted but failed for a real reason (auth / store fault / transport).
|
|
1113
|
-
| { accepted: false; skipped: false; reason:
|
|
1126
|
+
| { accepted: false; skipped: false; reason: FailureReason };
|
|
1114
1127
|
|
|
1115
1128
|
/**
|
|
1116
1129
|
* Mark a developer invite ACCEPTED at the magic-link confirm (#8425 accept-before-
|
|
@@ -1167,19 +1180,9 @@ export async function acceptDeveloperInvite(
|
|
|
1167
1180
|
if (res.status === 404) {
|
|
1168
1181
|
return { accepted: false, skipped: true, reason: "no-invite-for-email" };
|
|
1169
1182
|
}
|
|
1170
|
-
return {
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
reason: `tot20 accept returned status ${res.status}`,
|
|
1174
|
-
};
|
|
1175
|
-
} catch (cause) {
|
|
1176
|
-
return {
|
|
1177
|
-
accepted: false,
|
|
1178
|
-
skipped: false,
|
|
1179
|
-
reason:
|
|
1180
|
-
"developerInvites accept transport failure: " +
|
|
1181
|
-
(cause instanceof Error ? cause.message : String(cause)),
|
|
1182
|
-
};
|
|
1183
|
+
return { accepted: false, skipped: false, reason: failureReasonForStatus(res.status) };
|
|
1184
|
+
} catch {
|
|
1185
|
+
return { accepted: false, skipped: false, reason: "transport" };
|
|
1183
1186
|
}
|
|
1184
1187
|
}
|
|
1185
1188
|
|
|
@@ -1258,9 +1261,8 @@ export async function inviteDeveloperToTenant(
|
|
|
1258
1261
|
const preflight = deps.preflight ?? checkStorePreviewReady;
|
|
1259
1262
|
const ready = await preflight(input.tenant);
|
|
1260
1263
|
if (ready.block) {
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
);
|
|
1264
|
+
// Finite fields only: no tenant, no free-form preflight reason.
|
|
1265
|
+
console.warn("[developerInvites] invite blocked by preflight");
|
|
1264
1266
|
return {
|
|
1265
1267
|
invite: {
|
|
1266
1268
|
ok: false,
|
|
@@ -82,7 +82,7 @@ export async function checkStorePreviewReady(
|
|
|
82
82
|
if (outcome.kind === "not-configured") return { block: false, reason: "not-configured" };
|
|
83
83
|
if (outcome.kind === "bad-url") return { block: false, reason: "bad-mcp-url" };
|
|
84
84
|
if (outcome.kind === "transport-error") {
|
|
85
|
-
return { block: false, reason: "preflight request failed
|
|
85
|
+
return { block: false, reason: "preflight request failed" };
|
|
86
86
|
}
|
|
87
87
|
if (outcome.kind === "misconfigured") {
|
|
88
88
|
return { block: false, reason: `preflight misconfigured: status ${outcome.status}` };
|
|
@@ -76,7 +76,6 @@
|
|
|
76
76
|
* swaps in the Http client with no call-site change.
|
|
77
77
|
*/
|
|
78
78
|
import { readEnv } from "@/lib/publication/shared/env";
|
|
79
|
-
import { diagEmail } from "@/lib/observability/diag/redact";
|
|
80
79
|
import { BROKER_ASSERTION_HEADER, BROKER_PRIVATE_KEY_ENV } from "@/lib/access/auth/brokerAssertion";
|
|
81
80
|
|
|
82
81
|
/** A human grantee — the only principal type this HTTP surface accepts. */
|
|
@@ -285,8 +284,6 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
285
284
|
|
|
286
285
|
logGrantDiag("request", {
|
|
287
286
|
endpoint: `${this.baseUrl}/api/clients/principalGrants`,
|
|
288
|
-
tenant: input.tenant,
|
|
289
|
-
email: diagEmail(input.principal.email.toLowerCase()),
|
|
290
287
|
capability: input.capability,
|
|
291
288
|
expiresAt: input.expiresAt,
|
|
292
289
|
});
|
|
@@ -298,17 +295,18 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
298
295
|
this.brokeredHeaders(assertion, false),
|
|
299
296
|
body,
|
|
300
297
|
);
|
|
301
|
-
} catch
|
|
302
|
-
return transportFailure("creating principal grant"
|
|
298
|
+
} catch {
|
|
299
|
+
return transportFailure("creating principal grant");
|
|
303
300
|
}
|
|
304
301
|
const parsed = await safeJson(res, "creating principal grant");
|
|
305
302
|
if ("failed" in parsed) return parsed.failed;
|
|
306
303
|
const result = normalizeCreateResponse(res.status, parsed.body);
|
|
307
304
|
logGrantDiag("response", {
|
|
308
|
-
tenant: input.tenant,
|
|
309
305
|
httpStatus: res.status,
|
|
310
306
|
ok: result.ok,
|
|
311
|
-
...(result.ok
|
|
307
|
+
...(result.ok
|
|
308
|
+
? { hasRef: !!result.ref }
|
|
309
|
+
: { reason: result.reason, hasCode: !!result.code }),
|
|
312
310
|
});
|
|
313
311
|
return result;
|
|
314
312
|
}
|
|
@@ -324,8 +322,8 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
324
322
|
`${this.baseUrl}/api/clients/principalGrants?tenant=${encodeURIComponent(tenant)}`,
|
|
325
323
|
this.brokeredHeaders(assertion, true),
|
|
326
324
|
);
|
|
327
|
-
} catch
|
|
328
|
-
return transportFailure("listing principal grants"
|
|
325
|
+
} catch {
|
|
326
|
+
return transportFailure("listing principal grants");
|
|
329
327
|
}
|
|
330
328
|
const parsed = await safeJson(res, "listing principal grants");
|
|
331
329
|
if ("failed" in parsed) return parsed.failed;
|
|
@@ -345,8 +343,8 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
345
343
|
`?tenant=${encodeURIComponent(tenant)}`,
|
|
346
344
|
this.brokeredHeaders(assertion, true),
|
|
347
345
|
);
|
|
348
|
-
} catch
|
|
349
|
-
return transportFailure("revoking principal grant"
|
|
346
|
+
} catch {
|
|
347
|
+
return transportFailure("revoking principal grant");
|
|
350
348
|
}
|
|
351
349
|
const parsed = await safeJson(res, "revoking principal grant");
|
|
352
350
|
if ("failed" in parsed) return parsed.failed;
|
|
@@ -365,12 +363,12 @@ function brokerNotConfigured(): PrincipalGrantFailed {
|
|
|
365
363
|
};
|
|
366
364
|
}
|
|
367
365
|
|
|
368
|
-
async function transportFailure(action: string
|
|
366
|
+
async function transportFailure(action: string): Promise<PrincipalGrantFailed> {
|
|
369
367
|
return {
|
|
370
368
|
ok: false,
|
|
371
369
|
status: 0,
|
|
372
370
|
reason: "error",
|
|
373
|
-
message: `principalGrants: transport failure ${action}
|
|
371
|
+
message: `principalGrants: transport failure ${action}`,
|
|
374
372
|
};
|
|
375
373
|
}
|
|
376
374
|
|
|
@@ -458,11 +456,12 @@ function failureFrom(
|
|
|
458
456
|
const code = typeof c.code === "string" ? c.code : undefined;
|
|
459
457
|
const reason = reasonFor(code, status);
|
|
460
458
|
const upstream = typeof c.message === "string" ? c.message : undefined;
|
|
461
|
-
const message = reason === "error" && upstream ? upstream :
|
|
459
|
+
const message = reason === "error" && upstream ? upstream : principalGrantFailureMessage(reason);
|
|
462
460
|
return { status, reason, message, ...(code ? { code } : {}) };
|
|
463
461
|
}
|
|
464
462
|
|
|
465
|
-
|
|
463
|
+
/** Finite operator-facing copy for a principal-grant refusal — the only text a route returns. */
|
|
464
|
+
export function principalGrantFailureMessage(reason: PrincipalGrantFailureReason): string {
|
|
466
465
|
switch (reason) {
|
|
467
466
|
case "notAuthenticated":
|
|
468
467
|
return "storefront isn't authorized to grant on this tenant";
|
|
@@ -486,7 +485,7 @@ function defaultMessage(reason: PrincipalGrantFailureReason, status: number): st
|
|
|
486
485
|
case "brokerNotConfigured":
|
|
487
486
|
return "delegation isn't available on this deployment";
|
|
488
487
|
default:
|
|
489
|
-
return
|
|
488
|
+
return "Token of Trust couldn't complete the delegation; try again shortly";
|
|
490
489
|
}
|
|
491
490
|
}
|
|
492
491
|
|
|
@@ -578,7 +577,7 @@ export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
578
577
|
ok: false,
|
|
579
578
|
status: 403,
|
|
580
579
|
reason: "subjectNotTenantOwner",
|
|
581
|
-
message:
|
|
580
|
+
message: principalGrantFailureMessage("subjectNotTenantOwner"),
|
|
582
581
|
code: "operatorGrants:subjectNotTenantOwner",
|
|
583
582
|
};
|
|
584
583
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
+
LATEST_POSTS_DEFAULT_LIMIT,
|
|
3
|
+
LATEST_POSTS_MAX_LIMIT,
|
|
2
4
|
isPageBlockImageReference,
|
|
3
5
|
isSafeStorefrontHref,
|
|
4
6
|
} from "@tot/public-runtime";
|
|
5
7
|
import { withBase } from "@/lib/publication/shared/basePath";
|
|
8
|
+
import { categorySlug } from "@/lib/content/blog/collection.js";
|
|
9
|
+
import type { BlogPostSummary } from "@/lib/content/blog/types.js";
|
|
6
10
|
import type {
|
|
7
11
|
CommerceTrustStripBlock,
|
|
8
12
|
EditorialBlock,
|
|
9
13
|
FaqBlock,
|
|
10
14
|
HomeBlock,
|
|
15
|
+
LatestPostsBlock,
|
|
11
16
|
MarketingBlock,
|
|
12
17
|
MembershipBandBlock,
|
|
13
18
|
NewsletterBlock,
|
|
@@ -142,6 +147,70 @@ export function editorialBlockView(block: EditorialBlock): EditorialBlockView {
|
|
|
142
147
|
};
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
export interface LatestPostsView {
|
|
151
|
+
eyebrow?: string;
|
|
152
|
+
title: string;
|
|
153
|
+
posts: BlogPostSummary[];
|
|
154
|
+
cta: { label: string; href: string } | null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const LATEST_POSTS_DEFAULT_TITLE = "Latest posts";
|
|
158
|
+
const LATEST_POSTS_DEFAULT_CTA_LABEL = "View all";
|
|
159
|
+
|
|
160
|
+
/** The card count a `latest_posts` block shows: a whole number from 1 to the cap, else the default. */
|
|
161
|
+
export function latestPostsLimit(value: unknown): number {
|
|
162
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1
|
|
163
|
+
? Math.min(value, LATEST_POSTS_MAX_LIMIT)
|
|
164
|
+
: LATEST_POSTS_DEFAULT_LIMIT;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The newest `limit` posts, newest first by `publishedAt` (an unreadable date sorts last, ties keep
|
|
169
|
+
* the collection's own order), narrowed to one tag when the block declares it. The caller passes
|
|
170
|
+
* the posts the live blog lists, so drafts and unlisted posts never reach this.
|
|
171
|
+
*/
|
|
172
|
+
export function selectLatestPosts(
|
|
173
|
+
posts: readonly BlogPostSummary[],
|
|
174
|
+
block: Pick<LatestPostsBlock, "limit" | "tag">,
|
|
175
|
+
): BlogPostSummary[] {
|
|
176
|
+
const tag = declaredText(block.tag);
|
|
177
|
+
const wanted = tag === undefined ? null : categorySlug(tag);
|
|
178
|
+
const time = (post: BlogPostSummary) => {
|
|
179
|
+
const ms = Date.parse(post.publishedAt);
|
|
180
|
+
return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
|
|
181
|
+
};
|
|
182
|
+
return posts
|
|
183
|
+
.filter((post) => wanted === null || post.tags.some((term) => categorySlug(term) === wanted))
|
|
184
|
+
.map((post, index) => ({ post, index, at: time(post) }))
|
|
185
|
+
.sort((a, b) => (a.at === b.at ? a.index - b.index : b.at > a.at ? 1 : -1))
|
|
186
|
+
.slice(0, latestPostsLimit(block.limit))
|
|
187
|
+
.map((entry) => entry.post);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The `latest_posts` section, or null when the tenant has no post to show (a section with a
|
|
192
|
+
* heading and no cards is worse than none). `blogIndexPath` is the blog index the collection
|
|
193
|
+
* resolves for the serving space, tenant-relative; the declared `ctaHref` replaces it.
|
|
194
|
+
*/
|
|
195
|
+
export function latestPostsView(
|
|
196
|
+
block: LatestPostsBlock,
|
|
197
|
+
posts: readonly BlogPostSummary[],
|
|
198
|
+
blogIndexPath: string,
|
|
199
|
+
basePath: string,
|
|
200
|
+
): LatestPostsView | null {
|
|
201
|
+
const selected = selectLatestPosts(posts, block);
|
|
202
|
+
if (selected.length === 0) return null;
|
|
203
|
+
const href = declaredText(block.ctaHref) ?? blogIndexPath;
|
|
204
|
+
return {
|
|
205
|
+
eyebrow: declaredText(block.eyebrow),
|
|
206
|
+
title: declaredText(block.title) ?? LATEST_POSTS_DEFAULT_TITLE,
|
|
207
|
+
posts: selected,
|
|
208
|
+
cta: isSafeStorefrontHref(href)
|
|
209
|
+
? { label: declaredText(block.ctaLabel) ?? LATEST_POSTS_DEFAULT_CTA_LABEL, href: withBase(basePath, href) }
|
|
210
|
+
: null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
145
214
|
/**
|
|
146
215
|
* The editorial block's media column: the block image, else generated art when
|
|
147
216
|
* the block asks for it with `imageSeed`, else none — a text-only block.
|
|
@@ -26,9 +26,30 @@ export type HomeBlock = (
|
|
|
26
26
|
| EditorialBlock
|
|
27
27
|
| NewsletterBlock
|
|
28
28
|
| FaqBlock
|
|
29
|
+
| LatestPostsBlock
|
|
29
30
|
) &
|
|
30
31
|
BlockIdentity;
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The newest posts of the tenant's own blog as cards, rendered into the static
|
|
35
|
+
* home page at publish. Every prop is optional: `{ "component": "latest_posts" }`
|
|
36
|
+
* is a complete block. A tenant with no listable post renders nothing.
|
|
37
|
+
*/
|
|
38
|
+
export interface LatestPostsBlock {
|
|
39
|
+
component: "latest_posts";
|
|
40
|
+
eyebrow?: string;
|
|
41
|
+
/** Section heading. Absent uses the platform default. */
|
|
42
|
+
title?: string;
|
|
43
|
+
/** Cards shown, newest first. Positive integer up to 12; absent uses 3. */
|
|
44
|
+
limit?: number;
|
|
45
|
+
/** Only posts carrying this tag (matched by slug, so casing and spacing are free). */
|
|
46
|
+
tag?: string;
|
|
47
|
+
/** "View all" button label. Absent uses the platform default. */
|
|
48
|
+
ctaLabel?: string;
|
|
49
|
+
/** "View all" button target, a same-storefront path. Absent targets the blog index. */
|
|
50
|
+
ctaHref?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
export interface CommerceTrustStripBlock {
|
|
33
54
|
component: "trust_strip";
|
|
34
55
|
items: TrustStripItem[];
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* Built like `@/lib/grants/developerInvites` (interface + Http impl + Mock + env factory).
|
|
17
17
|
*/
|
|
18
18
|
import { readEnv } from "@/lib/publication/shared/env";
|
|
19
|
+
import {
|
|
20
|
+
failureReasonForStatus,
|
|
21
|
+
type FailureReason,
|
|
22
|
+
} from "@/lib/observability/diag/failureReason";
|
|
19
23
|
|
|
20
24
|
export interface InlineEmail {
|
|
21
25
|
/** Recipient address. */
|
|
@@ -26,9 +30,13 @@ export interface InlineEmail {
|
|
|
26
30
|
html: string;
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
/**
|
|
34
|
+
* `reason` is a finite category. Upstream failure text (exception messages, tot20's
|
|
35
|
+
* `message`/`reason` fields) can echo the recipient address, so it never leaves this client.
|
|
36
|
+
*/
|
|
29
37
|
export type MessageSendResult =
|
|
30
38
|
| { sent: true; id?: string }
|
|
31
|
-
| { sent: false; status: number; reason:
|
|
39
|
+
| { sent: false; status: number; reason: FailureReason };
|
|
32
40
|
|
|
33
41
|
export interface MessagesClient {
|
|
34
42
|
sendInlineEmail(msg: InlineEmail): Promise<MessageSendResult>;
|
|
@@ -97,14 +105,14 @@ export class HttpMessagesClient implements MessagesClient {
|
|
|
97
105
|
...this.credentialFields(),
|
|
98
106
|
},
|
|
99
107
|
);
|
|
100
|
-
} catch
|
|
101
|
-
return { sent: false, status: 0, reason:
|
|
108
|
+
} catch {
|
|
109
|
+
return { sent: false, status: 0, reason: "transport" };
|
|
102
110
|
}
|
|
103
111
|
let body: unknown;
|
|
104
112
|
try {
|
|
105
113
|
body = await res.json();
|
|
106
114
|
} catch {
|
|
107
|
-
return { sent: false, status: res.status, reason:
|
|
115
|
+
return { sent: false, status: res.status, reason: "non-json" };
|
|
108
116
|
}
|
|
109
117
|
return normalizeSend(res.status, body);
|
|
110
118
|
}
|
|
@@ -117,9 +125,7 @@ export function normalizeSend(status: number, body: unknown): MessageSendResult
|
|
|
117
125
|
if (c.sent === true || (status >= 200 && status < 300 && c.sent !== false)) {
|
|
118
126
|
return { sent: true, ...(typeof c.id === "string" ? { id: c.id } : {}) };
|
|
119
127
|
}
|
|
120
|
-
|
|
121
|
-
typeof c.message === "string" ? c.message : typeof c.reason === "string" ? c.reason : `send failed (status ${status})`;
|
|
122
|
-
return { sent: false, status, reason };
|
|
128
|
+
return { sent: false, status, reason: failureReasonForStatus(status) };
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
// --- Mock (tests + local dev) -----------------------------------------------
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The finite set of failure categories a diagnostic line or client-facing response may
|
|
3
|
+
* carry. Upstream failure text (exception messages, response bodies, remote `message` /
|
|
4
|
+
* `reason` fields) can hold addresses, tokens or tenant detail, so it is mapped to one of
|
|
5
|
+
* these categories at the producer and never forwarded verbatim.
|
|
6
|
+
*/
|
|
7
|
+
export const FAILURE_REASONS = [
|
|
8
|
+
"transport",
|
|
9
|
+
"non-json",
|
|
10
|
+
"unauthorized",
|
|
11
|
+
"not-found",
|
|
12
|
+
"rate-limited",
|
|
13
|
+
"rejected",
|
|
14
|
+
"upstream-error",
|
|
15
|
+
"not-configured",
|
|
16
|
+
"unknown",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export type FailureReason = (typeof FAILURE_REASONS)[number];
|
|
20
|
+
|
|
21
|
+
const KNOWN: ReadonlySet<string> = new Set(FAILURE_REASONS);
|
|
22
|
+
|
|
23
|
+
export function isFailureReason(value: unknown): value is FailureReason {
|
|
24
|
+
return typeof value === "string" && KNOWN.has(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Category for an HTTP status (0 = no response reached us). */
|
|
28
|
+
export function failureReasonForStatus(status: number): FailureReason {
|
|
29
|
+
if (status === 0) return "transport";
|
|
30
|
+
if (status === 401 || status === 403) return "unauthorized";
|
|
31
|
+
if (status === 404) return "not-found";
|
|
32
|
+
if (status === 429) return "rate-limited";
|
|
33
|
+
if (status >= 400 && status < 500) return "rejected";
|
|
34
|
+
if (status >= 500 && status < 600) return "upstream-error";
|
|
35
|
+
if (status >= 200 && status < 300) return "rejected";
|
|
36
|
+
return "unknown";
|
|
37
|
+
}
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Redaction helpers for diagnostic logs
|
|
2
|
+
* Redaction helpers for diagnostic logs. An email address never appears in a diagnostic
|
|
3
|
+
* line in any build: not the local part, not its first character, not the domain.
|
|
4
|
+
* Correlate with an opaque reference (for example `hashActorId`) instead.
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return
|
|
7
|
+
export const REDACTED_EMAIL = "[redacted-email]";
|
|
8
|
+
|
|
9
|
+
/** A log-safe stand-in for an email address. Reveals nothing about the input. */
|
|
10
|
+
export function maskEmail(_email: string): string {
|
|
11
|
+
return REDACTED_EMAIL;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* are local + gitignored and the raw address is needed for debugging; MASKED everywhere the
|
|
16
|
-
* build is deployed, so prod/worker logs never persist raw recipient PII.
|
|
17
|
-
*/
|
|
18
|
-
export function diagEmail(email: string): string {
|
|
19
|
-
return import.meta.env.DEV ? email : maskEmail(email);
|
|
14
|
+
/** Email for a diagnostic line. Fully redacted in local dev and deployed builds alike. */
|
|
15
|
+
export function diagEmail(_email: string): string {
|
|
16
|
+
return REDACTED_EMAIL;
|
|
20
17
|
}
|
|
@@ -45,7 +45,8 @@ export type OrderSourceGap = "no-tenant" | "stage-unknown" | "ledger-unavailable
|
|
|
45
45
|
export interface LedgerSyncStatus {
|
|
46
46
|
orders: number;
|
|
47
47
|
lastOrderAt: string | null;
|
|
48
|
-
|
|
48
|
+
/** Reservations from the last 7 days no payment confirmation has reached; `awaiting` is those still inside the grace window. */
|
|
49
|
+
unconfirmed: { count: number; oldestAt: string | null; awaiting: number };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/** Which FoxyCart store takes this tenant's payments, or null when no store is selected. */
|
|
@@ -147,11 +148,18 @@ export function orderSyncHealth(source: OrderSource, status: LedgerSyncStatus |
|
|
|
147
148
|
if (source.kind === "not-connected") return { value: "Not connected", detail: orderSourceGapMessage(source.gap) };
|
|
148
149
|
if (source.kind === "partner-api") return { value: "Connected", detail: "Reads this store's checkout orders." };
|
|
149
150
|
if (!status) return { value: "Check", detail: "Order sync status could not be read. Reload to try again." };
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
const overdue = status.unconfirmed.count - status.unconfirmed.awaiting;
|
|
152
|
+
if (overdue > 0) {
|
|
152
153
|
return {
|
|
153
154
|
value: "Check",
|
|
154
|
-
detail: `${
|
|
155
|
+
detail: `${overdue} checkout${overdue === 1 ? "" : "s"} passed final cart more than ${PAYMENT_CONFIRMATION_GRACE_MINUTES} minutes ago with no payment confirmation received.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (status.unconfirmed.awaiting > 0) {
|
|
159
|
+
const n = status.unconfirmed.awaiting;
|
|
160
|
+
return {
|
|
161
|
+
value: "Connected",
|
|
162
|
+
detail: `${n} checkout${n === 1 ? "" : "s"} passed final cart in the last ${PAYMENT_CONFIRMATION_GRACE_MINUTES} minutes; payment confirmation has not reached this store yet.`,
|
|
155
163
|
};
|
|
156
164
|
}
|
|
157
165
|
return {
|
|
@@ -283,6 +291,14 @@ function listLimit(limit: number | undefined): number {
|
|
|
283
291
|
/** A shopper who abandons at payment leaves a reservation too, so only recent ones are reported. */
|
|
284
292
|
export const UNCONFIRMED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
285
293
|
|
|
294
|
+
/**
|
|
295
|
+
* A payment confirmation reaches the ledger after the shopper pays, not with the reservation. A
|
|
296
|
+
* reservation younger than this is awaiting one; an older one is reported as missing it. One
|
|
297
|
+
* measured delivery took over 7 minutes (2026-10-27), so the window leaves room beyond that.
|
|
298
|
+
*/
|
|
299
|
+
export const PAYMENT_CONFIRMATION_GRACE_MINUTES = 15;
|
|
300
|
+
const PAYMENT_CONFIRMATION_GRACE_MS = PAYMENT_CONFIRMATION_GRACE_MINUTES * 60 * 1000;
|
|
301
|
+
|
|
286
302
|
/**
|
|
287
303
|
* Paid orders and sync status over one tenant's ledger book, read once per request. A `scrappy`
|
|
288
304
|
* reservation is never listed as an order; one from the last seven days that no provider event
|
|
@@ -338,10 +354,15 @@ export function ledgerOrderBook(
|
|
|
338
354
|
.map((entry) => isoInstant(entry.placedAt))
|
|
339
355
|
.filter((at) => at >= since)
|
|
340
356
|
.sort();
|
|
357
|
+
const settled = new Date(now.getTime() - PAYMENT_CONFIRMATION_GRACE_MS).toISOString();
|
|
341
358
|
return {
|
|
342
359
|
orders: orders.length,
|
|
343
360
|
lastOrderAt: placed[placed.length - 1] ?? null,
|
|
344
|
-
unconfirmed: {
|
|
361
|
+
unconfirmed: {
|
|
362
|
+
count: waiting.length,
|
|
363
|
+
oldestAt: waiting[0] ?? null,
|
|
364
|
+
awaiting: waiting.filter((at) => at > settled).length,
|
|
365
|
+
},
|
|
345
366
|
};
|
|
346
367
|
};
|
|
347
368
|
|