@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.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/apps/storefront/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
- package/apps/storefront/package.json +13 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
- package/apps/storefront/src/lib/auth/route.ts +16 -1
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +12 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
- package/apps/storefront/src/middleware/index.ts +28 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin.astro +29 -3
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +197 -41
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +433 -69
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/candidate-index.ts +311 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F14 — Field Web Vitals (Real User Monitoring).
|
|
3
|
+
*
|
|
4
|
+
* This is the BROWSER-side beacon that F16's `core-web-vitals` monitor names as
|
|
5
|
+
* its signal source ("F14 RUM beacon (web-vitals: LCP, INP, CLS)"). It measures
|
|
6
|
+
* the three Core Web Vitals from real visitors, buckets each sample into the
|
|
7
|
+
* SAME budgeted route id the lab harness uses (`classifyPath`, from
|
|
8
|
+
* `perfBudgets.ts`), rates it against the checked-in budget, and hands finished
|
|
9
|
+
* samples to a pluggable reporter — by default a single `navigator.sendBeacon`
|
|
10
|
+
* to the RUM sink.
|
|
11
|
+
*
|
|
12
|
+
* Why hand-rolled instead of the `web-vitals` npm package: the field metrics we
|
|
13
|
+
* need (LCP last-candidate, CLS session-window, INP high-percentile) are a small
|
|
14
|
+
* amount of `PerformanceObserver` code, and adding a runtime dep to every page's
|
|
15
|
+
* critical path to MEASURE the critical path is self-defeating. The measurement
|
|
16
|
+
* algorithms here mirror `web-vitals`' definitions (session-windowed CLS, the
|
|
17
|
+
* (n/50)th-worst interaction for INP) closely enough for a p75 regression gate;
|
|
18
|
+
* if we ever need attribution-grade precision, swap `startWebVitals` for the
|
|
19
|
+
* library behind the same `WebVitalsSample` contract and nothing downstream
|
|
20
|
+
* changes.
|
|
21
|
+
*
|
|
22
|
+
* Split for testability: everything above the `startWebVitals` line is PURE
|
|
23
|
+
* (no DOM, no `PerformanceObserver`) so `webVitals.test.ts` can exercise rating
|
|
24
|
+
* + bucketing in node; `startWebVitals` is the thin browser wiring, guarded so
|
|
25
|
+
* importing this module in a non-browser context (SSR, tests) is a no-op.
|
|
26
|
+
*
|
|
27
|
+
* Field CWV is emitted TWICE, deliberately: Cloudflare Web Analytics
|
|
28
|
+
* (PUBLIC_CF_ANALYTICS_TOKEN, Layout.astro) already gives aggregate field CWV;
|
|
29
|
+
* this beacon adds the PER-ROUTE-TEMPLATE breakdown the budget gate + the
|
|
30
|
+
* regression alert (`rumAlert.ts`) reason over, which CF's aggregate cannot.
|
|
31
|
+
*/
|
|
32
|
+
import { classifyPath, PERF_BUDGETS, type PerfBudgetManifest, type RouteClass } from "./perfBudgets.js";
|
|
33
|
+
|
|
34
|
+
/** The three field metrics we collect. INP is field-only (no lab equivalent). */
|
|
35
|
+
export type WebVitalName = "lcp" | "inp" | "cls";
|
|
36
|
+
|
|
37
|
+
/** Google's CWV rating buckets. `good` is the budget; `poor` is the far edge. */
|
|
38
|
+
export type WebVitalRating = "good" | "needs-improvement" | "poor";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The upper edge of the `needs-improvement` band — i.e. the "poor" threshold —
|
|
42
|
+
* for each metric. These are Google's fixed CWV boundaries and are NOT in the
|
|
43
|
+
* budget manifest: the manifest owns the `good` line (our gate), while the
|
|
44
|
+
* `poor` line is an industry constant used only to label a sample's severity.
|
|
45
|
+
*/
|
|
46
|
+
export const CWV_POOR_THRESHOLDS: Readonly<Record<WebVitalName, number>> = {
|
|
47
|
+
lcp: 4000,
|
|
48
|
+
inp: 500,
|
|
49
|
+
cls: 0.25,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** One finished field measurement, ready to send to the RUM sink. */
|
|
53
|
+
export interface WebVitalsSample {
|
|
54
|
+
metric: WebVitalName;
|
|
55
|
+
/** Milliseconds for lcp/inp; unitless for cls. */
|
|
56
|
+
value: number;
|
|
57
|
+
rating: WebVitalRating;
|
|
58
|
+
/** Budgeted route bucket (or "other") — shared with the lab harness. */
|
|
59
|
+
route: RouteClass;
|
|
60
|
+
/** The pathname the sample was collected on (for debugging, not grouping). */
|
|
61
|
+
path: string;
|
|
62
|
+
/** Navigation type, when the browser exposes it (navigate|reload|back_forward). */
|
|
63
|
+
navigationType?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Rate a raw metric value against the budget (the `good` line) and the fixed
|
|
68
|
+
* `poor` line. `<=` budget is good — the same comparison the lab gate uses, so
|
|
69
|
+
* a field sample and a lab measurement agree on where "good" ends.
|
|
70
|
+
*/
|
|
71
|
+
export function rateMetric(
|
|
72
|
+
metric: WebVitalName,
|
|
73
|
+
value: number,
|
|
74
|
+
manifest: PerfBudgetManifest = PERF_BUDGETS,
|
|
75
|
+
): WebVitalRating {
|
|
76
|
+
const goodMax =
|
|
77
|
+
metric === "lcp"
|
|
78
|
+
? manifest.webVitals.lcpMs
|
|
79
|
+
: metric === "inp"
|
|
80
|
+
? manifest.webVitals.inpMs
|
|
81
|
+
: manifest.webVitals.cls;
|
|
82
|
+
if (value <= goodMax) return "good";
|
|
83
|
+
if (value <= CWV_POOR_THRESHOLDS[metric]) return "needs-improvement";
|
|
84
|
+
return "poor";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build a finished sample from a raw reading (pure — used by the collector + tests). */
|
|
88
|
+
export function toSample(
|
|
89
|
+
metric: WebVitalName,
|
|
90
|
+
value: number,
|
|
91
|
+
path: string,
|
|
92
|
+
navigationType?: string,
|
|
93
|
+
manifest: PerfBudgetManifest = PERF_BUDGETS,
|
|
94
|
+
): WebVitalsSample {
|
|
95
|
+
// CLS is unitless and small; round to 4dp. Timings round to whole ms.
|
|
96
|
+
const rounded = metric === "cls" ? Math.round(value * 1e4) / 1e4 : Math.round(value);
|
|
97
|
+
return {
|
|
98
|
+
metric,
|
|
99
|
+
value: rounded,
|
|
100
|
+
rating: rateMetric(metric, rounded, manifest),
|
|
101
|
+
route: classifyPath(path, manifest),
|
|
102
|
+
path,
|
|
103
|
+
...(navigationType ? { navigationType } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A sink for finished samples. The default posts a JSON beacon to `endpoint`. */
|
|
108
|
+
export type WebVitalsReporter = (sample: WebVitalsSample) => void;
|
|
109
|
+
|
|
110
|
+
/** Where the browser beacon POSTs field samples. Kept here so the sink owns it. */
|
|
111
|
+
export const RUM_BEACON_PATH = "/api/rum/vitals";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Default reporter: one `sendBeacon` per finished metric to the RUM endpoint.
|
|
115
|
+
* `sendBeacon` is fire-and-forget and survives page unload — the moment CWV are
|
|
116
|
+
* finalized. Falls back to a keepalive `fetch` where `sendBeacon` is absent.
|
|
117
|
+
*/
|
|
118
|
+
export function beaconReporter(endpoint: string = RUM_BEACON_PATH): WebVitalsReporter {
|
|
119
|
+
return (sample) => {
|
|
120
|
+
if (typeof navigator === "undefined") return;
|
|
121
|
+
const body = JSON.stringify(sample);
|
|
122
|
+
try {
|
|
123
|
+
if (typeof navigator.sendBeacon === "function") {
|
|
124
|
+
navigator.sendBeacon(endpoint, body);
|
|
125
|
+
} else if (typeof fetch === "function") {
|
|
126
|
+
void fetch(endpoint, { method: "POST", body, keepalive: true });
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Never let telemetry throw into the page.
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Browser collection (thin wiring — everything above is pure + unit-tested)
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
interface PerfEntryLS extends PerformanceEntry {
|
|
139
|
+
value: number;
|
|
140
|
+
hadRecentInput: boolean;
|
|
141
|
+
}
|
|
142
|
+
interface PerfEntryEvent extends PerformanceEntry {
|
|
143
|
+
interactionId?: number;
|
|
144
|
+
}
|
|
145
|
+
interface LcpEntry extends PerformanceEntry {
|
|
146
|
+
renderTime: number;
|
|
147
|
+
loadTime: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Start field Web Vitals collection for the current page. Idempotent-safe to
|
|
152
|
+
* call once from the client entry. No-op (returns a no-op teardown) when
|
|
153
|
+
* `PerformanceObserver` is unavailable (SSR, old browsers, tests) so callers
|
|
154
|
+
* don't need their own guard.
|
|
155
|
+
*
|
|
156
|
+
* Metrics are finalized and reported when the page is first backgrounded
|
|
157
|
+
* (`visibilitychange` → hidden) or on `pagehide` — the last reliable moment on
|
|
158
|
+
* mobile — because CWV are "worst-so-far" values that only settle at end of life.
|
|
159
|
+
*/
|
|
160
|
+
export function startWebVitals(
|
|
161
|
+
report: WebVitalsReporter = beaconReporter(),
|
|
162
|
+
manifest: PerfBudgetManifest = PERF_BUDGETS,
|
|
163
|
+
): () => void {
|
|
164
|
+
if (typeof PerformanceObserver === "undefined" || typeof document === "undefined") {
|
|
165
|
+
return () => {};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const path = typeof location !== "undefined" ? location.pathname : "/";
|
|
169
|
+
const navEntry =
|
|
170
|
+
typeof performance !== "undefined"
|
|
171
|
+
? (performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined)
|
|
172
|
+
: undefined;
|
|
173
|
+
const navigationType = navEntry?.type;
|
|
174
|
+
|
|
175
|
+
// --- LCP: the last largest-contentful-paint candidate before input/hide. ---
|
|
176
|
+
let lcp = 0;
|
|
177
|
+
const lcpObs = new PerformanceObserver((list) => {
|
|
178
|
+
const entries = list.getEntries() as LcpEntry[];
|
|
179
|
+
const last = entries[entries.length - 1];
|
|
180
|
+
if (last) lcp = last.renderTime || last.loadTime || last.startTime;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// --- CLS: max session window (gap <1s, cap 5s), ignoring input-driven shifts. ---
|
|
184
|
+
let clsMax = 0;
|
|
185
|
+
let sessionValue = 0;
|
|
186
|
+
let sessionFirst = 0;
|
|
187
|
+
let sessionLast = 0;
|
|
188
|
+
const clsObs = new PerformanceObserver((list) => {
|
|
189
|
+
for (const raw of list.getEntries()) {
|
|
190
|
+
const e = raw as PerfEntryLS;
|
|
191
|
+
if (e.hadRecentInput) continue;
|
|
192
|
+
if (sessionValue && e.startTime - sessionLast < 1000 && e.startTime - sessionFirst < 5000) {
|
|
193
|
+
sessionValue += e.value;
|
|
194
|
+
sessionLast = e.startTime;
|
|
195
|
+
} else {
|
|
196
|
+
sessionValue = e.value;
|
|
197
|
+
sessionFirst = e.startTime;
|
|
198
|
+
sessionLast = e.startTime;
|
|
199
|
+
}
|
|
200
|
+
if (sessionValue > clsMax) clsMax = sessionValue;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// --- INP: the (n/50)th-worst interaction latency (web-vitals' definition). ---
|
|
205
|
+
const interactionDurations: number[] = [];
|
|
206
|
+
const seenInteraction = new Map<number, number>();
|
|
207
|
+
const inpObs = new PerformanceObserver((list) => {
|
|
208
|
+
for (const raw of list.getEntries()) {
|
|
209
|
+
const e = raw as PerfEntryEvent;
|
|
210
|
+
if (!e.interactionId) continue;
|
|
211
|
+
// Keep the WORST duration per interactionId (a tap can emit several events).
|
|
212
|
+
const prev = seenInteraction.get(e.interactionId);
|
|
213
|
+
if (prev === undefined || e.duration > prev) {
|
|
214
|
+
seenInteraction.set(e.interactionId, e.duration);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const observe = (obs: PerformanceObserver, type: string, opts: PerformanceObserverInit = {}) => {
|
|
220
|
+
try {
|
|
221
|
+
obs.observe({ type, buffered: true, ...opts });
|
|
222
|
+
} catch {
|
|
223
|
+
// Unsupported entry type in this browser — skip that metric silently.
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
observe(lcpObs, "largest-contentful-paint");
|
|
227
|
+
observe(clsObs, "layout-shift");
|
|
228
|
+
observe(inpObs, "event", { durationThreshold: 40 } as PerformanceObserverInit);
|
|
229
|
+
|
|
230
|
+
const computeInp = (): number | undefined => {
|
|
231
|
+
interactionDurations.length = 0;
|
|
232
|
+
for (const d of seenInteraction.values()) interactionDurations.push(d);
|
|
233
|
+
if (!interactionDurations.length) return undefined;
|
|
234
|
+
interactionDurations.sort((a, b) => b - a);
|
|
235
|
+
const idx = Math.min(Math.floor(interactionDurations.length / 50), interactionDurations.length - 1);
|
|
236
|
+
return interactionDurations[idx];
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
let reported = false;
|
|
240
|
+
const flush = () => {
|
|
241
|
+
if (reported) return;
|
|
242
|
+
reported = true;
|
|
243
|
+
lcpObs.takeRecords();
|
|
244
|
+
clsObs.takeRecords();
|
|
245
|
+
inpObs.takeRecords();
|
|
246
|
+
lcpObs.disconnect();
|
|
247
|
+
clsObs.disconnect();
|
|
248
|
+
inpObs.disconnect();
|
|
249
|
+
|
|
250
|
+
if (lcp > 0) report(toSample("lcp", lcp, path, navigationType, manifest));
|
|
251
|
+
report(toSample("cls", clsMax, path, navigationType, manifest));
|
|
252
|
+
const inp = computeInp();
|
|
253
|
+
if (inp !== undefined) report(toSample("inp", inp, path, navigationType, manifest));
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const onVisibility = () => {
|
|
257
|
+
if (document.visibilityState === "hidden") flush();
|
|
258
|
+
};
|
|
259
|
+
document.addEventListener("visibilitychange", onVisibility, { capture: true });
|
|
260
|
+
window.addEventListener("pagehide", flush, { capture: true });
|
|
261
|
+
|
|
262
|
+
return () => {
|
|
263
|
+
document.removeEventListener("visibilitychange", onVisibility, { capture: true });
|
|
264
|
+
window.removeEventListener("pagehide", flush, { capture: true });
|
|
265
|
+
lcpObs.disconnect();
|
|
266
|
+
clsObs.disconnect();
|
|
267
|
+
inpObs.disconnect();
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition root + shared helpers for the `/api/apps/admin/*` routes
|
|
3
|
+
* (PrivateApps epic, D2 Chunk C) — builds an `AppRegistryService` over the
|
|
4
|
+
* `STOREFRONT_APPS_DB` `Queryable` binding and a `D1CredentialStore`, the same
|
|
5
|
+
* construction `api/apps/oauth/token.ts` (Chunk B) uses, and mirrors
|
|
6
|
+
* `../grants/route.ts`'s `getService()` + no-store `json()` + 503-when-
|
|
7
|
+
* unconfigured pattern. Kept out of the pure service module so that stays
|
|
8
|
+
* transport-free and unit-testable against a fake `Queryable`.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
AppRegistryService,
|
|
12
|
+
RegistryError,
|
|
13
|
+
type AppInstall,
|
|
14
|
+
type RegistryErrorCode,
|
|
15
|
+
} from "./registryService.js";
|
|
16
|
+
import { D1CredentialStore } from "./credentials.js";
|
|
17
|
+
import { DEFAULT_APP_GATEWAY_KID, APP_GATEWAY_KID_ENV } from "./gatewayKeys.js";
|
|
18
|
+
import { fromD1 } from "../d1/catalog.js";
|
|
19
|
+
import { readD1, readEnv } from "../env.js";
|
|
20
|
+
import { MANAGE_ROLES, type AdminAuthResult } from "./adminSession.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* No-store JSON response — install/rotate bodies carry a one-time client
|
|
24
|
+
* secret and must never be edge- or browser-cached.
|
|
25
|
+
*/
|
|
26
|
+
export function json(body: unknown, status = 200): Response {
|
|
27
|
+
return new Response(JSON.stringify(body), {
|
|
28
|
+
status,
|
|
29
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build an `AppRegistryService` over the real `STOREFRONT_APPS_DB` D1
|
|
35
|
+
* binding, or return a 503 Response when that binding isn't configured in
|
|
36
|
+
* this runtime. Never leaks binding/secret detail in the response body.
|
|
37
|
+
*/
|
|
38
|
+
export async function getRegistryService(): Promise<AppRegistryService | Response> {
|
|
39
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
40
|
+
if (!d1) return json({ error: "app registry not configured" }, 503);
|
|
41
|
+
const db = fromD1(d1);
|
|
42
|
+
const activeKid = (await readEnv(APP_GATEWAY_KID_ENV)) ?? DEFAULT_APP_GATEWAY_KID;
|
|
43
|
+
const credentials = new D1CredentialStore(db, activeKid);
|
|
44
|
+
return new AppRegistryService(db, credentials);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** HTTP status per `RegistryError` code — the one shared fail-closed mapping. */
|
|
48
|
+
const STATUS_BY_REGISTRY_CODE: Record<RegistryErrorCode, number> = {
|
|
49
|
+
scope_denied: 400,
|
|
50
|
+
not_found: 404,
|
|
51
|
+
already_installed: 409,
|
|
52
|
+
invalid_state: 409,
|
|
53
|
+
invalid_webhook_endpoint: 400,
|
|
54
|
+
invalid_widget_placement: 400,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Map a `RegistryError` to its HTTP status. Never echoes internals — code + message only. */
|
|
58
|
+
export function registryErrorResponse(err: RegistryError): Response {
|
|
59
|
+
const body: Record<string, unknown> = { error: err.message, code: err.code };
|
|
60
|
+
if (err.denials) body.denials = err.denials;
|
|
61
|
+
return json(body, STATUS_BY_REGISTRY_CODE[err.code]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parse `{ installId }` from a JSON body — shared by every installId-bearing admin route. */
|
|
65
|
+
export function parseInstallId(body: unknown): string | null {
|
|
66
|
+
if (!body || typeof body !== "object") return null;
|
|
67
|
+
const installId = (body as { installId?: unknown }).installId;
|
|
68
|
+
return typeof installId === "string" && installId.trim() ? installId.trim() : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve `installId` and verify it belongs to `tenantId` — every mutating
|
|
73
|
+
* admin route (update/suspend/resume/uninstall/rotate) MUST call this BEFORE
|
|
74
|
+
* invoking a service method, since those methods take a bare `installId` with
|
|
75
|
+
* no tenant check of their own (Chunk A assumption #1). A foreign-tenant
|
|
76
|
+
* `installId` is reported IDENTICALLY to a truly-missing one — 404, same
|
|
77
|
+
* body shape as `RegistryError("not_found")` — so there is no cross-tenant
|
|
78
|
+
* existence oracle.
|
|
79
|
+
*/
|
|
80
|
+
export async function requireOwnedInstall(
|
|
81
|
+
registry: AppRegistryService,
|
|
82
|
+
installId: string,
|
|
83
|
+
tenantId: string,
|
|
84
|
+
): Promise<AppInstall | Response> {
|
|
85
|
+
const install = await registry.get(installId);
|
|
86
|
+
if (!install || install.tenantId !== tenantId) {
|
|
87
|
+
return json({ error: "App install not found.", code: "not_found" }, 404);
|
|
88
|
+
}
|
|
89
|
+
return install;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Gate a mutating admin route on the resolved auth's role — call AFTER
|
|
94
|
+
* `resolveOperatorSession` for every write route (suspend/resume/uninstall/
|
|
95
|
+
* update/rotate/replay). A console session's `role` must be in
|
|
96
|
+
* `MANAGE_ROLES`: a `developer` is read-only and gets a 403 here. The
|
|
97
|
+
* host-bound/Bearer path (`resolveAdminSession`) carries no `role` at all —
|
|
98
|
+
* it is already manage-capable (gated by `ADMIN_CAPABILITIES`/the dedicated
|
|
99
|
+
* secret) — so an `undefined` role is exempt, not denied.
|
|
100
|
+
*/
|
|
101
|
+
export function requireManageRole(auth: Extract<AdminAuthResult, { ok: true }>): Response | null {
|
|
102
|
+
if (auth.role !== undefined && !MANAGE_ROLES.has(auth.role)) {
|
|
103
|
+
return json({ error: "not authorized to manage apps for this tenant" }, 403);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-session resolution for the Private Apps admin API
|
|
3
|
+
* (`/api/apps/admin/*`, PrivateApps epic, D2 Chunk C). Adapted from
|
|
4
|
+
* `../grants/session.ts`'s owner-session resolution — same two paths, same
|
|
5
|
+
* fail-closed shape — but resolves `{ tenantId, actor }` instead of `{ owner }`:
|
|
6
|
+
*
|
|
7
|
+
* - `tenantId` is the `AppInstall.tenantId` scope. It MUST equal the
|
|
8
|
+
* host-resolved `appDomain` the OAuth token issuer checks installs
|
|
9
|
+
* against (`tokenIssuer.ts` compares `install.tenantId` to the caller's
|
|
10
|
+
* `context.locals.tenant.appDomain`) — so an app installed through this
|
|
11
|
+
* admin API is later discoverable by the SAME tenant identity a minted
|
|
12
|
+
* token is scoped to.
|
|
13
|
+
* - `actor` is an OPAQUE id for the audit log (`app_audit_log.actor` —
|
|
14
|
+
* "never email/PII", see `../d1/schema.ts`). A signed-in viewer's verified
|
|
15
|
+
* email is one-way hashed (`sha256Hex`, the same primitive `credentials.ts`
|
|
16
|
+
* uses for secrets) so distinct operators still produce distinct, stable
|
|
17
|
+
* audit actors without the trail ever holding an email address.
|
|
18
|
+
*
|
|
19
|
+
* Two resolution paths, tried in order:
|
|
20
|
+
* 1. SIGNED-IN TENANT OPERATOR (primary). `context.locals.viewer` (set by
|
|
21
|
+
* the login-gate middleware from the `tot_session` cookie) plus
|
|
22
|
+
* `context.locals.tenant`. Admitted only for an owner/admin capability
|
|
23
|
+
* on the resolved tenant — a lower-capability viewer is a resolved
|
|
24
|
+
* session with insufficient authority, so this fails closed with 403
|
|
25
|
+
* rather than silently falling through to path 2.
|
|
26
|
+
* 2. BEARER OPERATOR SECRET (fallback, headless console). Gated by a
|
|
27
|
+
* DEDICATED `APPS_ADMIN_SECRET` only — unlike grants' session, this does
|
|
28
|
+
* NOT fall back to the shared `PREVIEW_RECONCILE_SECRET`, since that
|
|
29
|
+
* secret must not double as a cross-tenant app-registry master key
|
|
30
|
+
* (decision d2-admin-auth-dedicated-secret). Unconfigured `APPS_ADMIN_SECRET`
|
|
31
|
+
* means this path is unavailable — fails closed rather than trusting
|
|
32
|
+
* another deployment's secret. The tenant is resolved server-side from
|
|
33
|
+
* `APPS_ADMIN_TENANT` env or the `X-Tot-Tenant` header (honoured only
|
|
34
|
+
* for an already-authenticated caller) — never from the request body.
|
|
35
|
+
* There is no per-caller identity on this path, so `actor` is the fixed
|
|
36
|
+
* literal below.
|
|
37
|
+
*
|
|
38
|
+
* Routes depend only on the `{ ok, tenantId, actor }` result. `authorizeAdmin`
|
|
39
|
+
* is the pure core of path 2 (unit-testable, no I/O).
|
|
40
|
+
*
|
|
41
|
+
* D8 ADDITION — console admin session. The two paths above are host-bound:
|
|
42
|
+
* they resolve exactly one tenant (`context.locals.tenant`). The
|
|
43
|
+
* `/api/apps/admin/*` surface is also reachable from the multi-tenant vendor
|
|
44
|
+
* console, where a signed-in viewer may hold memberships on several vendors —
|
|
45
|
+
* so `resolveConsoleAdminSession` mirrors `../grants/team-members.ts`'s
|
|
46
|
+
* pattern instead: `readViewerSession` (the FULL session, all `tenants[]`)
|
|
47
|
+
* plus an explicit `requestedTenant` from the request, admitted via
|
|
48
|
+
* `viewerCanAccess`/`membershipFor` (deny-by-default, staff-selection-aware).
|
|
49
|
+
* Its `role` is the caller's resolved membership role
|
|
50
|
+
* (`developer`|`appAdmin`|`appOwner`) so routes can gate mutations against
|
|
51
|
+
* `MANAGE_ROLES` — a `developer` is read-only. `resolveOperatorSession` is
|
|
52
|
+
* the single entry point routes call: it tries the console path first and
|
|
53
|
+
* only falls back to `resolveAdminSession` (path 1/2 above) when there is NO
|
|
54
|
+
* console session at all (401) — a console session that exists but lacks
|
|
55
|
+
* access to `requestedTenant` fails closed with its own 403, the same
|
|
56
|
+
* no-fall-through discipline path 1 already uses for an insufficient
|
|
57
|
+
* capability.
|
|
58
|
+
*/
|
|
59
|
+
import type { APIContext } from "astro";
|
|
60
|
+
import { sha256Hex } from "@tot/public-runtime";
|
|
61
|
+
import { readEnv } from "@/lib/env";
|
|
62
|
+
import { readViewerSession } from "@/lib/auth/route.js";
|
|
63
|
+
import { viewerCanAccess, membershipFor } from "@/lib/dashboard/tenantSelection.js";
|
|
64
|
+
|
|
65
|
+
/** Capabilities that make a signed-in viewer an app-registry operator for their tenant. */
|
|
66
|
+
const ADMIN_CAPABILITIES: ReadonlySet<string> = new Set(["owner", "admin"]);
|
|
67
|
+
|
|
68
|
+
/** Console membership roles authorized to mutate installs — a `developer` is read-only. */
|
|
69
|
+
export const MANAGE_ROLES: ReadonlySet<string> = new Set(["appOwner", "appAdmin"]);
|
|
70
|
+
|
|
71
|
+
/** Opaque `actor` stamped for the shared Bearer fallback — no per-caller identity to hash. */
|
|
72
|
+
const BEARER_ACTOR = "bearer-operator";
|
|
73
|
+
|
|
74
|
+
/** How many hex chars of the email digest to keep — enough entropy to distinguish operators. */
|
|
75
|
+
const ACTOR_HASH_LENGTH = 32;
|
|
76
|
+
|
|
77
|
+
/** Pure inputs to the Bearer-fallback authorization decision (env + request, no I/O). */
|
|
78
|
+
export interface AdminAuthInput {
|
|
79
|
+
/** The request `Authorization` header, if any. */
|
|
80
|
+
authHeader: string | null;
|
|
81
|
+
/** The configured operator gate secret (or undefined if unconfigured). */
|
|
82
|
+
gateSecret: string | undefined;
|
|
83
|
+
/** Server-configured tenant for this deployment (APPS_ADMIN_TENANT), if any. */
|
|
84
|
+
configuredTenant: string | undefined;
|
|
85
|
+
/** `X-Tot-Tenant` header — honoured only once the caller is authenticated. */
|
|
86
|
+
requestedTenant: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type AdminAuthResult =
|
|
90
|
+
| { ok: true; tenantId: string; actor: string; role?: string }
|
|
91
|
+
| { ok: false; status: number; message: string };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decide the authenticated tenant from env + request, with NO I/O —
|
|
95
|
+
* unit-testable. Fail-closed: unconfigured gate → 503, bad/absent Bearer →
|
|
96
|
+
* 401, no resolvable tenant → 400.
|
|
97
|
+
*/
|
|
98
|
+
export function authorizeAdmin(input: AdminAuthInput): AdminAuthResult {
|
|
99
|
+
if (!input.gateSecret) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
status: 503,
|
|
103
|
+
message: "apps admin operator secret not configured",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (input.authHeader !== `Bearer ${input.gateSecret}`) {
|
|
107
|
+
return { ok: false, status: 401, message: "Unauthorized" };
|
|
108
|
+
}
|
|
109
|
+
const tenantId = (input.configuredTenant ?? input.requestedTenant ?? "").trim();
|
|
110
|
+
if (!tenantId) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
status: 400,
|
|
114
|
+
message: "tenant unresolved (set APPS_ADMIN_TENANT or X-Tot-Tenant)",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, tenantId, actor: BEARER_ACTOR };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One-way, stable pseudonym for an audit `actor` — never the email itself. */
|
|
121
|
+
async function actorFromEmail(email: string): Promise<string> {
|
|
122
|
+
return `viewer:${(await sha256Hex(email)).slice(0, ACTOR_HASH_LENGTH)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the authenticated `{ tenantId, actor }` for a Private Apps admin
|
|
127
|
+
* request from the runtime env + the incoming request. Never reads `tenantId`
|
|
128
|
+
* from the body.
|
|
129
|
+
*/
|
|
130
|
+
export async function resolveAdminSession(context: APIContext): Promise<AdminAuthResult> {
|
|
131
|
+
// Path 1: a signed-in tenant-operator session. The tenant IS the resolved
|
|
132
|
+
// host's appDomain — never from the request body.
|
|
133
|
+
const viewer = context.locals?.viewer;
|
|
134
|
+
const tenantId = context.locals?.tenant?.appDomain;
|
|
135
|
+
if (viewer && tenantId) {
|
|
136
|
+
if (!ADMIN_CAPABILITIES.has(viewer.capability)) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
status: 403,
|
|
140
|
+
message: "not authorized to manage apps for this tenant",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, tenantId, actor: await actorFromEmail(viewer.email) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Path 2 (fallback): the headless-console Bearer operator secret. Dedicated
|
|
147
|
+
// secret only — no fallback to another deployment's secret.
|
|
148
|
+
const gateSecret = await readEnv("APPS_ADMIN_SECRET");
|
|
149
|
+
const configuredTenant = await readEnv("APPS_ADMIN_TENANT");
|
|
150
|
+
return authorizeAdmin({
|
|
151
|
+
authHeader: context.request.headers.get("authorization"),
|
|
152
|
+
gateSecret,
|
|
153
|
+
configuredTenant,
|
|
154
|
+
requestedTenant: context.request.headers.get("x-tot-tenant"),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a signed-in console viewer's admin session for `requestedTenant`
|
|
160
|
+
* (the `?tenant=` query param every admin route now reads) — the multi-tenant
|
|
161
|
+
* counterpart to `resolveAdminSession`'s host-bound path 1, mirroring
|
|
162
|
+
* `../grants/team-members.ts`'s `readViewerSession` + `viewerCanAccess`
|
|
163
|
+
* pattern instead of the single-tenant `locals.tenant`/`locals.viewer` gate.
|
|
164
|
+
* Fail-closed: no session → 401 (the signal `resolveOperatorSession` uses to
|
|
165
|
+
* fall back to the host-bound/Bearer path); a session with no membership or
|
|
166
|
+
* live staff admission for `requestedTenant` → 403 (does NOT fall back — an
|
|
167
|
+
* authenticated-but-denied caller is not the "no console session" case).
|
|
168
|
+
* `role` is the caller's resolved membership role for routes to gate
|
|
169
|
+
* mutations against `MANAGE_ROLES`.
|
|
170
|
+
*/
|
|
171
|
+
export async function resolveConsoleAdminSession(
|
|
172
|
+
context: APIContext,
|
|
173
|
+
requestedTenant: string | null,
|
|
174
|
+
): Promise<AdminAuthResult> {
|
|
175
|
+
const session = await readViewerSession(context);
|
|
176
|
+
if (!session) return { ok: false, status: 401, message: "not signed in" };
|
|
177
|
+
|
|
178
|
+
const tenantId = (requestedTenant ?? "").trim();
|
|
179
|
+
const staff = { selection: session.staffSelection, nowSeconds: Math.floor(Date.now() / 1000) };
|
|
180
|
+
if (!viewerCanAccess(session.tenants, tenantId, staff)) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
status: 403,
|
|
184
|
+
message: "not authorized to manage apps for this tenant",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const role = membershipFor(session.tenants, tenantId, staff)?.role ?? "developer";
|
|
188
|
+
return { ok: true, tenantId, actor: await actorFromEmail(session.email), role };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The single entry point admin routes call: try the signed-in console
|
|
193
|
+
* viewer's session first, and only fall back to the host-bound/Bearer path
|
|
194
|
+
* (`resolveAdminSession`) when there is no console session at all (401) — see
|
|
195
|
+
* {@link resolveConsoleAdminSession}'s doc for why a denied console session
|
|
196
|
+
* (403) does not fall through.
|
|
197
|
+
*/
|
|
198
|
+
export async function resolveOperatorSession(
|
|
199
|
+
context: APIContext,
|
|
200
|
+
requestedTenant: string | null,
|
|
201
|
+
): Promise<AdminAuthResult> {
|
|
202
|
+
const consoleAuth = await resolveConsoleAdminSession(context, requestedTenant);
|
|
203
|
+
if (consoleAuth.ok || consoleAuth.status !== 401) return consoleAuth;
|
|
204
|
+
return resolveAdminSession(context);
|
|
205
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The D3 scoped-API authorization boundary (PrivateApps epic) — every
|
|
3
|
+
* `/api/apps/v1/*` route calls `authorizeAppRequest` FIRST. Verifies the
|
|
4
|
+
* app-gateway bearer JWT (D2's `AppTokenVerifier`), resolves the calling
|
|
5
|
+
* install from the registry, and enforces the single scope the route
|
|
6
|
+
* requires per `docs/private-apps/contract/openapi.yaml`'s `x-requiredScope`.
|
|
7
|
+
*
|
|
8
|
+
* Tenant is ALWAYS the host-resolved `context.locals.tenant.appDomain` —
|
|
9
|
+
* never a header or query param — the same binding `tokenIssuer.ts` and
|
|
10
|
+
* `oauth/token.ts` use, so a token can't be replayed against a different
|
|
11
|
+
* tenant than the one it was minted for.
|
|
12
|
+
*
|
|
13
|
+
* Fail-closed throughout: an unconfigured signing key, a missing/invalid/
|
|
14
|
+
* expired token, and an install that's gone missing/cross-tenant/suspended/
|
|
15
|
+
* uninstalled are ALL reported identically as the generic 401 the contract's
|
|
16
|
+
* `Unauthorized` response documents (no detail that would let a caller
|
|
17
|
+
* distinguish "wrong tenant" from "suspended" from "never existed" — the
|
|
18
|
+
* same no-oracle discipline `registryService.ts`'s `mustFindRow` and
|
|
19
|
+
* `tokenIssuer.ts`'s `invalid_client` already keep).
|
|
20
|
+
*/
|
|
21
|
+
import type { APIContext } from "astro";
|
|
22
|
+
import { getAppTokenVerifier, type AppTokenClaims } from "./tokenVerifier.js";
|
|
23
|
+
import { getRegistryService } from "./adminService.js";
|
|
24
|
+
import type { AppInstall } from "./registryService.js";
|
|
25
|
+
import { json } from "./apiRoute.js";
|
|
26
|
+
|
|
27
|
+
export interface AuthorizedAppRequest {
|
|
28
|
+
ok: true;
|
|
29
|
+
claims: AppTokenClaims;
|
|
30
|
+
install: AppInstall;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AuthorizeAppResult = AuthorizedAppRequest | { ok: false; response: Response };
|
|
34
|
+
|
|
35
|
+
/** Generic, no-detail 401 — see the fail-closed note above. */
|
|
36
|
+
function unauthorized(): Response {
|
|
37
|
+
return json({ error: "Unauthorized", code: "invalid_token" }, 401);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Authorize one `/api/apps/v1/*` request. Pass the single scope id the route
|
|
42
|
+
* requires (per the contract's `x-requiredScope`), or `null` for the
|
|
43
|
+
* metadata endpoints that only require a valid token.
|
|
44
|
+
*/
|
|
45
|
+
export async function authorizeAppRequest(
|
|
46
|
+
context: APIContext,
|
|
47
|
+
requiredScope: string | null,
|
|
48
|
+
): Promise<AuthorizeAppResult> {
|
|
49
|
+
const tenant = context.locals.tenant.appDomain;
|
|
50
|
+
|
|
51
|
+
const verifier = await getAppTokenVerifier(tenant);
|
|
52
|
+
if (!verifier) {
|
|
53
|
+
return { ok: false, response: json({ error: "app gateway not configured", code: "unconfigured" }, 503) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const authHeader = context.request.headers.get("authorization");
|
|
57
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length).trim() : "";
|
|
58
|
+
if (!token) {
|
|
59
|
+
return { ok: false, response: unauthorized() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let claims: AppTokenClaims;
|
|
63
|
+
try {
|
|
64
|
+
claims = await verifier.verify(token);
|
|
65
|
+
} catch {
|
|
66
|
+
return { ok: false, response: unauthorized() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const registry = await getRegistryService();
|
|
70
|
+
if (registry instanceof Response) return { ok: false, response: registry };
|
|
71
|
+
|
|
72
|
+
const install = await registry.get(claims.sub);
|
|
73
|
+
if (!install || install.tenantId !== tenant || install.status !== "active") {
|
|
74
|
+
return { ok: false, response: unauthorized() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Enforce the scope against BOTH the live install grant and the token claims.
|
|
78
|
+
// install.scopes is the authoritative, real-time grant — so an operator
|
|
79
|
+
// narrowing scopes via update() takes effect immediately (not only after the
|
|
80
|
+
// ~300s token TTL), matching how install *status* is already live-checked
|
|
81
|
+
// above. Requiring it in the token too keeps a token from ever exceeding the
|
|
82
|
+
// grant it was minted with. Fail-closed: absent from either → denied.
|
|
83
|
+
if (
|
|
84
|
+
requiredScope &&
|
|
85
|
+
!(install.scopes.includes(requiredScope) && claims.scopes.includes(requiredScope))
|
|
86
|
+
) {
|
|
87
|
+
return { ok: false, response: json({ error: "scope_denied", code: "scope_denied" }, 403) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { ok: true, claims, install };
|
|
91
|
+
}
|