@tokenoftrust/storefront-runner 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +7 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1735 -132
- package/apps/storefront/src/lib/activity/alerts.ts +428 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +127 -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 +105 -0
- package/apps/storefront/src/lib/activity/store.ts +122 -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/auth/mcpClientAssertion.ts +4 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/d1/schema-apps.ts +54 -0
- package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +15 -8
- package/apps/storefront/src/middleware/index.ts +17 -0
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +154 -6
- 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/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/cockpit.astro +40 -3
- package/package.json +1 -1
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +501 -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/index.ts +6 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* `/admin/ops-timeline` — the operational-activity timeline VIEW (card D5).
|
|
4
|
+
*
|
|
5
|
+
* The READ surface of the actor×action telemetry spine: a chronological,
|
|
6
|
+
* tenant-scoped list of lifecycle activity answering "who shipped/rejected what,
|
|
7
|
+
* when, why — and what happened to my change/deploy". It reads the D1
|
|
8
|
+
* `activity_events` timeline cohort (DECISIONS.md §8) through the pure,
|
|
9
|
+
* tenant-scoped query module (`lib/activity/query.ts`).
|
|
10
|
+
*
|
|
11
|
+
* ROUTING / AUTH — mirrors `admin.astro`: middleware resolves the tenant, injects
|
|
12
|
+
* CSP/security headers, and already held anonymous/non-member visitors at the
|
|
13
|
+
* sign-in gate. This surface exposes operational detail, so it renders the data
|
|
14
|
+
* ONLY for a viewer with owner/admin standing for the routed tenant, or a ToT-staff
|
|
15
|
+
* session (`resolveAdminEntry` / `resolveAdminTenantMode`, the same machinery the
|
|
16
|
+
* admin shell uses). A signed-in non-owner sees the chrome + a "no standing" notice,
|
|
17
|
+
* never another tenant's rows.
|
|
18
|
+
*
|
|
19
|
+
* TENANT ISOLATION — the query is ALWAYS scoped to `Astro.locals.tenant.tenant_id`
|
|
20
|
+
* (the served tenant, never a URL value); `parseTimelineQuery` pins that tenant and
|
|
21
|
+
* ignores any `tenant`/`tenantId` query param. See `query.isolation.test.ts` for the
|
|
22
|
+
* hostile read-side proof.
|
|
23
|
+
*
|
|
24
|
+
* DEPLOY SKEW (card D2) — the page surfaces `deriveDeploySkew()`: a banner when the
|
|
25
|
+
* build serving this page (or the timeline) shows more than one live version, plus a
|
|
26
|
+
* per-row "stale build" chip on superseded `deploy.version_observed` rows.
|
|
27
|
+
*
|
|
28
|
+
* ALERTS (card D7) — the page also runs `checkActivityAlerts()` (silent-terminal,
|
|
29
|
+
* recurring preview.serve miss_no_env, reconcile-hang watchdog, and — promoting the
|
|
30
|
+
* skew signal above — a persistent deploy-skew alert) and renders an alerts banner
|
|
31
|
+
* above the deploy-skew visibility banner. Same on-demand posture: computed fresh on
|
|
32
|
+
* every page load, no cron. `GET /api/admin/activity-alerts` exposes the identical
|
|
33
|
+
* check for an external poller.
|
|
34
|
+
*/
|
|
35
|
+
export const prerender = false;
|
|
36
|
+
|
|
37
|
+
import { readViewerSession } from "@/lib/auth/route";
|
|
38
|
+
import { resolveAdminEntry } from "@/lib/auth/adminEntry";
|
|
39
|
+
import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
|
|
40
|
+
import { readD1 } from "@/lib/env";
|
|
41
|
+
import { fromD1 } from "@/lib/d1/catalog";
|
|
42
|
+
import {
|
|
43
|
+
parseTimelineQuery,
|
|
44
|
+
loadOpsTimeline,
|
|
45
|
+
type OpsTimelineData,
|
|
46
|
+
type ActivityTimelineRow,
|
|
47
|
+
} from "@/lib/activity/query";
|
|
48
|
+
import { checkActivityAlerts, type ActivityAlert } from "@/lib/activity/alerts";
|
|
49
|
+
|
|
50
|
+
const { tenant, basePath, cspNonce } = Astro.locals;
|
|
51
|
+
const adminPath = `${basePath || ""}/admin`;
|
|
52
|
+
const timelinePath = `${adminPath}/ops-timeline`;
|
|
53
|
+
const tenantLabel = tenant.displayName || tenant.appDomain || tenant.tenant_id;
|
|
54
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
55
|
+
|
|
56
|
+
const viewerSession = await readViewerSession(Astro);
|
|
57
|
+
const viewerEmail = (Astro.locals.viewer?.email ?? viewerSession?.email ?? "").toLowerCase();
|
|
58
|
+
|
|
59
|
+
const adminEntry = resolveAdminEntry({
|
|
60
|
+
record: viewerSession,
|
|
61
|
+
hostResource: tenant.appDomain,
|
|
62
|
+
nowSeconds,
|
|
63
|
+
});
|
|
64
|
+
const { isStaff } = resolveAdminTenantMode({
|
|
65
|
+
session: viewerSession,
|
|
66
|
+
adminAppTenant: tenant.appDomain,
|
|
67
|
+
requestedTarget: null,
|
|
68
|
+
nowSeconds,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Gate: a signed-in owner/admin of THIS tenant, or ToT staff. Everyone else gets
|
|
72
|
+
// chrome + a notice, and the query never runs.
|
|
73
|
+
const authorized = Boolean(viewerSession) && (adminEntry.isOwnerViewer || isStaff);
|
|
74
|
+
|
|
75
|
+
// The query is scoped to the SERVED tenant's id — never a URL value.
|
|
76
|
+
const query = parseTimelineQuery(Astro.url.searchParams, tenant.tenant_id);
|
|
77
|
+
|
|
78
|
+
let data: OpsTimelineData | null = null;
|
|
79
|
+
let alerts: ActivityAlert[] = [];
|
|
80
|
+
let dbAvailable = true;
|
|
81
|
+
if (authorized) {
|
|
82
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
83
|
+
if (d1) {
|
|
84
|
+
const db = fromD1(d1);
|
|
85
|
+
[data, alerts] = await Promise.all([
|
|
86
|
+
loadOpsTimeline(db, query),
|
|
87
|
+
checkActivityAlerts(db, tenant.tenant_id),
|
|
88
|
+
]);
|
|
89
|
+
} else {
|
|
90
|
+
// Dev/Node or an env without the binding — the timeline lives only in D1.
|
|
91
|
+
dbAvailable = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rows = data?.rows ?? [];
|
|
96
|
+
const skew = data?.deploySkew ?? null;
|
|
97
|
+
// Versions superseded by a newer observation → a "stale build" chip on those rows.
|
|
98
|
+
const staleVersions = new Set(
|
|
99
|
+
(skew?.observedVersions ?? [])
|
|
100
|
+
.filter((v) => v.version !== skew?.latestObservedVersion)
|
|
101
|
+
.map((v) => v.version),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/** Short, non-PII actor label (the id is already an opaque pseudonym). */
|
|
105
|
+
function shortActor(row: ActivityTimelineRow): string {
|
|
106
|
+
const id = row.actorId.length > 14 ? `${row.actorId.slice(0, 14)}…` : row.actorId;
|
|
107
|
+
return `${row.actorKind}:${id}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Map a status to a chip color class. */
|
|
111
|
+
function statusClass(status: string): string {
|
|
112
|
+
switch (status) {
|
|
113
|
+
case "succeeded":
|
|
114
|
+
return "green";
|
|
115
|
+
case "failed":
|
|
116
|
+
return "red";
|
|
117
|
+
case "refused":
|
|
118
|
+
return "amber";
|
|
119
|
+
default:
|
|
120
|
+
return "blue"; // invoked
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Compact, human detail line from the (already redacted) payload. */
|
|
125
|
+
function detailFor(row: ActivityTimelineRow): string {
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
const rendered = row.payload.rendered ?? {};
|
|
128
|
+
for (const [k, v] of Object.entries(rendered)) parts.push(`${k}: ${v}`);
|
|
129
|
+
const args = row.payload.args ?? {};
|
|
130
|
+
for (const [k, v] of Object.entries(args)) {
|
|
131
|
+
if (k === "changeId") continue; // shown in its own column
|
|
132
|
+
parts.push(`${k}=${String(v)}`);
|
|
133
|
+
}
|
|
134
|
+
return parts.join(" · ");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The version an event refers to (deploy.version_observed rows carry it). */
|
|
138
|
+
function versionOf(row: ActivityTimelineRow): string | undefined {
|
|
139
|
+
const v = row.payload.args?.version;
|
|
140
|
+
return typeof v === "string" ? v : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const statusOptions = ["", "invoked", "succeeded", "failed", "refused"];
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
<!doctype html>
|
|
147
|
+
<html lang="en">
|
|
148
|
+
<head>
|
|
149
|
+
<meta charset="utf-8" />
|
|
150
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
151
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
152
|
+
<title>{tenantLabel} · Ops Timeline</title>
|
|
153
|
+
<style is:global nonce={cspNonce}>
|
|
154
|
+
:root {
|
|
155
|
+
color-scheme: light;
|
|
156
|
+
--bg: #f5f7f6;
|
|
157
|
+
--surface: #ffffff;
|
|
158
|
+
--surface-soft: #f9fbfa;
|
|
159
|
+
--ink: #16211f;
|
|
160
|
+
--muted: #64716d;
|
|
161
|
+
--line: #dce5e2;
|
|
162
|
+
--teal: #007f79;
|
|
163
|
+
--green-soft: #e3f5eb;
|
|
164
|
+
--green: #137a55;
|
|
165
|
+
--red-soft: #fde7e4;
|
|
166
|
+
--red: #b42318;
|
|
167
|
+
--amber-soft: #fff0d5;
|
|
168
|
+
--amber: #a45d00;
|
|
169
|
+
--blue-soft: #e7efff;
|
|
170
|
+
--blue: #2458c7;
|
|
171
|
+
--radius: 8px;
|
|
172
|
+
--font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
173
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
174
|
+
}
|
|
175
|
+
* { box-sizing: border-box; }
|
|
176
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 var(--font); }
|
|
177
|
+
a { color: var(--teal); }
|
|
178
|
+
.wrap { max-width: 1180px; margin: 0 auto; padding: 24px 20px 64px; }
|
|
179
|
+
header.page { margin-bottom: 18px; }
|
|
180
|
+
header.page h1 { margin: 0; font-size: 22px; font-weight: 760; }
|
|
181
|
+
header.page p { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
|
|
182
|
+
header.page code { font-family: var(--mono); }
|
|
183
|
+
.notice {
|
|
184
|
+
padding: 14px 16px; border: 1px solid var(--amber); border-radius: var(--radius);
|
|
185
|
+
background: var(--amber-soft); color: #5c3600; margin-bottom: 18px; font-size: 13px;
|
|
186
|
+
}
|
|
187
|
+
.skew {
|
|
188
|
+
display: flex; align-items: flex-start; gap: 12px;
|
|
189
|
+
padding: 12px 14px; border: 1px solid #f1c883; border-radius: var(--radius);
|
|
190
|
+
background: #fff6e7; margin-bottom: 18px; font-size: 13px;
|
|
191
|
+
}
|
|
192
|
+
.skew strong { display: block; }
|
|
193
|
+
.skew .versions { color: var(--muted); font-size: 12px; margin-top: 3px; }
|
|
194
|
+
.skew code { font-family: var(--mono); }
|
|
195
|
+
.alerts { display: grid; gap: 8px; margin-bottom: 18px; }
|
|
196
|
+
.alert {
|
|
197
|
+
display: flex; align-items: flex-start; gap: 12px;
|
|
198
|
+
padding: 12px 14px; border-radius: var(--radius); font-size: 13px;
|
|
199
|
+
}
|
|
200
|
+
.alert strong { display: block; }
|
|
201
|
+
.alert .meta { color: inherit; opacity: 0.75; font-size: 12px; margin-top: 3px; }
|
|
202
|
+
.alert.page, .alert.critical {
|
|
203
|
+
border: 1px solid var(--red); background: var(--red-soft); color: var(--red);
|
|
204
|
+
}
|
|
205
|
+
.alert.warning {
|
|
206
|
+
border: 1px solid #f1c883; background: #fff6e7; color: #7a4a00;
|
|
207
|
+
}
|
|
208
|
+
.alert.info {
|
|
209
|
+
border: 1px solid var(--line); background: var(--surface-soft); color: var(--muted);
|
|
210
|
+
}
|
|
211
|
+
form.filters {
|
|
212
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
213
|
+
gap: 10px; align-items: end;
|
|
214
|
+
padding: 14px; border: 1px solid var(--line); border-radius: var(--radius);
|
|
215
|
+
background: var(--surface); margin-bottom: 18px;
|
|
216
|
+
}
|
|
217
|
+
form.filters .field { display: grid; gap: 4px; min-width: 0; }
|
|
218
|
+
form.filters label { color: var(--muted); font-size: 11px; font-weight: 760; text-transform: uppercase; }
|
|
219
|
+
form.filters input, form.filters select {
|
|
220
|
+
min-height: 36px; padding: 0 9px; font: inherit;
|
|
221
|
+
border: 1px solid var(--line); border-radius: 6px; background: #fcfdfc; color: var(--ink);
|
|
222
|
+
}
|
|
223
|
+
form.filters .actions { display: flex; gap: 8px; }
|
|
224
|
+
.btn {
|
|
225
|
+
min-height: 36px; display: inline-flex; align-items: center; padding: 0 14px;
|
|
226
|
+
border: 1px solid var(--teal); border-radius: 6px; background: var(--teal); color: #fff;
|
|
227
|
+
font-weight: 680; text-decoration: none; cursor: pointer;
|
|
228
|
+
}
|
|
229
|
+
.btn.ghost { background: var(--surface); color: var(--ink); border-color: var(--line); }
|
|
230
|
+
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
|
231
|
+
table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: 13px; }
|
|
232
|
+
th {
|
|
233
|
+
height: 38px; padding: 0 12px; border-bottom: 1px solid var(--line);
|
|
234
|
+
background: var(--surface-soft); color: var(--muted); font-size: 11px;
|
|
235
|
+
font-weight: 760; text-align: left; text-transform: uppercase; white-space: nowrap;
|
|
236
|
+
}
|
|
237
|
+
td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
238
|
+
tr:last-child td { border-bottom: 0; }
|
|
239
|
+
td.at { white-space: nowrap; font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
|
240
|
+
td.action code, td.change code, td.actor code { font-family: var(--mono); font-size: 12px; }
|
|
241
|
+
td.action small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
|
|
242
|
+
td.detail { color: var(--muted); overflow-wrap: anywhere; }
|
|
243
|
+
.chip {
|
|
244
|
+
display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px;
|
|
245
|
+
border-radius: 999px; background: var(--surface-soft); color: #3f4d49;
|
|
246
|
+
font-size: 11px; font-weight: 740; white-space: nowrap;
|
|
247
|
+
}
|
|
248
|
+
.chip.green { background: var(--green-soft); color: var(--green); }
|
|
249
|
+
.chip.red { background: var(--red-soft); color: var(--red); }
|
|
250
|
+
.chip.amber { background: var(--amber-soft); color: var(--amber); }
|
|
251
|
+
.chip.blue { background: var(--blue-soft); color: var(--blue); }
|
|
252
|
+
.chip.stale { background: #ffe0d6; color: #a5330a; margin-left: 6px; }
|
|
253
|
+
.empty { padding: 40px 16px; text-align: center; color: var(--muted); }
|
|
254
|
+
@media (max-width: 760px) {
|
|
255
|
+
form.filters { grid-template-columns: 1fr 1fr; }
|
|
256
|
+
}
|
|
257
|
+
</style>
|
|
258
|
+
</head>
|
|
259
|
+
<body>
|
|
260
|
+
<div class="wrap">
|
|
261
|
+
<header class="page">
|
|
262
|
+
<h1>Operational activity timeline</h1>
|
|
263
|
+
<p>
|
|
264
|
+
{tenantLabel} · <code>{tenant.tenant_id}</code> · lifecycle events, newest first ·
|
|
265
|
+
<a href={adminPath}>← Admin</a>
|
|
266
|
+
</p>
|
|
267
|
+
</header>
|
|
268
|
+
|
|
269
|
+
{!viewerSession && (
|
|
270
|
+
<div class="notice">
|
|
271
|
+
Sign in as an owner/admin of {tenantLabel} to view the operational activity timeline.
|
|
272
|
+
<a href="/auth/login">Sign in</a>.
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{viewerSession && !authorized && (
|
|
277
|
+
<div class="notice">
|
|
278
|
+
{viewerEmail} is signed in but has no owner/admin standing for {tenantLabel}. The ops
|
|
279
|
+
timeline is limited to owners/admins of this store and ToT staff.
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
|
|
283
|
+
{authorized && !dbAvailable && (
|
|
284
|
+
<div class="notice">
|
|
285
|
+
The activity timeline is stored in D1 (<code>STOREFRONT_APPS_DB</code>), which isn't
|
|
286
|
+
bound in this environment — no rows to show here.
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{authorized && alerts.length > 0 && (
|
|
291
|
+
<div class="alerts" role="alert">
|
|
292
|
+
{alerts.map((a) => (
|
|
293
|
+
<div class={`alert ${a.severity}`}>
|
|
294
|
+
<span aria-hidden="true">{a.severity === "page" || a.severity === "critical" ? "🚨" : "⚠️"}</span>
|
|
295
|
+
<div>
|
|
296
|
+
<strong>{a.title}</strong>
|
|
297
|
+
<div>{a.summary}</div>
|
|
298
|
+
<div class="meta">
|
|
299
|
+
{a.changeId && <>Change <code>{a.changeId}</code> · </>}
|
|
300
|
+
since {a.since}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{authorized && skew?.skewDetected && (
|
|
309
|
+
<div class="skew" role="status">
|
|
310
|
+
<span aria-hidden="true">⚠️</span>
|
|
311
|
+
<div>
|
|
312
|
+
<strong>Deploy skew detected</strong>
|
|
313
|
+
<div class="versions">
|
|
314
|
+
{skew.runningVersion
|
|
315
|
+
? <>Serving build <code>{skew.runningVersion}</code>. </>
|
|
316
|
+
: <>Serving build unknown. </>}
|
|
317
|
+
Observed builds:{" "}
|
|
318
|
+
{skew.observedVersions.map((v, i) => (
|
|
319
|
+
<>
|
|
320
|
+
{i > 0 ? ", " : ""}
|
|
321
|
+
<code>{v.version}</code>{v.env ? ` (${v.env})` : ""}
|
|
322
|
+
{v.version === skew.latestObservedVersion ? " — latest" : " — stale"}
|
|
323
|
+
</>
|
|
324
|
+
))}.
|
|
325
|
+
This is a visibility signal only (alerting is a separate concern).
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
{authorized && (
|
|
332
|
+
<form class="filters" method="get" action={timelinePath}>
|
|
333
|
+
<div class="field">
|
|
334
|
+
<label for="f-changeId">Change ID</label>
|
|
335
|
+
<input id="f-changeId" type="text" name="changeId" value={query.changeId ?? ""} placeholder="chg-…" />
|
|
336
|
+
</div>
|
|
337
|
+
<div class="field">
|
|
338
|
+
<label for="f-action">Action</label>
|
|
339
|
+
<input id="f-action" type="text" name="action" value={query.action ?? ""} placeholder="change.ship" />
|
|
340
|
+
</div>
|
|
341
|
+
<div class="field">
|
|
342
|
+
<label for="f-actionPrefix">Action family</label>
|
|
343
|
+
<input id="f-actionPrefix" type="text" name="actionPrefix" value={query.actionPrefix ?? ""} placeholder="change." />
|
|
344
|
+
</div>
|
|
345
|
+
<div class="field">
|
|
346
|
+
<label for="f-actorId">Actor ID</label>
|
|
347
|
+
<input id="f-actorId" type="text" name="actorId" value={query.actorId ?? ""} placeholder="h_…" />
|
|
348
|
+
</div>
|
|
349
|
+
<div class="field">
|
|
350
|
+
<label for="f-status">Status</label>
|
|
351
|
+
<select id="f-status" name="status">
|
|
352
|
+
{statusOptions.map((s) => (
|
|
353
|
+
<option value={s} selected={query.status === s || (s === "" && !query.status)}>
|
|
354
|
+
{s === "" ? "any" : s}
|
|
355
|
+
</option>
|
|
356
|
+
))}
|
|
357
|
+
</select>
|
|
358
|
+
</div>
|
|
359
|
+
<div class="field">
|
|
360
|
+
<label for="f-since">Since (ISO)</label>
|
|
361
|
+
<input id="f-since" type="text" name="since" value={query.since ?? ""} placeholder="2026-08-01" />
|
|
362
|
+
</div>
|
|
363
|
+
<div class="field">
|
|
364
|
+
<label for="f-until">Until (ISO)</label>
|
|
365
|
+
<input id="f-until" type="text" name="until" value={query.until ?? ""} placeholder="2026-08-31" />
|
|
366
|
+
</div>
|
|
367
|
+
<div class="field actions">
|
|
368
|
+
<button class="btn" type="submit">Filter</button>
|
|
369
|
+
<a class="btn ghost" href={timelinePath}>Reset</a>
|
|
370
|
+
</div>
|
|
371
|
+
</form>
|
|
372
|
+
)}
|
|
373
|
+
|
|
374
|
+
{authorized && dbAvailable && (
|
|
375
|
+
<div class="table-wrap">
|
|
376
|
+
<table>
|
|
377
|
+
<thead>
|
|
378
|
+
<tr>
|
|
379
|
+
<th>When</th>
|
|
380
|
+
<th>Actor</th>
|
|
381
|
+
<th>Action</th>
|
|
382
|
+
<th>Status</th>
|
|
383
|
+
<th>Change</th>
|
|
384
|
+
<th>Detail</th>
|
|
385
|
+
</tr>
|
|
386
|
+
</thead>
|
|
387
|
+
<tbody>
|
|
388
|
+
{rows.length === 0 && (
|
|
389
|
+
<tr>
|
|
390
|
+
<td class="empty" colspan="6">No activity matches these filters yet.</td>
|
|
391
|
+
</tr>
|
|
392
|
+
)}
|
|
393
|
+
{rows.map((row) => {
|
|
394
|
+
const version = versionOf(row);
|
|
395
|
+
const isStale = version != null && staleVersions.has(version);
|
|
396
|
+
return (
|
|
397
|
+
<tr>
|
|
398
|
+
<td class="at">{row.at}</td>
|
|
399
|
+
<td class="actor"><code>{shortActor(row)}</code></td>
|
|
400
|
+
<td class="action">
|
|
401
|
+
<code>{row.action}</code>
|
|
402
|
+
{row.actionDesc && <small>{row.actionDesc}</small>}
|
|
403
|
+
</td>
|
|
404
|
+
<td>
|
|
405
|
+
<span class={`chip ${statusClass(row.status)}`}>{row.status}</span>
|
|
406
|
+
{row.errorClass && <span class="chip amber">{row.errorClass}</span>}
|
|
407
|
+
{isStale && <span class="chip stale">stale build</span>}
|
|
408
|
+
</td>
|
|
409
|
+
<td class="change">{row.changeId ? <code>{row.changeId}</code> : "—"}</td>
|
|
410
|
+
<td class="detail">{detailFor(row) || "—"}</td>
|
|
411
|
+
</tr>
|
|
412
|
+
);
|
|
413
|
+
})}
|
|
414
|
+
</tbody>
|
|
415
|
+
</table>
|
|
416
|
+
</div>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
</body>
|
|
420
|
+
</html>
|
|
@@ -103,7 +103,9 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
|
|
|
103
103
|
const adminTabs = ["orders", "products", "subscriptions", "customers", "fulfillment", "reporting", "ai-workflows", "publish", "settings"];
|
|
104
104
|
const canViewInternalGuide = document.documentElement.dataset.internalAdminGuide === "true";
|
|
105
105
|
const defaultTab = canViewInternalGuide ? "orders" : "settings";
|
|
106
|
-
|
|
106
|
+
// A deep link may carry a query suffix in the hash (e.g. #publish?pr=42);
|
|
107
|
+
// the tab id is only the part before "?".
|
|
108
|
+
const hash = window.location.hash.slice(1).split("?")[0];
|
|
107
109
|
const activeTab = adminTabs.includes(hash) ? hash : defaultTab;
|
|
108
110
|
const mode = canViewInternalGuide && hash && !adminTabs.includes(hash) ? "guide" : "work-area";
|
|
109
111
|
|
|
@@ -252,6 +254,100 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
|
|
|
252
254
|
background: rgba(255, 255, 255, 0.06);
|
|
253
255
|
}
|
|
254
256
|
|
|
257
|
+
/* Cross-surface hop to the developer cockpit — same card language as the
|
|
258
|
+
sidebar's other blocks, kept visually quieter than the nav tabs. */
|
|
259
|
+
.cockpit-link {
|
|
260
|
+
display: flex;
|
|
261
|
+
align-items: center;
|
|
262
|
+
gap: 8px;
|
|
263
|
+
padding: 10px 12px;
|
|
264
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
265
|
+
border-radius: var(--admin-radius);
|
|
266
|
+
background: rgba(255, 255, 255, 0.04);
|
|
267
|
+
color: rgba(238, 247, 245, 0.85);
|
|
268
|
+
font-size: 13px;
|
|
269
|
+
text-decoration: none;
|
|
270
|
+
}
|
|
271
|
+
.cockpit-link:hover {
|
|
272
|
+
background: rgba(255, 255, 255, 0.1);
|
|
273
|
+
color: #fff;
|
|
274
|
+
}
|
|
275
|
+
.cockpit-link svg {
|
|
276
|
+
width: 15px;
|
|
277
|
+
height: 15px;
|
|
278
|
+
stroke: currentColor;
|
|
279
|
+
fill: none;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* Signed-in-as chip (identity visibility — Trello 13075 postscript: an
|
|
283
|
+
owner staring at a read-only Publish tab could not tell WHO the shell
|
|
284
|
+
thought they were). Mirrors .store's card language; the standing line
|
|
285
|
+
states the resolved principal FOR THIS STORE, not a global role. */
|
|
286
|
+
.account {
|
|
287
|
+
display: grid;
|
|
288
|
+
grid-template-columns: auto 1fr;
|
|
289
|
+
gap: 10px;
|
|
290
|
+
align-items: center;
|
|
291
|
+
padding: 12px;
|
|
292
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
293
|
+
border-radius: var(--admin-radius);
|
|
294
|
+
background: rgba(255, 255, 255, 0.06);
|
|
295
|
+
/* Pinned to the sidebar's lower-left (the conventional account slot) —
|
|
296
|
+
the sidebar is a flex column, so this soaks the remaining space. */
|
|
297
|
+
margin-top: auto;
|
|
298
|
+
}
|
|
299
|
+
.account .account-avatar {
|
|
300
|
+
display: grid;
|
|
301
|
+
place-items: center;
|
|
302
|
+
width: 32px;
|
|
303
|
+
height: 32px;
|
|
304
|
+
border-radius: 50%;
|
|
305
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
306
|
+
background: rgba(255, 255, 255, 0.08);
|
|
307
|
+
}
|
|
308
|
+
.account .account-avatar svg {
|
|
309
|
+
width: 16px;
|
|
310
|
+
height: 16px;
|
|
311
|
+
stroke: currentColor;
|
|
312
|
+
fill: none;
|
|
313
|
+
stroke-width: 1.8;
|
|
314
|
+
}
|
|
315
|
+
.account .account-meta {
|
|
316
|
+
display: grid;
|
|
317
|
+
gap: 2px;
|
|
318
|
+
min-width: 0;
|
|
319
|
+
}
|
|
320
|
+
.account .account-email {
|
|
321
|
+
font-weight: 640;
|
|
322
|
+
font-size: 13px;
|
|
323
|
+
overflow: hidden;
|
|
324
|
+
text-overflow: ellipsis;
|
|
325
|
+
white-space: nowrap;
|
|
326
|
+
}
|
|
327
|
+
.account .account-standing {
|
|
328
|
+
color: rgba(238, 247, 245, 0.7);
|
|
329
|
+
font-size: 11px;
|
|
330
|
+
}
|
|
331
|
+
.account .account-standing[data-standing="none"] {
|
|
332
|
+
color: rgba(255, 196, 120, 0.9);
|
|
333
|
+
}
|
|
334
|
+
.account a {
|
|
335
|
+
color: inherit;
|
|
336
|
+
}
|
|
337
|
+
.account .account-signout button {
|
|
338
|
+
margin-top: 2px;
|
|
339
|
+
padding: 0;
|
|
340
|
+
border: 0;
|
|
341
|
+
background: none;
|
|
342
|
+
color: rgba(238, 247, 245, 0.6);
|
|
343
|
+
font-size: 11px;
|
|
344
|
+
text-decoration: underline;
|
|
345
|
+
cursor: pointer;
|
|
346
|
+
}
|
|
347
|
+
.account .account-signout button:hover {
|
|
348
|
+
color: rgba(238, 247, 245, 0.9);
|
|
349
|
+
}
|
|
350
|
+
|
|
255
351
|
.tenant-picker {
|
|
256
352
|
display: grid;
|
|
257
353
|
gap: 4px;
|
|
@@ -805,6 +901,13 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
|
|
|
805
901
|
background: #fff6e7;
|
|
806
902
|
}
|
|
807
903
|
|
|
904
|
+
/* A JS-toggled `.risk-alert` (e.g. the degraded-forge strip) must stay hidden
|
|
905
|
+
when `hidden` is set — the base display:flex above would otherwise win over
|
|
906
|
+
the UA [hidden] rule and keep it visible. */
|
|
907
|
+
.risk-alert[hidden] {
|
|
908
|
+
display: none;
|
|
909
|
+
}
|
|
910
|
+
|
|
808
911
|
.risk-alert svg {
|
|
809
912
|
color: var(--admin-amber);
|
|
810
913
|
}
|
|
@@ -1089,6 +1192,7 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
|
|
|
1089
1192
|
<symbol id="i-orders" viewBox="0 0 24 24"><path d="M7 4h10a2 2 0 0 1 2 2v14l-3-2-2 2-2-2-2 2-3 2V6a2 2 0 0 1 2-2Z"></path><path d="M9 9h6M9 13h6"></path></symbol>
|
|
1090
1193
|
<symbol id="i-box" viewBox="0 0 24 24"><path d="m3 7 9-4 9 4-9 4-9-4Z"></path><path d="M3 7v10l9 4 9-4V7"></path><path d="M12 11v10"></path></symbol>
|
|
1091
1194
|
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"></path><circle cx="9.5" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.9"></path><path d="M16 3.1a4 4 0 0 1 0 7.8"></path></symbol>
|
|
1195
|
+
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></symbol>
|
|
1092
1196
|
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3 20 6v6c0 5-3.4 8.4-8 9-4.6-.6-8-4-8-9V6l8-3Z"></path><path d="m8.8 12 2.2 2.2L15.8 9"></path></symbol>
|
|
1093
1197
|
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M8 16v-5"></path><path d="M12 16V8"></path><path d="M16 16v-3"></path></symbol>
|
|
1094
1198
|
<symbol id="i-workflows" viewBox="0 0 24 24"><path d="M6 5h6a4 4 0 0 1 4 4v1"></path><path d="m19 7-3 3-3-3"></path><path d="M18 19h-6a4 4 0 0 1-4-4v-1"></path><path d="m5 17 3-3 3 3"></path><circle cx="6" cy="5" r="2"></circle><circle cx="18" cy="19" r="2"></circle></symbol>
|
|
@@ -1163,6 +1267,44 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
|
|
|
1163
1267
|
<div class="health-row"><span>Payments</span><strong>OK</strong></div>
|
|
1164
1268
|
<div class="health-row"><span>Risk checks</span><strong>OK</strong></div>
|
|
1165
1269
|
</div>
|
|
1270
|
+
|
|
1271
|
+
<!-- Cross-surface hop: the developer cockpit is a SIBLING surface (dev
|
|
1272
|
+
setup + connection status), not an admin tab — link, don't embed.
|
|
1273
|
+
basePath-aware so it stays inside the tenant prefix. -->
|
|
1274
|
+
<a class="cockpit-link" href={`${basePath || ""}/cockpit`}>
|
|
1275
|
+
<svg><use href="#i-gear"></use></svg><span>Developer cockpit</span>
|
|
1276
|
+
</a>
|
|
1277
|
+
|
|
1278
|
+
<div class="account" data-admin-account>
|
|
1279
|
+
<span class="account-avatar" aria-hidden="true"><svg><use href="#i-user"></use></svg></span>
|
|
1280
|
+
<span class="account-meta">
|
|
1281
|
+
{viewerEmail ? (
|
|
1282
|
+
<>
|
|
1283
|
+
<span class="account-email" title={viewerEmail}>{viewerEmail}</span>
|
|
1284
|
+
<span
|
|
1285
|
+
class="account-standing"
|
|
1286
|
+
data-standing={adminEntry.isOwnerViewer ? "owner" : isStaff ? "staff" : "none"}
|
|
1287
|
+
>
|
|
1288
|
+
{adminEntry.isOwnerViewer
|
|
1289
|
+
? `owner/admin of ${tenantEnvelope.targetTenant}`
|
|
1290
|
+
: isStaff
|
|
1291
|
+
? `ToT staff · not an owner of ${tenantEnvelope.targetTenant}`
|
|
1292
|
+
: `signed in · no owner/admin standing for ${tenantEnvelope.targetTenant}`}
|
|
1293
|
+
</span>
|
|
1294
|
+
<form class="account-signout" method="post" action="/api/auth/logout">
|
|
1295
|
+
<button type="submit">Sign out</button>
|
|
1296
|
+
</form>
|
|
1297
|
+
</>
|
|
1298
|
+
) : (
|
|
1299
|
+
<>
|
|
1300
|
+
<span class="account-email">Not signed in</span>
|
|
1301
|
+
<span class="account-standing" data-standing="none">
|
|
1302
|
+
<a href="/auth/login">sign in</a> as an owner/admin of {tenantEnvelope.targetTenant} to publish
|
|
1303
|
+
</span>
|
|
1304
|
+
</>
|
|
1305
|
+
)}
|
|
1306
|
+
</span>
|
|
1307
|
+
</div>
|
|
1166
1308
|
</aside>
|
|
1167
1309
|
|
|
1168
1310
|
<main class="main">
|
|
@@ -1490,12 +1632,15 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
|
|
|
1490
1632
|
};
|
|
1491
1633
|
|
|
1492
1634
|
const panelIdFromHash = () => {
|
|
1493
|
-
const
|
|
1494
|
-
|
|
1635
|
+
const rawHash = window.location.hash.slice(1);
|
|
1636
|
+
// The tab id is the part before any "?" query suffix (#publish?pr=42);
|
|
1637
|
+
// the raw hash is still used for internal-guide anchor resolution.
|
|
1638
|
+
const hash = rawHash.split("?")[0];
|
|
1639
|
+
if (!rawHash) return defaultTab;
|
|
1495
1640
|
if (tabSet.has(hash)) return hash;
|
|
1496
1641
|
if (!canViewInternalGuide) return defaultTab;
|
|
1497
1642
|
|
|
1498
|
-
const target =
|
|
1643
|
+
const target = rawHash ? document.getElementById(rawHash) : null;
|
|
1499
1644
|
const panel = target?.closest?.("[data-admin-panel]");
|
|
1500
1645
|
if (panel?.id && tabSet.has(panel.id)) return panel.id;
|
|
1501
1646
|
|
|
@@ -1625,8 +1770,11 @@ const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = admin
|
|
|
1625
1770
|
|
|
1626
1771
|
if (panelId) {
|
|
1627
1772
|
setActiveTab(panelId);
|
|
1628
|
-
|
|
1629
|
-
|
|
1773
|
+
// Strip any query suffix before deciding whether the hash names a
|
|
1774
|
+
// real tab — a valid deep link like #publish?pr=42 must not be
|
|
1775
|
+
// normalized away to #settings for merchant viewers.
|
|
1776
|
+
const hashTab = window.location.hash.slice(1).split("?")[0];
|
|
1777
|
+
if (!canViewInternalGuide && !tabSet.has(hashTab)) {
|
|
1630
1778
|
history.replaceState(null, "", "#settings");
|
|
1631
1779
|
}
|
|
1632
1780
|
if (scroll) requestAnimationFrame(() => scrollToHashTarget(panelId));
|