@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.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 +17 -1
- package/apps/storefront/integrations/materialize-guard.mjs +56 -0
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
- package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/public/js/dashboard-team.js +38 -2
- package/apps/storefront/src/components/Seo.astro +65 -1
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
- package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
- package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
- package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
- package/apps/storefront/src/layouts/Layout.astro +48 -3
- package/apps/storefront/src/lib/activity/alerts.ts +426 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
- package/apps/storefront/src/lib/activity/store.ts +120 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
- package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
- package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
- package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
- package/apps/storefront/src/lib/apps/adminService.ts +4 -4
- package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
- package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
- package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
- package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
- package/apps/storefront/src/lib/apps/credentials.ts +2 -2
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
- package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
- package/apps/storefront/src/lib/apps/registryService.ts +6 -6
- package/apps/storefront/src/lib/apps/scopes.ts +2 -2
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
- package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
- package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
- package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
- package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/auth/session.ts +7 -8
- package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
- package/apps/storefront/src/lib/basePath.ts +9 -2
- package/apps/storefront/src/lib/blog/access.ts +29 -0
- package/apps/storefront/src/lib/blog/collection.ts +313 -0
- package/apps/storefront/src/lib/blog/markdown.ts +174 -0
- package/apps/storefront/src/lib/blog/provider.ts +5 -0
- package/apps/storefront/src/lib/blog/reactions.ts +87 -0
- package/apps/storefront/src/lib/blog/rss.ts +92 -0
- package/apps/storefront/src/lib/blog/teaser.ts +83 -0
- package/apps/storefront/src/lib/blog/types.ts +34 -0
- package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
- package/apps/storefront/src/lib/d1/catalog.ts +2 -2
- package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
- package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
- package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
- package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
- package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
- package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
- package/apps/storefront/src/lib/edgeCache.ts +17 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
- package/apps/storefront/src/lib/i18n.ts +25 -0
- package/apps/storefront/src/lib/jsonld.ts +118 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
- package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
- package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
- package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
- package/apps/storefront/src/lib/newsletter/send.ts +265 -0
- package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
- package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
- package/apps/storefront/src/lib/publish/domainState.ts +22 -13
- package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
- package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
- package/apps/storefront/src/lib/search/index.ts +2 -2
- package/apps/storefront/src/lib/seo/alternates.ts +42 -0
- package/apps/storefront/src/lib/seo/meta.ts +65 -0
- package/apps/storefront/src/lib/social/golive.ts +212 -0
- package/apps/storefront/src/lib/social/notify.ts +32 -0
- package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
- package/apps/storefront/src/lib/the-build/provider.ts +27 -0
- package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
- package/apps/storefront/src/lib/the-build/toc.ts +95 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
- package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
- package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
- package/apps/storefront/src/middleware/index.ts +130 -2
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +52 -1206
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
- package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
- package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
- package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +96 -16
- package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
- package/apps/storefront/src/pages/cockpit.astro +74 -12
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
- package/apps/storefront/src/pages/search.astro +1 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
- package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
- package/apps/storefront/src/pages/the-build/index.astro +146 -0
- package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
- package/apps/storefront/src/styles/admin.css +1080 -0
- package/apps/storefront/tsconfig.json +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +553 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +13 -0
- package/packages/public-runtime/src/catalog-d1.ts +1 -1
- package/packages/public-runtime/src/customization-preview.ts +25 -2
- package/packages/public-runtime/src/customization-runtime.ts +125 -3
- package/packages/public-runtime/src/customization-versioning.ts +10 -0
- package/packages/public-runtime/src/index.ts +7 -0
- package/packages/public-runtime/src/static-bundle.ts +357 -0
- package/packages/public-runtime/src/tenant.ts +36 -8
- package/scripts/build/copy-tenant-assets.mjs +14 -4
- package/scripts/dev/ai-edit.mjs +1 -1
- package/scripts/dev/checkout-watch.mjs +2 -2
- package/scripts/dev/file-browser.mjs +2 -4
- package/scripts/dev/file-writer.mjs +1 -1
- package/scripts/dev/git-status.mjs +2 -2
- package/scripts/dev/locate-handler.mjs +3 -3
- package/scripts/dev/port-check.mjs +3 -3
- package/scripts/dev/publish.mjs +5 -9
- package/scripts/dev/shot.mjs +1 -1
- package/scripts/dev/unified-diff.mjs +8 -3
- package/scripts/tot-dev.mjs +6 -9
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teaser truncation for gated posts. A members-only post is still a 200 with
|
|
3
|
+
* partial content (indexable — see the B4.0 spike's SEO note), so an anonymous /
|
|
4
|
+
* unentitled reader gets the first few blocks of the real body plus a paywall
|
|
5
|
+
* card, not a hard wall.
|
|
6
|
+
*
|
|
7
|
+
* Truncation is by TOP-LEVEL BLOCK COUNT, cutting only on an element boundary so
|
|
8
|
+
* the returned string is never a partial tag. The blog renderer emits each block
|
|
9
|
+
* (`<p>`, `<h2>`, `<ul>`, `<blockquote>`, `<pre>`, …) as a top-level sibling, so
|
|
10
|
+
* "first N blocks" is a clean prefix of the rendered HTML; the boundary scan also
|
|
11
|
+
* tolerates legacy/migrated `contentHtml` from other sources.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const VOID_TAGS = new Set([
|
|
15
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
16
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** Default teaser length: enough to be a genuine content sample, not the whole story. */
|
|
20
|
+
const DEFAULT_BLOCKS = 3;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The first `blocks` top-level block elements of `contentHtml`. Returns the whole
|
|
24
|
+
* string when it has fewer than `blocks` top-level elements. Cuts only after a
|
|
25
|
+
* top-level element closes (or a void/self-closing element), so HTML is never
|
|
26
|
+
* severed mid-tag.
|
|
27
|
+
*/
|
|
28
|
+
export function teaserHtml(contentHtml: string, blocks: number = DEFAULT_BLOCKS): string {
|
|
29
|
+
if (blocks <= 0) return "";
|
|
30
|
+
const n = contentHtml.length;
|
|
31
|
+
let i = 0;
|
|
32
|
+
let depth = 0;
|
|
33
|
+
let topLevel = 0;
|
|
34
|
+
|
|
35
|
+
while (i < n) {
|
|
36
|
+
if (contentHtml[i] !== "<") {
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (contentHtml.startsWith("<!--", i)) {
|
|
41
|
+
const end = contentHtml.indexOf("-->", i + 4);
|
|
42
|
+
i = end === -1 ? n : end + 3;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const gt = contentHtml.indexOf(">", i);
|
|
46
|
+
if (gt === -1) break; // malformed tail — stop rather than emit a partial tag
|
|
47
|
+
const tag = contentHtml.slice(i, gt + 1);
|
|
48
|
+
i = gt + 1;
|
|
49
|
+
|
|
50
|
+
const closing = tag.startsWith("</");
|
|
51
|
+
const selfClosing = tag.endsWith("/>");
|
|
52
|
+
const name = tag.match(/^<\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/)?.[1]?.toLowerCase() ?? "";
|
|
53
|
+
|
|
54
|
+
if (closing) {
|
|
55
|
+
if (depth > 0) depth--;
|
|
56
|
+
if (depth === 0) {
|
|
57
|
+
topLevel++;
|
|
58
|
+
if (topLevel >= blocks) return contentHtml.slice(0, i);
|
|
59
|
+
}
|
|
60
|
+
} else if (selfClosing || VOID_TAGS.has(name)) {
|
|
61
|
+
if (depth === 0) {
|
|
62
|
+
topLevel++;
|
|
63
|
+
if (topLevel >= blocks) return contentHtml.slice(0, i);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
depth++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return contentHtml;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The body HTML to render for an article: full content when the viewer is
|
|
74
|
+
* entitled (or the post is ungated), a teaser otherwise. The single place the
|
|
75
|
+
* gate/teaser branch lives, so the public/full-content path stays provably
|
|
76
|
+
* unchanged — `articleBodyHtml(html, { gated: false, ... }) === html`.
|
|
77
|
+
*/
|
|
78
|
+
export function articleBodyHtml(
|
|
79
|
+
contentHtml: string,
|
|
80
|
+
opts: { gated: boolean; unlock: boolean },
|
|
81
|
+
): string {
|
|
82
|
+
return opts.gated && !opts.unlock ? teaserHtml(contentHtml) : contentHtml;
|
|
83
|
+
}
|
|
@@ -5,6 +5,16 @@ export interface BlogImage {
|
|
|
5
5
|
height?: number;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
export type PostStatus = "draft" | "published" | "unlisted" | "scheduled"; // "scheduled" added in B3.1
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Per-post entitlement gate — who may read the body. Orthogonal to `PostStatus`
|
|
12
|
+
* (which answers "is this published to the live site"): a post has both. Checked
|
|
13
|
+
* at render time via `viewerHasBlogAccess`, never folded into `isVisibleOnLive`.
|
|
14
|
+
* Room for future `"tier:<id>"` / `"assurance:<acr>"`; only these three today.
|
|
15
|
+
*/
|
|
16
|
+
export type BlogAccess = "public" | "authenticated" | "members";
|
|
17
|
+
|
|
8
18
|
export interface BlogPostSummary {
|
|
9
19
|
source: { platform: "wordpress"; id: string };
|
|
10
20
|
slug: string;
|
|
@@ -18,9 +28,33 @@ export interface BlogPostSummary {
|
|
|
18
28
|
categories: string[];
|
|
19
29
|
tags: string[];
|
|
20
30
|
featuredImage?: BlogImage;
|
|
31
|
+
status: PostStatus; // seeded/legacy posts without this field default to "published"
|
|
32
|
+
scheduledAt?: string; // ISO date; when status === "scheduled", the post goes live once this instant has passed (B3.1)
|
|
33
|
+
canonicalUrl?: string; // syndication override (B1.6)
|
|
34
|
+
inLanguage?: string; // default "en" (B1.6)
|
|
35
|
+
access?: BlogAccess; // entitlement gate; default "public". Orthogonal to `status` (B4.0)
|
|
36
|
+
translationGroupId?: string; // stable id shared by every language variant of one article; absent = singleton (I1)
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
export interface BlogPost extends BlogPostSummary {
|
|
24
40
|
oldSlugs: string[];
|
|
25
41
|
contentHtml: string;
|
|
42
|
+
// Markdown source for the admin editor (B3.4). When present, `contentHtml` is
|
|
43
|
+
// its rendered output; migrated/legacy posts may carry only `contentHtml`.
|
|
44
|
+
bodyMarkdown?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Optional per-tenant × per-collection author bios, keyed by the author's slug
|
|
49
|
+
* (see `authorSlug`). Sourced from `tenants/<id>/content/<collection>/authors.json`;
|
|
50
|
+
* absent file or missing key falls back to just the byline name.
|
|
51
|
+
*/
|
|
52
|
+
export interface AuthorBio {
|
|
53
|
+
name: string;
|
|
54
|
+
bio?: string;
|
|
55
|
+
title?: string; // role/title, e.g. "Founder"
|
|
56
|
+
avatar?: string; // image path (site-relative or absolute)
|
|
57
|
+
url?: string; // personal/site URL
|
|
58
|
+
sameAs?: string[]; // social profile URLs — E-E-A-T signal (feeds Person.sameAs, B1.6)
|
|
59
|
+
twitter?: string; // handle, e.g. "@ada" (feeds twitter:creator, B1.6)
|
|
26
60
|
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-post view counts: the "first demand signal" for the blog platform.
|
|
3
|
+
*
|
|
4
|
+
* Rides the EXISTING RUM beacon (`api/rum/vitals.ts`) rather than adding a new
|
|
5
|
+
* ingestion path. Each page load emits exactly one CLS field-vitals sample (LCP
|
|
6
|
+
* and INP are conditional), so the beacon handler treats that CLS sample as the
|
|
7
|
+
* one-per-view signal and increments counters when it lands on a blog-post URL.
|
|
8
|
+
* All counts are keyed by (tenant, collection, slug) — no PII, no per-visitor
|
|
9
|
+
* identity.
|
|
10
|
+
*
|
|
11
|
+
* Two counters per view, written together:
|
|
12
|
+
* - a rolling LIFETIME total (`blogViewsKey`), unbounded; and
|
|
13
|
+
* - a per-UTC-day BUCKET (`blogViewsDayKey`, TTL-expiring) that powers the
|
|
14
|
+
* time-series sparkline and the "views over last N days" top-posts ranking.
|
|
15
|
+
* The day buckets are additive to the lifetime total, not a replacement.
|
|
16
|
+
*
|
|
17
|
+
* Storage is the TENANT_CACHE KV namespace — the same binding the newsletter stub
|
|
18
|
+
* writes to and the only durable store bound on the live `preview` worker. The
|
|
19
|
+
* increment is a read-modify-write, so under concurrency some increments can be
|
|
20
|
+
* lost to KV's last-write-wins semantics: acceptable for a rough demand signal,
|
|
21
|
+
* not a billing counter.
|
|
22
|
+
*
|
|
23
|
+
* Tenant/collection-generic by construction — nothing here is bound to
|
|
24
|
+
* `the-build` or `tokenoftrust.com`.
|
|
25
|
+
*/
|
|
26
|
+
import { getPost, listPosts } from "./collection.js";
|
|
27
|
+
|
|
28
|
+
// Mirror the engine's guards (collection.ts): a collection is one path segment;
|
|
29
|
+
// a slug is lower-kebab. Keeping them local avoids widening the engine's surface.
|
|
30
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
31
|
+
const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
32
|
+
|
|
33
|
+
// Daily buckets auto-expire this many days after their last write. Comfortably
|
|
34
|
+
// past the widest window the dashboard reads (30d), so a "last N days" query
|
|
35
|
+
// never asks for a key that should still exist but was reaped.
|
|
36
|
+
const DAY_BUCKET_TTL_DAYS = 45;
|
|
37
|
+
const DAY_MS = 86_400_000;
|
|
38
|
+
|
|
39
|
+
/** KV key for one post's rolling lifetime view count. */
|
|
40
|
+
export function blogViewsKey(tenantId: string, collection: string, slug: string): string {
|
|
41
|
+
return `blogviews:${tenantId}:${collection}:${slug}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `yyyy-mm-dd` (UTC) stamp for a Date — the day-bucket suffix. */
|
|
45
|
+
export function dayStamp(date: Date): string {
|
|
46
|
+
return date.toISOString().slice(0, 10);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* KV key for one post's views on ONE UTC day. Distinct from `blogViewsKey` by
|
|
51
|
+
* the trailing `:<yyyy-mm-dd>` segment, so the lifetime counter and the daily
|
|
52
|
+
* buckets never collide.
|
|
53
|
+
*/
|
|
54
|
+
export function blogViewsDayKey(
|
|
55
|
+
tenantId: string,
|
|
56
|
+
collection: string,
|
|
57
|
+
slug: string,
|
|
58
|
+
day: string,
|
|
59
|
+
): string {
|
|
60
|
+
return `${blogViewsKey(tenantId, collection, slug)}:${day}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a request pathname into a candidate (collection, slug) post ref, or null
|
|
65
|
+
* if it is not shaped like a post URL. A post lives at `<base>/<slug>/`, so the
|
|
66
|
+
* LAST two non-empty path segments are the candidate collection + slug. Taking
|
|
67
|
+
* the last two (not the first) naturally ignores any `/<domain>` basePath prefix
|
|
68
|
+
* (DNS-free preview routing) and rejects listing/section pages (`/the-build/`,
|
|
69
|
+
* `/the-build/category/x/`, `/the-build/author/x/`) and file routes
|
|
70
|
+
* (`/the-build/rss.xml`, whose slug fails `SLUG_RE`) — those either fail the
|
|
71
|
+
* guards or resolve to no post. This is only a SHAPE guard; the caller confirms
|
|
72
|
+
* the post actually exists and is live via `getPost`.
|
|
73
|
+
*/
|
|
74
|
+
export function parseBlogPostPath(path: string): { collection: string; slug: string } | null {
|
|
75
|
+
const segs = path.split("/").filter(Boolean);
|
|
76
|
+
if (segs.length < 2) return null;
|
|
77
|
+
const slug = segs[segs.length - 1]!;
|
|
78
|
+
const collection = segs[segs.length - 2]!;
|
|
79
|
+
if (!SEGMENT_RE.test(collection) || !SLUG_RE.test(slug)) return null;
|
|
80
|
+
return { collection, slug };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readCount(kv: KVNamespace, key: string): Promise<number> {
|
|
84
|
+
const raw = await kv.get(key);
|
|
85
|
+
const n = raw == null ? 0 : Number.parseInt(raw, 10);
|
|
86
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Increment the view counter for a post IF the pathname resolves to a live
|
|
91
|
+
* (published or unlisted) post for this tenant. Drafts, 404s, and non-blog paths
|
|
92
|
+
* are ignored — `getPost` returns null on the live path for anything not visible.
|
|
93
|
+
* Best-effort: never throws (a telemetry beacon must never fail the request).
|
|
94
|
+
*/
|
|
95
|
+
export async function recordBlogView(
|
|
96
|
+
kv: KVNamespace,
|
|
97
|
+
tenantId: string,
|
|
98
|
+
path: string,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
const ref = parseBlogPostPath(path);
|
|
102
|
+
if (!ref) return;
|
|
103
|
+
const post = await getPost(tenantId, ref.collection, ref.slug);
|
|
104
|
+
if (!post) return; // not a live post (draft / 404 / non-blog) — do not count
|
|
105
|
+
const key = blogViewsKey(tenantId, ref.collection, ref.slug);
|
|
106
|
+
const dayKey = blogViewsDayKey(tenantId, ref.collection, ref.slug, dayStamp(new Date()));
|
|
107
|
+
const [lifetime, today] = await Promise.all([readCount(kv, key), readCount(kv, dayKey)]);
|
|
108
|
+
await Promise.all([
|
|
109
|
+
// Rolling lifetime counter — unbounded.
|
|
110
|
+
kv.put(key, String(lifetime + 1)),
|
|
111
|
+
// Additive daily bucket for the time-series/top-posts dashboard. TTL keeps
|
|
112
|
+
// the day-bucket keyspace bounded (a day is only ever written on that day,
|
|
113
|
+
// so it expires ~45d after it stops receiving writes).
|
|
114
|
+
kv.put(dayKey, String(today + 1), { expirationTtl: (DAY_BUCKET_TTL_DAYS * DAY_MS) / 1000 }),
|
|
115
|
+
]);
|
|
116
|
+
} catch {
|
|
117
|
+
/* swallow — view counting is best-effort telemetry */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* READER for the future admin "Blog" tab (B2.1, not yet in this base branch).
|
|
123
|
+
* The tab is server-rendered with `Astro.locals.tenant` and can obtain the KV via
|
|
124
|
+
* `readKv("TENANT_CACHE")`, so it calls this directly with the slugs from
|
|
125
|
+
* `listPosts(tenantId, collection, { includeUnpublished: true })` to render a
|
|
126
|
+
* "Views" column. Returns a slug -> count map (missing keys read as 0); a null KV
|
|
127
|
+
* (local dev, no binding) yields all-zero counts.
|
|
128
|
+
*/
|
|
129
|
+
export async function getBlogViewCounts(
|
|
130
|
+
kv: KVNamespace | undefined,
|
|
131
|
+
tenantId: string,
|
|
132
|
+
collection: string,
|
|
133
|
+
slugs: readonly string[],
|
|
134
|
+
): Promise<Record<string, number>> {
|
|
135
|
+
const out: Record<string, number> = {};
|
|
136
|
+
for (const slug of slugs) out[slug] = 0;
|
|
137
|
+
if (!kv) return out;
|
|
138
|
+
await Promise.all(
|
|
139
|
+
slugs.map(async (slug) => {
|
|
140
|
+
out[slug] = await readCount(kv, blogViewsKey(tenantId, collection, slug));
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The UTC day stamps for the last `days` days, oldest first (today is last). */
|
|
147
|
+
export function recentDayStamps(days: number, now: Date = new Date()): string[] {
|
|
148
|
+
const n = Math.max(1, Math.floor(days));
|
|
149
|
+
const end = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
150
|
+
const out: string[] = [];
|
|
151
|
+
for (let i = n - 1; i >= 0; i--) out.push(dayStamp(new Date(end - i * DAY_MS)));
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One day of a post's view series. */
|
|
156
|
+
export interface DailyViews {
|
|
157
|
+
day: string;
|
|
158
|
+
views: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Per-day view series for one post over the last `days` days (oldest first),
|
|
163
|
+
* for a sparkline/trend. Missing days read as 0; a null KV yields an all-zero
|
|
164
|
+
* series of the right length.
|
|
165
|
+
*/
|
|
166
|
+
export async function getPostViewSeries(
|
|
167
|
+
kv: KVNamespace | undefined,
|
|
168
|
+
tenantId: string,
|
|
169
|
+
collection: string,
|
|
170
|
+
slug: string,
|
|
171
|
+
days: number,
|
|
172
|
+
now: Date = new Date(),
|
|
173
|
+
): Promise<DailyViews[]> {
|
|
174
|
+
const stamps = recentDayStamps(days, now);
|
|
175
|
+
if (!kv) return stamps.map((day) => ({ day, views: 0 }));
|
|
176
|
+
return Promise.all(
|
|
177
|
+
stamps.map(async (day) => ({
|
|
178
|
+
day,
|
|
179
|
+
views: await readCount(kv, blogViewsDayKey(tenantId, collection, slug, day)),
|
|
180
|
+
})),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One ranked row in the top-posts dashboard. */
|
|
185
|
+
export interface TopPost {
|
|
186
|
+
collection: string;
|
|
187
|
+
slug: string;
|
|
188
|
+
title: string;
|
|
189
|
+
status: string;
|
|
190
|
+
/** Views within the window (`sinceDays`), or lifetime when no window given. */
|
|
191
|
+
views: number;
|
|
192
|
+
/** Lifetime total, always populated. */
|
|
193
|
+
lifetime: number;
|
|
194
|
+
/** Per-day series over the window, oldest first — omitted when unwindowed. */
|
|
195
|
+
series?: DailyViews[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Rank a collection's posts by view count, most-viewed first.
|
|
200
|
+
*
|
|
201
|
+
* KV has no cross-key aggregation, so we take the simplest correct approach:
|
|
202
|
+
* enumerate the known posts via `listPosts` and read each one's counter(s). At
|
|
203
|
+
* blog scale (tens of posts) this is a bounded fan-out of parallel KV gets, not
|
|
204
|
+
* a scan — fine for an admin-tab render, and it needs no write-time index to
|
|
205
|
+
* keep in sync. `opts.sinceDays` ranks by a windowed sum of the daily buckets
|
|
206
|
+
* (and returns the series for a sparkline); without it, ranking is by lifetime.
|
|
207
|
+
*/
|
|
208
|
+
export async function getTopPosts(
|
|
209
|
+
kv: KVNamespace | undefined,
|
|
210
|
+
tenantId: string,
|
|
211
|
+
collection: string,
|
|
212
|
+
opts?: { limit?: number; sinceDays?: number; now?: Date },
|
|
213
|
+
): Promise<TopPost[]> {
|
|
214
|
+
const posts = await listPosts(tenantId, collection, { includeUnpublished: true });
|
|
215
|
+
const now = opts?.now ?? new Date();
|
|
216
|
+
const rows = await Promise.all(
|
|
217
|
+
posts.map(async (post) => {
|
|
218
|
+
const lifetime = kv ? await readCount(kv, blogViewsKey(tenantId, collection, post.slug)) : 0;
|
|
219
|
+
if (opts?.sinceDays == null) {
|
|
220
|
+
return {
|
|
221
|
+
collection,
|
|
222
|
+
slug: post.slug,
|
|
223
|
+
title: post.title,
|
|
224
|
+
status: post.status,
|
|
225
|
+
views: lifetime,
|
|
226
|
+
lifetime,
|
|
227
|
+
} satisfies TopPost;
|
|
228
|
+
}
|
|
229
|
+
const series = await getPostViewSeries(
|
|
230
|
+
kv,
|
|
231
|
+
tenantId,
|
|
232
|
+
collection,
|
|
233
|
+
post.slug,
|
|
234
|
+
opts.sinceDays,
|
|
235
|
+
now,
|
|
236
|
+
);
|
|
237
|
+
const views = series.reduce((sum, d) => sum + d.views, 0);
|
|
238
|
+
return { collection, slug: post.slug, title: post.title, status: post.status, views, lifetime, series } satisfies TopPost;
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
rows.sort((a, b) => b.views - a.views || b.lifetime - a.lifetime);
|
|
242
|
+
return opts?.limit != null ? rows.slice(0, Math.max(0, opts.limit)) : rows;
|
|
243
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* D1 catalog read-model queries (the one-store target
|
|
2
|
+
* D1 catalog read-model queries (the one-store target).
|
|
3
3
|
*
|
|
4
4
|
* Queries run against a small `Queryable` seam so the SAME code serves D1 in the
|
|
5
5
|
* Worker (via `fromD1`) and an in-memory `node:sqlite` in tests. Covers FTS5
|
|
@@ -154,7 +154,7 @@ export async function getProductByHandle(
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
|
-
* All active-status products, full data. No pagination — the
|
|
157
|
+
* All active-status products, full data. No pagination — the inventory
|
|
158
158
|
* projection (`api/apps/v1/inventory.ts`) reads the whole active catalog to
|
|
159
159
|
* build its per-SKU list, mirroring `searchProducts`'s active-only scoping.
|
|
160
160
|
*/
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Drizzle schema — the source of truth for `STOREFRONT_APPS_DB` *migration
|
|
3
|
-
* generation
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* D1, never the catalog.
|
|
3
|
+
* generation*. `schema.ts` covers the catalog + customization tables on
|
|
4
|
+
* `CATALOG_DB`; these tables are the storefront-apps registry/webhooks/orders
|
|
5
|
+
* spine and belong to a DEDICATED D1, never the catalog.
|
|
7
6
|
*
|
|
8
7
|
* `drizzle-kit generate --config drizzle.config.apps.ts` diffs this into
|
|
9
8
|
* numbered SQL under `migrations-apps/`, applied by `wrangler d1 migrations
|
|
@@ -11,8 +10,8 @@
|
|
|
11
10
|
*/
|
|
12
11
|
import { sqliteTable, text, integer, real, index, uniqueIndex, primaryKey } from "drizzle-orm/sqlite-core";
|
|
13
12
|
|
|
14
|
-
// --- Private Apps registry
|
|
15
|
-
// One install per (app, tenant, env). `installId` is the
|
|
13
|
+
// --- Private Apps registry --------------------------------------------------
|
|
14
|
+
// One install per (app, tenant, env). `installId` is the JWT `sub`.
|
|
16
15
|
// Append-only `app_audit_log` is never pruned by the service layer.
|
|
17
16
|
export const appInstalls = sqliteTable(
|
|
18
17
|
"app_installs",
|
|
@@ -71,7 +70,61 @@ export const appAuditLog = sqliteTable(
|
|
|
71
70
|
}),
|
|
72
71
|
);
|
|
73
72
|
|
|
74
|
-
// ---
|
|
73
|
+
// --- Operational activity timeline ------------------------------------------
|
|
74
|
+
// The low-volume / high-value LIFECYCLE cohort of the actor×action telemetry
|
|
75
|
+
// spine (DECISIONS.md §8). Only actions whose catalog `spec.sinks` include
|
|
76
|
+
// "timeline" land here; the high-volume cohort goes to Cloudflare Analytics
|
|
77
|
+
// Engine (binding `ACTIVITY_ANALYTICS`), and EVERY action also goes to Workers
|
|
78
|
+
// Logs. Reuses the EXISTING STOREFRONT_APPS_DB (precedent: `app_audit_log`
|
|
79
|
+
// above) rather than a new binding — the cohort is small (~50k rows/month at
|
|
80
|
+
// 10× pilot) and the timeline query UI needs point queries + joins + arbitrary
|
|
81
|
+
// retention that Analytics Engine cannot give.
|
|
82
|
+
//
|
|
83
|
+
// `id` is the emitter-minted event UUID and the PRIMARY KEY, so a retried/
|
|
84
|
+
// replayed ingest is idempotent (INSERT OR IGNORE). `tenant_id` is written from
|
|
85
|
+
// the AUTHENTICATED ingest identity, never the client-supplied `scope.tenantId`
|
|
86
|
+
// (see api/activity.ts) — that is the multi-tenant isolation boundary. `payload`
|
|
87
|
+
// is the SERVER-redacted JSON (default-deny allowlist + canary scan re-applied
|
|
88
|
+
// at ingest, never trusting client-side redaction alone). The core columns
|
|
89
|
+
// mirror the `ActivityEvent` envelope's non-payload zone (packages/public-
|
|
90
|
+
// runtime/src/activity/event.ts), flattened for SQL point queries.
|
|
91
|
+
export const activityEvents = sqliteTable(
|
|
92
|
+
"activity_events",
|
|
93
|
+
{
|
|
94
|
+
id: text("id").primaryKey(),
|
|
95
|
+
tenantId: text("tenant_id").notNull(),
|
|
96
|
+
action: text("action").notNull(),
|
|
97
|
+
actorKind: text("actor_kind").notNull(),
|
|
98
|
+
// Opaque, pseudonymized actor id — NEVER raw email/PII (emitter's contract).
|
|
99
|
+
actorId: text("actor_id").notNull(),
|
|
100
|
+
actorRef: text("actor_ref"),
|
|
101
|
+
source: text("source").notNull(),
|
|
102
|
+
status: text("status").notNull(),
|
|
103
|
+
// Low-cardinality error CLASS, never a raw exception message.
|
|
104
|
+
errorClass: text("error_class"),
|
|
105
|
+
durationMs: integer("duration_ms"),
|
|
106
|
+
changeId: text("change_id"),
|
|
107
|
+
traceId: text("trace_id"),
|
|
108
|
+
// Server-redacted payload JSON (args + rendered lanes), default "{}".
|
|
109
|
+
payload: text("payload").notNull().default("{}"),
|
|
110
|
+
// Envelope contract version (ACTIVITY_SCHEMA_VERSION) so a reader can migrate.
|
|
111
|
+
schemaVersion: integer("schema_version").notNull(),
|
|
112
|
+
// Emit-clock ISO-8601 (the event's `at`).
|
|
113
|
+
at: text("at").notNull(),
|
|
114
|
+
// Server receive-clock ISO-8601 (when ingest stored it).
|
|
115
|
+
ingestedAt: text("ingested_at").notNull(),
|
|
116
|
+
},
|
|
117
|
+
(t) => ({
|
|
118
|
+
// The timeline query: a tenant's events newest-first.
|
|
119
|
+
idxActivityTenantAt: index("idx_activity_tenant_at").on(t.tenantId, t.at),
|
|
120
|
+
// Filter a tenant's timeline by action family (dashboards glob the prefix).
|
|
121
|
+
idxActivityTenantAction: index("idx_activity_tenant_action").on(t.tenantId, t.action),
|
|
122
|
+
// Join a change's whole lifecycle span across actions.
|
|
123
|
+
idxActivityChange: index("idx_activity_change").on(t.changeId),
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// --- Webhook event spine ----------------------------------------------------
|
|
75
128
|
// Immutable event log. `id` is the semantic event id we mint — distinct from
|
|
76
129
|
// any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
|
|
77
130
|
export const webhookEvents = sqliteTable("webhook_events", {
|
|
@@ -141,10 +194,10 @@ export const webhookInstallHealth = sqliteTable("webhook_install_health", {
|
|
|
141
194
|
updatedAt: text("updated_at").notNull(),
|
|
142
195
|
});
|
|
143
196
|
|
|
144
|
-
// --- Order ingestion
|
|
197
|
+
// --- Order ingestion --------------------------------------------------------
|
|
145
198
|
// The read-model `ordersStore.ts` diffs incoming Foxy orders against (created
|
|
146
|
-
// → fulfilled transitions, for
|
|
147
|
-
// attribution route
|
|
199
|
+
// → fulfilled transitions, for event emission) and that the
|
|
200
|
+
// attribution route reads from for its cross-tenant ownership
|
|
148
201
|
// check. `(tenantId, externalOrderId)` is the composite PK — same "no
|
|
149
202
|
// synthetic id, the natural key IS the key" style as `customizationVersions`
|
|
150
203
|
// (schema.ts) — no SQL FK to `appAttributions.orderId` (repo convention — code-only).
|
|
@@ -171,7 +224,7 @@ export const orders = sqliteTable(
|
|
|
171
224
|
}),
|
|
172
225
|
);
|
|
173
226
|
|
|
174
|
-
//
|
|
227
|
+
// The idempotency ledger — keyed per
|
|
175
228
|
// `(installId, idempotencyKey)` so a retried write (e.g. attribution POST)
|
|
176
229
|
// replays the stored `responseBody` instead of re-applying the mutation.
|
|
177
230
|
// `bodyHash` (sha256 of the canonical request body) lets a caller re-sending
|
|
@@ -188,9 +241,38 @@ export const idempotencyKeys = sqliteTable(
|
|
|
188
241
|
(t) => ({ pk: primaryKey({ columns: [t.installId, t.idempotencyKey] }) }),
|
|
189
242
|
);
|
|
190
243
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
244
|
+
// --- Encrypted outbound social OAuth tokens ---------------------------------
|
|
245
|
+
// The ONLY retrievable secret store in this codebase: per-`(tenant, network)`
|
|
246
|
+
// OAuth access/refresh tokens we must REPLAY to X/LinkedIn, so — unlike the
|
|
247
|
+
// one-way-hashed `app_credentials` above — they are stored ENCRYPTED-AT-REST,
|
|
248
|
+
// not hashed. `ciphertext` is AES-GCM(base64) over the JSON token payload and
|
|
249
|
+
// `iv` its per-write 96-bit nonce (base64); the key-encryption-key (KEK) is a
|
|
250
|
+
// Worker env binding (`SOCIAL_TOKEN_KEK`), NEVER a column here — mirroring how
|
|
251
|
+
// every other real secret in this codebase is an env binding, not row data.
|
|
252
|
+
// Composite PK `(tenant_id, network)` is the natural key (same "the natural key
|
|
253
|
+
// IS the key" style as `orders`); a rotate is an INSERT OR REPLACE overwrite.
|
|
254
|
+
// `expires_at` is the token's own OAuth expiry (nullable — long-lived manual
|
|
255
|
+
// creds have none), distinct from the row's `updated_at` bookkeeping clock.
|
|
256
|
+
export const socialTokens = sqliteTable(
|
|
257
|
+
"social_tokens",
|
|
258
|
+
{
|
|
259
|
+
tenantId: text("tenant_id").notNull(),
|
|
260
|
+
network: text("network").notNull(),
|
|
261
|
+
ciphertext: text("ciphertext").notNull(),
|
|
262
|
+
iv: text("iv").notNull(),
|
|
263
|
+
expiresAt: text("expires_at"),
|
|
264
|
+
createdAt: text("created_at").notNull(),
|
|
265
|
+
updatedAt: text("updated_at").notNull(),
|
|
266
|
+
},
|
|
267
|
+
(t) => ({
|
|
268
|
+
pk: primaryKey({ columns: [t.tenantId, t.network] }),
|
|
269
|
+
idxSocialTokensTenant: index("idx_social_tokens_tenant").on(t.tenantId),
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// App-owned attribution/commission records (`attribution:write`). `orderId`
|
|
274
|
+
// references `orders.externalOrderId` in code
|
|
275
|
+
// only (no SQL FK, repo convention); the route enforces the cross-tenant
|
|
194
276
|
// ownership check the contract requires before ever writing a row here.
|
|
195
277
|
export const appAttributions = sqliteTable(
|
|
196
278
|
"app_attributions",
|
|
@@ -211,3 +293,54 @@ export const appAttributions = sqliteTable(
|
|
|
211
293
|
idxAppAttributionsTenantOrder: index("idx_app_attributions_tenant_order").on(t.tenantId, t.orderId),
|
|
212
294
|
}),
|
|
213
295
|
);
|
|
296
|
+
|
|
297
|
+
// --- Newsletter subscribers -------------------------------------------------
|
|
298
|
+
// First-party audience for the blog/newsletter digest, kept in OUR D1 (not a
|
|
299
|
+
// vendor list) so the per-tenant isolation + GDPR data-control invariants stay
|
|
300
|
+
// under our control.
|
|
301
|
+
// `tenantId` is the isolation boundary, written from the resolved host/session
|
|
302
|
+
// — NEVER a client body. Keyed on `(tenant_id, collection, email)` so a tenant
|
|
303
|
+
// with multiple collections (e.g. `blog` + `the-build`) runs independent lists.
|
|
304
|
+
// `token` is an opaque random used for both confirm + one-click unsubscribe
|
|
305
|
+
// links; `consentIpHash` is sha256(ip+salt) — never a raw IP (PII). Timestamps
|
|
306
|
+
// are ISO-8601 text. `status`: pending | confirmed | unsubscribed | bounced.
|
|
307
|
+
export const newsletterSubscribers = sqliteTable(
|
|
308
|
+
"newsletter_subscribers",
|
|
309
|
+
{
|
|
310
|
+
tenantId: text("tenant_id").notNull(),
|
|
311
|
+
collection: text("collection").notNull(),
|
|
312
|
+
email: text("email").notNull(),
|
|
313
|
+
status: text("status").notNull().default("pending"),
|
|
314
|
+
token: text("token").notNull(),
|
|
315
|
+
consentSource: text("consent_source"),
|
|
316
|
+
consentIpHash: text("consent_ip_hash"),
|
|
317
|
+
subscribedAt: text("subscribed_at").notNull(),
|
|
318
|
+
confirmedAt: text("confirmed_at"),
|
|
319
|
+
unsubscribedAt: text("unsubscribed_at"),
|
|
320
|
+
lastSentAt: text("last_sent_at"),
|
|
321
|
+
},
|
|
322
|
+
(t) => ({
|
|
323
|
+
pk: primaryKey({ columns: [t.tenantId, t.collection, t.email] }),
|
|
324
|
+
idxNewsletterTenantCollStatus: index("idx_newsletter_tenant_coll_status").on(
|
|
325
|
+
t.tenantId,
|
|
326
|
+
t.collection,
|
|
327
|
+
t.status,
|
|
328
|
+
),
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// Idempotent per-digest send log so a re-triggered digest can't double-send.
|
|
333
|
+
// Keyed on `(tenant_id, collection, digest_id)` (e.g. `the-build-2026-W34`);
|
|
334
|
+
// `postSlugs` is a JSON array of the slugs included in that digest.
|
|
335
|
+
export const newsletterSends = sqliteTable(
|
|
336
|
+
"newsletter_sends",
|
|
337
|
+
{
|
|
338
|
+
tenantId: text("tenant_id").notNull(),
|
|
339
|
+
collection: text("collection").notNull(),
|
|
340
|
+
digestId: text("digest_id").notNull(),
|
|
341
|
+
postSlugs: text("post_slugs").notNull().default("[]"),
|
|
342
|
+
recipientCount: integer("recipient_count").notNull().default(0),
|
|
343
|
+
sentAt: text("sent_at").notNull(),
|
|
344
|
+
},
|
|
345
|
+
(t) => ({ pk: primaryKey({ columns: [t.tenantId, t.collection, t.digestId] }) }),
|
|
346
|
+
);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Opaque, SESSION-KV-backed credential that lets a developer's LOCAL dev-loop
|
|
3
3
|
* process authenticate POST /api/dev/activity on the hosted Worker — the
|
|
4
|
-
* local→hosted activity bridge
|
|
5
|
-
* 2026-07-14-hosted-dev-activity-bridge-and-stepper).
|
|
4
|
+
* local→hosted activity bridge.
|
|
6
5
|
*
|
|
7
6
|
* Minted alongside the existing cli-signin-code paste
|
|
8
7
|
* (apps/storefront/src/lib/dev/cliSignInCode.ts) and cached by the CLI in
|
|
@@ -30,8 +29,7 @@ export interface ActivityIngestClaims {
|
|
|
30
29
|
* sign-in code was issued (see api/dev/cli-signin-code.ts). It rides in the
|
|
31
30
|
* claims so every runtime/activity write the local loop makes with this token
|
|
32
31
|
* is stampable with the same id — support looks a live session up by it.
|
|
33
|
-
* Optional
|
|
34
|
-
* carry no trace); every freshly-minted token sets it.
|
|
32
|
+
* Optional: a token without it carries no trace; every freshly-minted token sets it.
|
|
35
33
|
*/
|
|
36
34
|
traceId?: string;
|
|
37
35
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-vendor "recent activity" feed for the hosted /dev panel — a small ring
|
|
3
3
|
* buffer of file-save events reported by a developer's LOCAL dev loop (see
|
|
4
|
-
* activityIngestToken.ts for the write-path auth). File-level only
|
|
5
|
-
*
|
|
6
|
-
* handoff 2026-07-14-hosted-dev-activity-bridge-and-stepper).
|
|
4
|
+
* activityIngestToken.ts for the write-path auth). File-level only: no
|
|
5
|
+
* per-line diff, no separate published-history tier.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
export type ActivityEventKind = "change" | "add" | "unlink" | "up";
|
|
@@ -62,8 +61,7 @@ export async function clearActivity(kv: KVNamespace, appDomain: string): Promise
|
|
|
62
61
|
|
|
63
62
|
/**
|
|
64
63
|
* Business-readable translation of a reported file path + event, for the
|
|
65
|
-
* activity log. File-level only — no per-line diff enrichment
|
|
66
|
-
* 2026-07-14: "we can show that in step 2").
|
|
64
|
+
* activity log. File-level only — no per-line diff enrichment.
|
|
67
65
|
*/
|
|
68
66
|
export function describeActivity(file: string | undefined, event: ActivityEventKind): string {
|
|
69
67
|
if (event === "up") return "Your store is running locally";
|
|
@@ -3,12 +3,9 @@
|
|
|
3
3
|
* click-to-source). Local `astro dev` serves these off dev-server-only plugins at
|
|
4
4
|
* `/__tot/*` (dev-plugins/dev-file-browser.mjs, dev-write.mjs, dev-diff.mjs, ...) —
|
|
5
5
|
* paths that don't exist in the deployed Cloudflare Worker. The hosted Worker instead
|
|
6
|
-
* serves the SAME shape off `/api/dev/*` Astro API routes
|
|
6
|
+
* serves the SAME shape off `/api/dev/*` Astro API routes
|
|
7
7
|
* (cp-tree, cp-file, cp-write, cp-diff, cp-locate). One dev.astro UI, one resolver.
|
|
8
8
|
*
|
|
9
|
-
* See decision `architecture-environment-aware-cockpit`
|
|
10
|
-
* (workstream tot-lime-cloud-dev-cockpit).
|
|
11
|
-
*
|
|
12
9
|
* `basePath` is the request's tenant prefix ("" host-routed, "/<tenant>" path-routed).
|
|
13
10
|
* The HOSTED routes are tenant-scoped through the middleware's host/path resolution, so
|
|
14
11
|
* a `/<tenant>/cockpit` render must call `/<tenant>/api/dev/*` for `locals.tenant` to be
|