@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,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query surface over the D1 `activity_events` timeline.
|
|
3
|
+
*
|
|
4
|
+
* The READ half of the operational-activity spine: given a tenant, return that
|
|
5
|
+
* tenant's lifecycle activity newest-first for the `/admin` ops timeline UI
|
|
6
|
+
* ("who shipped/rejected what, when, why — and what happened to my change/
|
|
7
|
+
* deploy"). PURE over the injected `Queryable` seam (D1 in the Worker, an
|
|
8
|
+
* in-memory `node:sqlite` in tests), same posture as `lib/d1/catalog.ts` and the
|
|
9
|
+
* write path (`store.ts`).
|
|
10
|
+
*
|
|
11
|
+
* TENANT ISOLATION IS THE INVARIANT. `tenantId` is REQUIRED on every query and is
|
|
12
|
+
* ALWAYS the first `WHERE` predicate (`tenant_id = ?`, a bound param — never
|
|
13
|
+
* string-interpolated). There is NO code path that reads `activity_events` without
|
|
14
|
+
* a tenant equality bound in: a query with a missing/empty tenant THROWS rather
|
|
15
|
+
* than degrading to a cross-tenant scan. This mirrors the write-side isolation
|
|
16
|
+
* (ingest writes only the authenticated tenant) for the read side — a colliding
|
|
17
|
+
* `changeId`/`actorId`/`action` across two tenants can never surface tenant B's
|
|
18
|
+
* rows to a tenant-A query, because the collision is AND-ed under the tenant
|
|
19
|
+
* bound, not OR-ed with it. See `query.isolation.test.ts`.
|
|
20
|
+
*
|
|
21
|
+
* DESERIALIZATION. The stored `payload` column is the SERVER-redacted JSON
|
|
22
|
+
* (default-deny allowlist + canary scan already applied at ingest — see
|
|
23
|
+
* `ingest.ts`/`redaction.ts`); we only parse it back into `{ args, rendered }` for
|
|
24
|
+
* the UI. We never re-expand or un-redact it; a malformed row degrades to an empty
|
|
25
|
+
* payload rather than throwing the whole query.
|
|
26
|
+
*
|
|
27
|
+
* DEPLOY SKEW (lightweight). The `deploy.version_observed` action is
|
|
28
|
+
* in the timeline cohort, so its rows are queryable here. `deriveDeploySkew()`
|
|
29
|
+
* folds those rows into the set of distinct build versions observed for the tenant
|
|
30
|
+
* and compares them against the build serving THIS query (`workerCommitShort()`),
|
|
31
|
+
* surfacing "a stale/newer isolate is (or was) live" as a visible signal. The full
|
|
32
|
+
* alerting logic lives elsewhere — this only makes the data visibly queryable.
|
|
33
|
+
*/
|
|
34
|
+
import {
|
|
35
|
+
getActionSpec,
|
|
36
|
+
type ActivityPayload,
|
|
37
|
+
type ActorKind,
|
|
38
|
+
type ActivitySource,
|
|
39
|
+
type ActivityStatus,
|
|
40
|
+
} from "@tot/public-runtime";
|
|
41
|
+
import type { Queryable } from "../d1/catalog.js";
|
|
42
|
+
import { workerCommitShort, UNKNOWN_COMMIT } from "./workerCommit.js";
|
|
43
|
+
|
|
44
|
+
/** Hard cap on rows returned by one query — the ops timeline is paged/bounded,
|
|
45
|
+
* never an unbounded scan. */
|
|
46
|
+
export const MAX_TIMELINE_ROWS = 500;
|
|
47
|
+
/** Default page size when the caller doesn't specify one. */
|
|
48
|
+
export const DEFAULT_TIMELINE_ROWS = 100;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A timeline query. `tenantId` is REQUIRED (the isolation boundary); every other
|
|
52
|
+
* field is an optional, AND-ed narrowing. Ordering is newest-first by default
|
|
53
|
+
* (`at desc`) — the natural shape for a "what just happened" operator timeline.
|
|
54
|
+
*/
|
|
55
|
+
export interface ActivityQuery {
|
|
56
|
+
/** REQUIRED tenant scope. Empty/missing ⇒ the query throws (never a cross-tenant scan). */
|
|
57
|
+
tenantId: string;
|
|
58
|
+
/** Exact catalog action key (e.g. "change.ship"). */
|
|
59
|
+
action?: string;
|
|
60
|
+
/** Action-family prefix (e.g. "change." → every change.* row). Mutually useful with `action`. */
|
|
61
|
+
actionPrefix?: string;
|
|
62
|
+
/** ShipLoop change/candidate id — the "what happened to THIS change" span. */
|
|
63
|
+
changeId?: string;
|
|
64
|
+
/** Opaque, pseudonymized actor id (never raw PII). */
|
|
65
|
+
actorId?: string;
|
|
66
|
+
/** Actor cohort. */
|
|
67
|
+
actorKind?: ActorKind;
|
|
68
|
+
/** Origin surface. */
|
|
69
|
+
source?: ActivitySource;
|
|
70
|
+
/** Terminal/interim status. */
|
|
71
|
+
status?: ActivityStatus;
|
|
72
|
+
/** Inclusive lower bound on the emit clock `at` (ISO-8601). */
|
|
73
|
+
since?: string;
|
|
74
|
+
/** Inclusive upper bound on the emit clock `at` (ISO-8601). */
|
|
75
|
+
until?: string;
|
|
76
|
+
/** Page size (clamped to [1, MAX_TIMELINE_ROWS]). */
|
|
77
|
+
limit?: number;
|
|
78
|
+
/** Sort direction on `at`. Default "desc" (newest first). */
|
|
79
|
+
order?: "asc" | "desc";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The raw D1 row shape (snake_case columns of `activity_events`). */
|
|
83
|
+
interface ActivityRow {
|
|
84
|
+
id: string;
|
|
85
|
+
tenant_id: string;
|
|
86
|
+
action: string;
|
|
87
|
+
actor_kind: string;
|
|
88
|
+
actor_id: string;
|
|
89
|
+
actor_ref: string | null;
|
|
90
|
+
source: string;
|
|
91
|
+
status: string;
|
|
92
|
+
error_class: string | null;
|
|
93
|
+
duration_ms: number | null;
|
|
94
|
+
change_id: string | null;
|
|
95
|
+
trace_id: string | null;
|
|
96
|
+
payload: string;
|
|
97
|
+
schema_version: number;
|
|
98
|
+
at: string;
|
|
99
|
+
ingested_at: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One deserialized timeline row, ready for the ops UI. */
|
|
103
|
+
export interface ActivityTimelineRow {
|
|
104
|
+
id: string;
|
|
105
|
+
tenantId: string;
|
|
106
|
+
action: string;
|
|
107
|
+
/** One-line human description from the catalog (undefined for an unknown action). */
|
|
108
|
+
actionDesc?: string;
|
|
109
|
+
actorKind: string;
|
|
110
|
+
actorId: string;
|
|
111
|
+
actorRef?: string;
|
|
112
|
+
source: string;
|
|
113
|
+
status: string;
|
|
114
|
+
errorClass?: string;
|
|
115
|
+
durationMs?: number;
|
|
116
|
+
changeId?: string;
|
|
117
|
+
traceId?: string;
|
|
118
|
+
/** Redacted payload, parsed back to `{ args, rendered }`. Never un-redacted. */
|
|
119
|
+
payload: ActivityPayload;
|
|
120
|
+
schemaVersion: number;
|
|
121
|
+
at: string;
|
|
122
|
+
ingestedAt: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Parse the stored redacted payload JSON back to `{ args, rendered }`, degrading
|
|
126
|
+
* a malformed value to an empty payload rather than throwing the query. */
|
|
127
|
+
function parsePayload(raw: string): ActivityPayload {
|
|
128
|
+
if (!raw || raw === "{}") return {};
|
|
129
|
+
try {
|
|
130
|
+
const p = JSON.parse(raw) as unknown;
|
|
131
|
+
if (!p || typeof p !== "object") return {};
|
|
132
|
+
const obj = p as Record<string, unknown>;
|
|
133
|
+
const out: ActivityPayload = {};
|
|
134
|
+
if (obj.args && typeof obj.args === "object") out.args = obj.args as Record<string, unknown>;
|
|
135
|
+
if (obj.rendered && typeof obj.rendered === "object") {
|
|
136
|
+
out.rendered = obj.rendered as Record<string, string>;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
} catch {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toTimelineRow(r: ActivityRow): ActivityTimelineRow {
|
|
145
|
+
const spec = getActionSpec(r.action);
|
|
146
|
+
return {
|
|
147
|
+
id: r.id,
|
|
148
|
+
tenantId: r.tenant_id,
|
|
149
|
+
action: r.action,
|
|
150
|
+
...(spec ? { actionDesc: spec.desc } : {}),
|
|
151
|
+
actorKind: r.actor_kind,
|
|
152
|
+
actorId: r.actor_id,
|
|
153
|
+
...(r.actor_ref ? { actorRef: r.actor_ref } : {}),
|
|
154
|
+
source: r.source,
|
|
155
|
+
status: r.status,
|
|
156
|
+
...(r.error_class ? { errorClass: r.error_class } : {}),
|
|
157
|
+
...(r.duration_ms != null ? { durationMs: r.duration_ms } : {}),
|
|
158
|
+
...(r.change_id ? { changeId: r.change_id } : {}),
|
|
159
|
+
...(r.trace_id ? { traceId: r.trace_id } : {}),
|
|
160
|
+
payload: parsePayload(r.payload),
|
|
161
|
+
schemaVersion: r.schema_version,
|
|
162
|
+
at: r.at,
|
|
163
|
+
ingestedAt: r.ingested_at,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Clamp a requested limit into [1, MAX_TIMELINE_ROWS]. */
|
|
168
|
+
function clampLimit(limit?: number): number {
|
|
169
|
+
if (limit == null || !Number.isFinite(limit)) return DEFAULT_TIMELINE_ROWS;
|
|
170
|
+
return Math.min(MAX_TIMELINE_ROWS, Math.max(1, Math.floor(limit)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Escape LIKE metacharacters so an `actionPrefix` is a literal prefix, not a glob. */
|
|
174
|
+
function escapeLike(v: string): string {
|
|
175
|
+
return v.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Query a tenant's activity timeline. THROWS when `tenantId` is empty — there is
|
|
180
|
+
* no unscoped read path. Every optional filter is an AND-ed narrowing bound as a
|
|
181
|
+
* parameter; `tenant_id = ?` is always present and always first, so no filter
|
|
182
|
+
* collision can widen the result across tenants.
|
|
183
|
+
*/
|
|
184
|
+
export async function queryActivity(
|
|
185
|
+
db: Queryable,
|
|
186
|
+
q: ActivityQuery,
|
|
187
|
+
): Promise<ActivityTimelineRow[]> {
|
|
188
|
+
if (!q.tenantId || typeof q.tenantId !== "string") {
|
|
189
|
+
throw new Error("activity query: tenantId is required — every query is tenant-scoped");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// tenant_id is ALWAYS the first predicate and ALWAYS a bound param.
|
|
193
|
+
const where: string[] = ["tenant_id = ?"];
|
|
194
|
+
const params: unknown[] = [q.tenantId];
|
|
195
|
+
|
|
196
|
+
if (q.action) {
|
|
197
|
+
where.push("action = ?");
|
|
198
|
+
params.push(q.action);
|
|
199
|
+
}
|
|
200
|
+
if (q.actionPrefix) {
|
|
201
|
+
where.push("action LIKE ? ESCAPE '\\'");
|
|
202
|
+
params.push(`${escapeLike(q.actionPrefix)}%`);
|
|
203
|
+
}
|
|
204
|
+
if (q.changeId) {
|
|
205
|
+
where.push("change_id = ?");
|
|
206
|
+
params.push(q.changeId);
|
|
207
|
+
}
|
|
208
|
+
if (q.actorId) {
|
|
209
|
+
where.push("actor_id = ?");
|
|
210
|
+
params.push(q.actorId);
|
|
211
|
+
}
|
|
212
|
+
if (q.actorKind) {
|
|
213
|
+
where.push("actor_kind = ?");
|
|
214
|
+
params.push(q.actorKind);
|
|
215
|
+
}
|
|
216
|
+
if (q.source) {
|
|
217
|
+
where.push("source = ?");
|
|
218
|
+
params.push(q.source);
|
|
219
|
+
}
|
|
220
|
+
if (q.status) {
|
|
221
|
+
where.push("status = ?");
|
|
222
|
+
params.push(q.status);
|
|
223
|
+
}
|
|
224
|
+
if (q.since) {
|
|
225
|
+
where.push("at >= ?");
|
|
226
|
+
params.push(q.since);
|
|
227
|
+
}
|
|
228
|
+
if (q.until) {
|
|
229
|
+
where.push("at <= ?");
|
|
230
|
+
params.push(q.until);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const order = q.order === "asc" ? "ASC" : "DESC";
|
|
234
|
+
const limit = clampLimit(q.limit);
|
|
235
|
+
// Secondary sort on id keeps ordering deterministic when two events share `at`.
|
|
236
|
+
const sql = `SELECT * FROM activity_events
|
|
237
|
+
WHERE ${where.join(" AND ")}
|
|
238
|
+
ORDER BY at ${order}, id ${order}
|
|
239
|
+
LIMIT ?`;
|
|
240
|
+
const rows = await db.all<ActivityRow>(sql, ...params, limit);
|
|
241
|
+
return rows.map(toTimelineRow);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Deploy skew ─────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/** One distinct build version observed in the tenant's timeline. */
|
|
247
|
+
export interface ObservedVersion {
|
|
248
|
+
/** Short (7-char) commit, from a `deploy.version_observed` row's payload. */
|
|
249
|
+
version: string;
|
|
250
|
+
/** Deploy env/target the observation named (e.g. "preview"), when present. */
|
|
251
|
+
env?: string;
|
|
252
|
+
/** Newest `at` this version was observed. */
|
|
253
|
+
lastObservedAt: string;
|
|
254
|
+
/** How many observations of this version are in the window. */
|
|
255
|
+
count: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Deploy-skew summary for a tenant. Lightweight and derived, NOT an alert —
|
|
260
|
+
* alerting lives elsewhere. It answers: what build(s) has this tenant seen serving, and does
|
|
261
|
+
* the build serving THIS request match the newest one?
|
|
262
|
+
*/
|
|
263
|
+
export interface DeploySkewSummary {
|
|
264
|
+
/** Short commit of the build serving this query, or null when unknown (vitest/no git). */
|
|
265
|
+
runningVersion: string | null;
|
|
266
|
+
/** Distinct observed versions, newest-first. */
|
|
267
|
+
observedVersions: ObservedVersion[];
|
|
268
|
+
/** The newest observed build version, or null when none observed. */
|
|
269
|
+
latestObservedVersion: string | null;
|
|
270
|
+
/**
|
|
271
|
+
* True when skew is visible: either more than one distinct build was observed in
|
|
272
|
+
* the window (a stale isolate lingered), or the running build differs from the
|
|
273
|
+
* newest observed one. False/needs-nothing when a single version lines up.
|
|
274
|
+
*/
|
|
275
|
+
skewDetected: boolean;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Fold the tenant's `deploy.version_observed` rows into a deploy-skew summary. Reads
|
|
280
|
+
* only that action (still tenant-scoped through {@link queryActivity}), so it is
|
|
281
|
+
* cheap. `runningOverride` lets a test pin the "serving" build; real callers omit it
|
|
282
|
+
* and it reads the baked-in `__GIT_SHA__` via `workerCommitShort()`.
|
|
283
|
+
*/
|
|
284
|
+
export async function deriveDeploySkew(
|
|
285
|
+
db: Queryable,
|
|
286
|
+
tenantId: string,
|
|
287
|
+
opts: { since?: string; until?: string; limit?: number; runningOverride?: string } = {},
|
|
288
|
+
): Promise<DeploySkewSummary> {
|
|
289
|
+
const rows = await queryActivity(db, {
|
|
290
|
+
tenantId,
|
|
291
|
+
action: "deploy.version_observed",
|
|
292
|
+
since: opts.since,
|
|
293
|
+
until: opts.until,
|
|
294
|
+
limit: opts.limit ?? MAX_TIMELINE_ROWS,
|
|
295
|
+
order: "desc",
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const byVersion = new Map<string, ObservedVersion>();
|
|
299
|
+
for (const r of rows) {
|
|
300
|
+
const version = typeof r.payload.args?.version === "string" ? (r.payload.args.version as string) : undefined;
|
|
301
|
+
if (!version) continue; // an "unknown" build omits version (see deployVersion.ts)
|
|
302
|
+
const env = typeof r.payload.args?.env === "string" ? (r.payload.args.env as string) : undefined;
|
|
303
|
+
const existing = byVersion.get(version);
|
|
304
|
+
if (existing) {
|
|
305
|
+
existing.count += 1;
|
|
306
|
+
// rows are newest-first, so the first `at` seen is already the latest.
|
|
307
|
+
} else {
|
|
308
|
+
byVersion.set(version, { version, ...(env ? { env } : {}), lastObservedAt: r.at, count: 1 });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const observedVersions = [...byVersion.values()].sort((a, b) =>
|
|
313
|
+
a.lastObservedAt < b.lastObservedAt ? 1 : a.lastObservedAt > b.lastObservedAt ? -1 : 0,
|
|
314
|
+
);
|
|
315
|
+
const latestObservedVersion = observedVersions[0]?.version ?? null;
|
|
316
|
+
|
|
317
|
+
const running = workerCommitShort(opts.runningOverride);
|
|
318
|
+
const runningVersion = running === UNKNOWN_COMMIT ? null : running;
|
|
319
|
+
|
|
320
|
+
const skewDetected =
|
|
321
|
+
observedVersions.length > 1 ||
|
|
322
|
+
(runningVersion != null && latestObservedVersion != null && runningVersion !== latestObservedVersion);
|
|
323
|
+
|
|
324
|
+
return { runningVersion, observedVersions, latestObservedVersion, skewDetected };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── URL-param parsing (pure, shared by the page + tests) ────────────────────
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build an {@link ActivityQuery} from a `URLSearchParams` and the REQUIRED,
|
|
331
|
+
* server-resolved `tenantId`. The tenant is NEVER read from the URL — it is the
|
|
332
|
+
* caller's authenticated scope — so no query string can widen or redirect the
|
|
333
|
+
* tenant boundary. Unknown/blank params are simply omitted.
|
|
334
|
+
*/
|
|
335
|
+
export function parseTimelineQuery(params: URLSearchParams, tenantId: string): ActivityQuery {
|
|
336
|
+
const s = (k: string): string | undefined => {
|
|
337
|
+
const v = params.get(k);
|
|
338
|
+
return v && v.trim() ? v.trim() : undefined;
|
|
339
|
+
};
|
|
340
|
+
const n = (k: string): number | undefined => {
|
|
341
|
+
const v = s(k);
|
|
342
|
+
if (v == null) return undefined;
|
|
343
|
+
const parsed = Number(v);
|
|
344
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
345
|
+
};
|
|
346
|
+
const order = s("order") === "asc" ? "asc" : "desc";
|
|
347
|
+
return {
|
|
348
|
+
tenantId,
|
|
349
|
+
...(s("action") ? { action: s("action") } : {}),
|
|
350
|
+
...(s("actionPrefix") ? { actionPrefix: s("actionPrefix") } : {}),
|
|
351
|
+
...(s("changeId") ? { changeId: s("changeId") } : {}),
|
|
352
|
+
...(s("actorId") ? { actorId: s("actorId") } : {}),
|
|
353
|
+
...(s("actorKind") ? { actorKind: s("actorKind") as ActorKind } : {}),
|
|
354
|
+
...(s("source") ? { source: s("source") as ActivitySource } : {}),
|
|
355
|
+
...(s("status") ? { status: s("status") as ActivityStatus } : {}),
|
|
356
|
+
...(s("since") ? { since: s("since") } : {}),
|
|
357
|
+
...(s("until") ? { until: s("until") } : {}),
|
|
358
|
+
...(n("limit") != null ? { limit: n("limit") } : {}),
|
|
359
|
+
order,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ─── TODO — cross-tenant MCP ops query for ToT staff ────────────────────────
|
|
364
|
+
// Deliberately NOT built: storefront has no in-repo MCP tool surface to hook into
|
|
365
|
+
// (the MCP tools live in the external `tot-mcp` repo; this repo only *authenticates*
|
|
366
|
+
// tot-mcp callers via the `x-mcp-service-secret` webhook seam — it exposes no tool
|
|
367
|
+
// server of its own). Standing up that plumbing here would be net-new MCP infra,
|
|
368
|
+
// which the card marks out of scope. When a staff cross-tenant surface is wanted, it
|
|
369
|
+
// should be a SEPARATE, explicitly-audited entry point (its own tenant-omitting
|
|
370
|
+
// query fn + a staff-authz gate) — NOT a relaxation of `queryActivity`'s required
|
|
371
|
+
// tenant bound, which stays the isolation invariant. Every function in this module
|
|
372
|
+
// is deliberately single-tenant.
|
|
373
|
+
|
|
374
|
+
// ─── Page orchestration (pure over the seam; the .astro just wires locals) ───
|
|
375
|
+
|
|
376
|
+
/** Everything the `/admin/ops-timeline` view needs, in one tenant-scoped load. */
|
|
377
|
+
export interface OpsTimelineData {
|
|
378
|
+
rows: ActivityTimelineRow[];
|
|
379
|
+
deploySkew: DeploySkewSummary;
|
|
380
|
+
query: ActivityQuery;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Load the ops-timeline view model for a tenant in one call: the filtered rows
|
|
385
|
+
* plus the deploy-skew summary. Both are tenant-scoped through {@link queryActivity},
|
|
386
|
+
* so the same isolation guarantee covers the whole page. `runningOverride` is a
|
|
387
|
+
* test seam for the serving build.
|
|
388
|
+
*/
|
|
389
|
+
export async function loadOpsTimeline(
|
|
390
|
+
db: Queryable,
|
|
391
|
+
query: ActivityQuery,
|
|
392
|
+
opts: { runningOverride?: string } = {},
|
|
393
|
+
): Promise<OpsTimelineData> {
|
|
394
|
+
const [rows, deploySkew] = await Promise.all([
|
|
395
|
+
queryActivity(db, query),
|
|
396
|
+
deriveDeploySkew(db, query.tenantId, {
|
|
397
|
+
since: query.since,
|
|
398
|
+
until: query.until,
|
|
399
|
+
runningOverride: opts.runningOverride,
|
|
400
|
+
}),
|
|
401
|
+
]);
|
|
402
|
+
return { rows, deploySkew, query };
|
|
403
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side activity emitter — the storefront's entry point onto the
|
|
3
|
+
* actor×action telemetry contract (`@tot/public-runtime/activity`).
|
|
4
|
+
*
|
|
5
|
+
* Every emitter builds through {@link recordActivity}, which runs the SAFE
|
|
6
|
+
* constructor `createActivityEvent()` so the payload zone is always redacted, and
|
|
7
|
+
* writes the envelope as one structured JSON line to Workers Logs.
|
|
8
|
+
*
|
|
9
|
+
* TRANSPORT — Workers Logs (DECISIONS.md §8, tier 1 "universal"). `console.log` of
|
|
10
|
+
* the structured envelope, tagged `[activity]`, so `wrangler tail` / CF-dashboard
|
|
11
|
+
* grep tooling works. `[observability]` is already enabled in
|
|
12
|
+
* `apps/storefront/wrangler.toml`, so there is nothing to provision. The
|
|
13
|
+
* analytics/timeline sinks named on each action's `spec.sinks` are the ingest/store
|
|
14
|
+
* job — this emitter writes to the firehose only.
|
|
15
|
+
*
|
|
16
|
+
* BEST-EFFORT + BOUNDED — a logging failure must NEVER throw or block the caller.
|
|
17
|
+
* Every path is wrapped; a construction/serialize fault degrades to a single
|
|
18
|
+
* `console.error` and is otherwise swallowed. Returns `void`: callers fire-and-forget.
|
|
19
|
+
*/
|
|
20
|
+
import type { APIContext } from "astro";
|
|
21
|
+
import { createActivityEvent, type CreateActivityEventInput } from "@tot/public-runtime";
|
|
22
|
+
import { isActivityDisabled } from "./killSwitch";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Emit ONE activity event. Takes the SAME input shape as `createActivityEvent()`
|
|
26
|
+
* (action / actor / source / outcome / scope / payload) so redaction of the payload
|
|
27
|
+
* zone is guaranteed by construction. Best-effort + never throws.
|
|
28
|
+
*/
|
|
29
|
+
export function recordActivity(input: CreateActivityEventInput): void {
|
|
30
|
+
// Kill switch: the whole pipeline is a no-op when disabled — fail-open, the
|
|
31
|
+
// action this describes is unaffected. Covers the UI cohort too (recordUiActivity
|
|
32
|
+
// wraps this). Sync check so this fire-and-forget path stays synchronous.
|
|
33
|
+
if (isActivityDisabled()) return;
|
|
34
|
+
try {
|
|
35
|
+
const { event } = createActivityEvent(input);
|
|
36
|
+
console.log(`[activity] ${JSON.stringify(event)}`);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
// Swallow-all: telemetry must never break the action it describes. A single
|
|
39
|
+
// fallback line (best-effort, itself guarded) preserves a breadcrumb.
|
|
40
|
+
try {
|
|
41
|
+
console.error(
|
|
42
|
+
`[activity] failed to record ${String((input as { action?: unknown })?.action)}: ${
|
|
43
|
+
err instanceof Error ? err.message : String(err)
|
|
44
|
+
}`,
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
/* nothing more we can safely do */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Opaque, stable actor id for the CORE (unredacted) zone — NEVER a raw email/name
|
|
54
|
+
* (see `event.ts` `ActivityActor`: a raw PII id there would defeat the contract).
|
|
55
|
+
* A salted SHA-256 of the identifier: the SAME input always yields the SAME id (so
|
|
56
|
+
* one person stays correlatable across events) while being non-reversible.
|
|
57
|
+
*
|
|
58
|
+
* The salt is a Worker secret (`ACTIVITY_ACTOR_SALT`). The canonical salt is the
|
|
59
|
+
* open question flagged in `redaction.ts` — this SERVER-side helper is where
|
|
60
|
+
* it lands, deliberately NOT baked into the edge-shipped `@tot/public-runtime`
|
|
61
|
+
* package (which the redaction header warns against). Best-effort: on any crypto fault it
|
|
62
|
+
* returns a coarse non-PII fallback so an emitter never throws.
|
|
63
|
+
*/
|
|
64
|
+
export async function hashActorId(identifier: string, salt?: string | null): Promise<string> {
|
|
65
|
+
try {
|
|
66
|
+
const data = new TextEncoder().encode(`${salt ?? ""}:${identifier}`);
|
|
67
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
68
|
+
const hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
69
|
+
return `h_${hex.slice(0, 24)}`;
|
|
70
|
+
} catch {
|
|
71
|
+
return "actor_unknown";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The real acting viewer's email for an admitted ship session — distinct from
|
|
77
|
+
* `auth.owner`, which is the TENANT owner's identity for a ship-on-behalf developer
|
|
78
|
+
* (a KV/tenant-scoping key, not the actor). Falls back to `auth.owner` only for the
|
|
79
|
+
* headless Bearer-operator path, where there is no signed-in viewer at all.
|
|
80
|
+
*
|
|
81
|
+
* Typed against a minimal `{ owner: string }` (NOT the control-plane `ShipSessionResult`)
|
|
82
|
+
* so this renderer-shared module never imports the moat — the field is all it reads.
|
|
83
|
+
* The returned email is PII: callers pass it through {@link hashActorId} for the
|
|
84
|
+
* event's `actor.id`, never into a core field raw.
|
|
85
|
+
*/
|
|
86
|
+
export function actorEmailFor(context: APIContext, auth: { owner: string }): string {
|
|
87
|
+
return context.locals?.viewer?.email ?? auth.owner;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Best-effort, deterministic display name from an email — a real name is NOT
|
|
92
|
+
* captured anywhere in the current session/grant model. A placeholder good enough
|
|
93
|
+
* to render a UI/log line; capturing the OAuth profile's real name at sign-in and
|
|
94
|
+
* threading it through the viewer session is the correct long-term fix.
|
|
95
|
+
*/
|
|
96
|
+
export function displayNameFromEmail(email: string): string {
|
|
97
|
+
const local = email.split("@")[0] ?? email;
|
|
98
|
+
const words = local.split(/[._-]+/).filter(Boolean);
|
|
99
|
+
if (words.length === 0) return email;
|
|
100
|
+
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
101
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sink fan-out for the activity spine — routes ONE redacted
|
|
3
|
+
* `ActivityEvent` to the sink(s) its catalog entry declares (`spec.sinks`), per
|
|
4
|
+
* the store decision in DECISIONS.md §8:
|
|
5
|
+
*
|
|
6
|
+
* • "logs" — Workers Logs firehose, EVERY action. `console.log` of the
|
|
7
|
+
* structured envelope. Zero provision, days-only retention.
|
|
8
|
+
* • "analytics" — Cloudflare Analytics Engine (`ACTIVITY_ANALYTICS` binding).
|
|
9
|
+
* High-volume / query-light cohort; aggregate SQL, ~90 days.
|
|
10
|
+
* • "timeline" — D1 `STOREFRONT_APPS_DB.activity_events`. Low-volume / high-
|
|
11
|
+
* value lifecycle cohort; point queries + joins + arbitrary
|
|
12
|
+
* retention for the ops timeline UI.
|
|
13
|
+
*
|
|
14
|
+
* PURE over injected seams (a `Queryable` for D1, an `AnalyticsEngineLike` for
|
|
15
|
+
* Analytics Engine) so the same code serves the real Worker bindings and an
|
|
16
|
+
* in-memory test. BEST-EFFORT PER SINK: one sink failing (or being unbound) never
|
|
17
|
+
* blocks the others and never throws to the caller — activity telemetry must
|
|
18
|
+
* never break the request it describes. The whole fan-out runs OFF the response
|
|
19
|
+
* critical path via `runAfterResponse` at the call site.
|
|
20
|
+
*/
|
|
21
|
+
import { getActionSpec, type ActivityEvent, type ActivitySink } from "@tot/public-runtime";
|
|
22
|
+
import type { Queryable } from "../d1/catalog.js";
|
|
23
|
+
|
|
24
|
+
/** The slice of Cloudflare's Analytics Engine dataset binding we use. */
|
|
25
|
+
export interface AnalyticsEngineLike {
|
|
26
|
+
writeDataPoint(point: { blobs?: (string | null)[]; doubles?: number[]; indexes?: string[] }): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ActivitySinks {
|
|
30
|
+
/** D1 `STOREFRONT_APPS_DB` seam (the "timeline" sink). Absent ⇒ skipped. */
|
|
31
|
+
db?: Queryable;
|
|
32
|
+
/** Analytics Engine binding (the "analytics" sink). Absent ⇒ skipped. */
|
|
33
|
+
analytics?: AnalyticsEngineLike;
|
|
34
|
+
/** Server receive-clock ISO-8601, stamped once per ingest for the D1 row. */
|
|
35
|
+
ingestedAt: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Structured Workers-Logs line — grep-able on the `[activity]` tag. Never throws. */
|
|
39
|
+
export function logActivity(event: ActivityEvent): void {
|
|
40
|
+
try {
|
|
41
|
+
console.log(`[activity] ${JSON.stringify(event)}`);
|
|
42
|
+
} catch {
|
|
43
|
+
// best-effort only
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Map an event onto one Analytics Engine data point. `indexes` holds the tenant
|
|
48
|
+
* (the single sampling/filter key AE allows); the core fields go to `blobs`,
|
|
49
|
+
* numerics to `doubles`. Never throws. */
|
|
50
|
+
export function writeAnalytics(analytics: AnalyticsEngineLike, event: ActivityEvent): void {
|
|
51
|
+
try {
|
|
52
|
+
analytics.writeDataPoint({
|
|
53
|
+
indexes: [event.scope.tenantId ?? ""],
|
|
54
|
+
blobs: [
|
|
55
|
+
event.action,
|
|
56
|
+
event.actor.kind,
|
|
57
|
+
event.actor.id,
|
|
58
|
+
event.source,
|
|
59
|
+
event.outcome.status,
|
|
60
|
+
event.outcome.errorClass ?? "",
|
|
61
|
+
event.scope.changeId ?? "",
|
|
62
|
+
event.scope.traceId ?? "",
|
|
63
|
+
event.id,
|
|
64
|
+
event.at,
|
|
65
|
+
event.payload ? JSON.stringify(event.payload) : "",
|
|
66
|
+
],
|
|
67
|
+
doubles: [event.v, event.outcome.durationMs ?? 0],
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
// best-effort only
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Insert one event into the D1 timeline table. `INSERT OR IGNORE` on the event
|
|
75
|
+
* `id` primary key makes a retried/replayed ingest idempotent. Never throws. */
|
|
76
|
+
export async function insertTimeline(db: Queryable, event: ActivityEvent, ingestedAt: string): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
await db.run(
|
|
79
|
+
`INSERT OR IGNORE INTO activity_events
|
|
80
|
+
(id, tenant_id, action, actor_kind, actor_id, actor_ref, source, status,
|
|
81
|
+
error_class, duration_ms, change_id, trace_id, payload, schema_version, at, ingested_at)
|
|
82
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
83
|
+
event.id,
|
|
84
|
+
event.scope.tenantId ?? "",
|
|
85
|
+
event.action,
|
|
86
|
+
event.actor.kind,
|
|
87
|
+
event.actor.id,
|
|
88
|
+
event.actor.ref ?? null,
|
|
89
|
+
event.source,
|
|
90
|
+
event.outcome.status,
|
|
91
|
+
event.outcome.errorClass ?? null,
|
|
92
|
+
event.outcome.durationMs ?? null,
|
|
93
|
+
event.scope.changeId ?? null,
|
|
94
|
+
event.scope.traceId ?? null,
|
|
95
|
+
event.payload ? JSON.stringify(event.payload) : "{}",
|
|
96
|
+
event.v,
|
|
97
|
+
event.at,
|
|
98
|
+
ingestedAt,
|
|
99
|
+
);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// best-effort: a D1 write failure is logged (the firehose still has the row)
|
|
102
|
+
// but never breaks ingest.
|
|
103
|
+
console.error(`[activity] timeline write failed for ${event.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Which sinks this event routes to (from its catalog entry). Unknown action ⇒
|
|
108
|
+
* logs-only (deny-by-default: never fan an unrecognized action to a store). */
|
|
109
|
+
export function sinksFor(event: ActivityEvent): readonly ActivitySink[] {
|
|
110
|
+
return getActionSpec(event.action)?.sinks ?? ["logs"];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Fan one redacted event out to every sink its catalog entry declares.
|
|
114
|
+
* Best-effort per sink; never throws. */
|
|
115
|
+
export async function fanOutActivity(event: ActivityEvent, sinks: ActivitySinks): Promise<void> {
|
|
116
|
+
const routes = sinksFor(event);
|
|
117
|
+
if (routes.includes("logs")) logActivity(event);
|
|
118
|
+
if (routes.includes("analytics") && sinks.analytics) writeAnalytics(sinks.analytics, event);
|
|
119
|
+
if (routes.includes("timeline") && sinks.db) await insertTimeline(sinks.db, event, sinks.ingestedAt);
|
|
120
|
+
}
|