@storyshelf/app 0.5.3
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/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/app-types.d.mts +54 -0
- package/dist/asset-manifest.mjs +41 -0
- package/dist/asset-manifest.mjs.map +1 -0
- package/dist/asset-paths.mjs +23 -0
- package/dist/asset-paths.mjs.map +1 -0
- package/dist/assets/htmx.min.js +3304 -0
- package/dist/assets/mark.svg +1 -0
- package/dist/capture-setup.mjs +139 -0
- package/dist/capture-setup.mjs.map +1 -0
- package/dist/credential-keys.mjs +20 -0
- package/dist/credential-keys.mjs.map +1 -0
- package/dist/credentials.mjs +56 -0
- package/dist/credentials.mjs.map +1 -0
- package/dist/index.d.mts +18 -0
- package/dist/index.mjs +310 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lifecycle.mjs +132 -0
- package/dist/lifecycle.mjs.map +1 -0
- package/dist/middleware/auth-gate.mjs +36 -0
- package/dist/middleware/auth-gate.mjs.map +1 -0
- package/dist/middleware/csrf.mjs +75 -0
- package/dist/middleware/csrf.mjs.map +1 -0
- package/dist/middleware/rate-limit.mjs +68 -0
- package/dist/middleware/rate-limit.mjs.map +1 -0
- package/dist/middleware/request-log.mjs +30 -0
- package/dist/middleware/request-log.mjs.map +1 -0
- package/dist/middleware/server-timing.mjs +39 -0
- package/dist/middleware/server-timing.mjs.map +1 -0
- package/dist/middleware/setup-gate.mjs +37 -0
- package/dist/middleware/setup-gate.mjs.map +1 -0
- package/dist/middleware/store-scope.mjs +33 -0
- package/dist/middleware/store-scope.mjs.map +1 -0
- package/dist/notify.d.mts +17 -0
- package/dist/notify.mjs +141 -0
- package/dist/notify.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +243 -0
- package/dist/pages/admin-system.mjs.map +1 -0
- package/dist/pages/build-detail.mjs +392 -0
- package/dist/pages/build-detail.mjs.map +1 -0
- package/dist/pages/build-diff-actions.mjs +162 -0
- package/dist/pages/build-diff-actions.mjs.map +1 -0
- package/dist/pages/build-diff-comments.mjs +104 -0
- package/dist/pages/build-diff-comments.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +117 -0
- package/dist/pages/build-diff-header.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +48 -0
- package/dist/pages/build-diff-model.mjs.map +1 -0
- package/dist/pages/build-diff-nav.mjs +122 -0
- package/dist/pages/build-diff-nav.mjs.map +1 -0
- package/dist/pages/build-diff-shortcuts.mjs +78 -0
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
- package/dist/pages/build-diff-stale.mjs +70 -0
- package/dist/pages/build-diff-stale.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +70 -0
- package/dist/pages/build-diff-viewer.mjs.map +1 -0
- package/dist/pages/build-diff.mjs +110 -0
- package/dist/pages/build-diff.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +165 -0
- package/dist/pages/compute-jobs.mjs.map +1 -0
- package/dist/pages/invite.mjs +91 -0
- package/dist/pages/invite.mjs.map +1 -0
- package/dist/pages/label-detail.mjs +154 -0
- package/dist/pages/label-detail.mjs.map +1 -0
- package/dist/pages/library.mjs +343 -0
- package/dist/pages/library.mjs.map +1 -0
- package/dist/pages/login.mjs +117 -0
- package/dist/pages/login.mjs.map +1 -0
- package/dist/pages/passkey-ceremony.mjs +97 -0
- package/dist/pages/passkey-ceremony.mjs.map +1 -0
- package/dist/pages/profile.mjs +364 -0
- package/dist/pages/profile.mjs.map +1 -0
- package/dist/pages/project-builds-table.mjs +151 -0
- package/dist/pages/project-builds-table.mjs.map +1 -0
- package/dist/pages/project-builds.mjs +143 -0
- package/dist/pages/project-builds.mjs.map +1 -0
- package/dist/pages/project-create.mjs +90 -0
- package/dist/pages/project-create.mjs.map +1 -0
- package/dist/pages/project-settings.mjs +95 -0
- package/dist/pages/project-settings.mjs.map +1 -0
- package/dist/pages/projects-card.mjs +194 -0
- package/dist/pages/projects-card.mjs.map +1 -0
- package/dist/pages/projects.mjs +129 -0
- package/dist/pages/projects.mjs.map +1 -0
- package/dist/pages/root.mjs +98 -0
- package/dist/pages/root.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +138 -0
- package/dist/pages/settings-general.mjs.map +1 -0
- package/dist/pages/settings-labels.mjs +107 -0
- package/dist/pages/settings-labels.mjs.map +1 -0
- package/dist/pages/settings-members.mjs +192 -0
- package/dist/pages/settings-members.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +240 -0
- package/dist/pages/settings-notifications.mjs.map +1 -0
- package/dist/pages/settings-status.mjs +137 -0
- package/dist/pages/settings-status.mjs.map +1 -0
- package/dist/pages/settings-tests.mjs +111 -0
- package/dist/pages/settings-tests.mjs.map +1 -0
- package/dist/pages/settings-tokens.mjs +90 -0
- package/dist/pages/settings-tokens.mjs.map +1 -0
- package/dist/pages/settings-webhooks.mjs +104 -0
- package/dist/pages/settings-webhooks.mjs.map +1 -0
- package/dist/pages/storybook.mjs +116 -0
- package/dist/pages/storybook.mjs.map +1 -0
- package/dist/retention-timer.mjs +65 -0
- package/dist/retention-timer.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +48 -0
- package/dist/routers/admin-pages.mjs.map +1 -0
- package/dist/routers/admin.mjs +113 -0
- package/dist/routers/admin.mjs.map +1 -0
- package/dist/routers/assets.mjs +64 -0
- package/dist/routers/assets.mjs.map +1 -0
- package/dist/routers/attempts.mjs +96 -0
- package/dist/routers/attempts.mjs.map +1 -0
- package/dist/routers/auth-sync.mjs +64 -0
- package/dist/routers/auth-sync.mjs.map +1 -0
- package/dist/routers/auth.mjs +236 -0
- package/dist/routers/auth.mjs.map +1 -0
- package/dist/routers/baseline-guard.mjs +83 -0
- package/dist/routers/baseline-guard.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +217 -0
- package/dist/routers/builds.handlers.mjs.map +1 -0
- package/dist/routers/builds.mjs +381 -0
- package/dist/routers/builds.mjs.map +1 -0
- package/dist/routers/comments.mjs +109 -0
- package/dist/routers/comments.mjs.map +1 -0
- package/dist/routers/flash.mjs +35 -0
- package/dist/routers/flash.mjs.map +1 -0
- package/dist/routers/health-report.mjs +159 -0
- package/dist/routers/health-report.mjs.map +1 -0
- package/dist/routers/health.mjs +46 -0
- package/dist/routers/health.mjs.map +1 -0
- package/dist/routers/helpers.mjs +110 -0
- package/dist/routers/helpers.mjs.map +1 -0
- package/dist/routers/htmx.mjs +24 -0
- package/dist/routers/htmx.mjs.map +1 -0
- package/dist/routers/labels.mjs +118 -0
- package/dist/routers/labels.mjs.map +1 -0
- package/dist/routers/media.mjs +53 -0
- package/dist/routers/media.mjs.map +1 -0
- package/dist/routers/members.mjs +177 -0
- package/dist/routers/members.mjs.map +1 -0
- package/dist/routers/notification-channels.mjs +190 -0
- package/dist/routers/notification-channels.mjs.map +1 -0
- package/dist/routers/notification-subscriptions.mjs +120 -0
- package/dist/routers/notification-subscriptions.mjs.map +1 -0
- package/dist/routers/profile.mjs +276 -0
- package/dist/routers/profile.mjs.map +1 -0
- package/dist/routers/projects.mjs +133 -0
- package/dist/routers/projects.mjs.map +1 -0
- package/dist/routers/review-next.mjs +47 -0
- package/dist/routers/review-next.mjs.map +1 -0
- package/dist/routers/schemas.mjs +337 -0
- package/dist/routers/schemas.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +104 -0
- package/dist/routers/settings-general.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +52 -0
- package/dist/routers/settings-labels.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +58 -0
- package/dist/routers/settings-members.mjs.map +1 -0
- package/dist/routers/settings-notifications.mjs +108 -0
- package/dist/routers/settings-notifications.mjs.map +1 -0
- package/dist/routers/settings-status.mjs +77 -0
- package/dist/routers/settings-status.mjs.map +1 -0
- package/dist/routers/settings-tokens.mjs +40 -0
- package/dist/routers/settings-tokens.mjs.map +1 -0
- package/dist/routers/settings-webhooks.mjs +67 -0
- package/dist/routers/settings-webhooks.mjs.map +1 -0
- package/dist/routers/settings.handlers.mjs +149 -0
- package/dist/routers/settings.handlers.mjs.map +1 -0
- package/dist/routers/settings.mjs +25 -0
- package/dist/routers/settings.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +240 -0
- package/dist/routers/snapshots.mjs.map +1 -0
- package/dist/routers/status-configs.mjs +108 -0
- package/dist/routers/status-configs.mjs.map +1 -0
- package/dist/routers/storybook.mjs +171 -0
- package/dist/routers/storybook.mjs.map +1 -0
- package/dist/routers/tokens.mjs +91 -0
- package/dist/routers/tokens.mjs.map +1 -0
- package/dist/routers/ui.mjs +166 -0
- package/dist/routers/ui.mjs.map +1 -0
- package/dist/routers/webhooks.mjs +97 -0
- package/dist/routers/webhooks.mjs.map +1 -0
- package/dist/routers/well-known.mjs +43 -0
- package/dist/routers/well-known.mjs.map +1 -0
- package/dist/runtime.mjs +52 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/store.mjs +20 -0
- package/dist/store.mjs.map +1 -0
- package/dist/ui/avatar.mjs +76 -0
- package/dist/ui/avatar.mjs.map +1 -0
- package/dist/ui/buttons.mjs +205 -0
- package/dist/ui/buttons.mjs.map +1 -0
- package/dist/ui/code-block.mjs +47 -0
- package/dist/ui/code-block.mjs.map +1 -0
- package/dist/ui/compare-stage.mjs +344 -0
- package/dist/ui/compare-stage.mjs.map +1 -0
- package/dist/ui/csrf-field.mjs +20 -0
- package/dist/ui/csrf-field.mjs.map +1 -0
- package/dist/ui/css.mjs +36 -0
- package/dist/ui/css.mjs.map +1 -0
- package/dist/ui/document.mjs +130 -0
- package/dist/ui/document.mjs.map +1 -0
- package/dist/ui/dropdown.mjs +181 -0
- package/dist/ui/dropdown.mjs.map +1 -0
- package/dist/ui/feedback.mjs +341 -0
- package/dist/ui/feedback.mjs.map +1 -0
- package/dist/ui/filter-input.mjs +58 -0
- package/dist/ui/filter-input.mjs.map +1 -0
- package/dist/ui/forms.mjs +291 -0
- package/dist/ui/forms.mjs.map +1 -0
- package/dist/ui/icons/icon.mjs +53 -0
- package/dist/ui/icons/icon.mjs.map +1 -0
- package/dist/ui/icons/paths.mjs +61 -0
- package/dist/ui/icons/paths.mjs.map +1 -0
- package/dist/ui/icons/sprite.mjs +26 -0
- package/dist/ui/icons/sprite.mjs.map +1 -0
- package/dist/ui/kbd.mjs +33 -0
- package/dist/ui/kbd.mjs.map +1 -0
- package/dist/ui/layout.mjs +173 -0
- package/dist/ui/layout.mjs.map +1 -0
- package/dist/ui/progress.mjs +44 -0
- package/dist/ui/progress.mjs.map +1 -0
- package/dist/ui/segmented.mjs +77 -0
- package/dist/ui/segmented.mjs.map +1 -0
- package/dist/ui/shell/account.mjs +161 -0
- package/dist/ui/shell/account.mjs.map +1 -0
- package/dist/ui/shell/bare.mjs +69 -0
- package/dist/ui/shell/bare.mjs.map +1 -0
- package/dist/ui/shell/boot-script.mjs +46 -0
- package/dist/ui/shell/boot-script.mjs.map +1 -0
- package/dist/ui/shell/client-script.mjs +259 -0
- package/dist/ui/shell/client-script.mjs.map +1 -0
- package/dist/ui/shell/nav.mjs +111 -0
- package/dist/ui/shell/nav.mjs.map +1 -0
- package/dist/ui/shell/review-script.mjs +195 -0
- package/dist/ui/shell/review-script.mjs.map +1 -0
- package/dist/ui/shell/sidebar.mjs +323 -0
- package/dist/ui/shell/sidebar.mjs.map +1 -0
- package/dist/ui/shell/topbar.mjs +268 -0
- package/dist/ui/shell/topbar.mjs.map +1 -0
- package/dist/ui/stacks.mjs +82 -0
- package/dist/ui/stacks.mjs.map +1 -0
- package/dist/ui/styles/base.mjs +41 -0
- package/dist/ui/styles/base.mjs.map +1 -0
- package/dist/ui/styles/behavior.mjs +43 -0
- package/dist/ui/styles/behavior.mjs.map +1 -0
- package/dist/ui/styles/review-layout.mjs +347 -0
- package/dist/ui/styles/review-layout.mjs.map +1 -0
- package/dist/ui/styles/review.mjs +86 -0
- package/dist/ui/styles/review.mjs.map +1 -0
- package/dist/ui/styles/tokens.mjs +132 -0
- package/dist/ui/styles/tokens.mjs.map +1 -0
- package/dist/ui/styles.mjs +19 -0
- package/dist/ui/styles.mjs.map +1 -0
- package/dist/ui/subnav.mjs +91 -0
- package/dist/ui/subnav.mjs.map +1 -0
- package/dist/ui/table.mjs +73 -0
- package/dist/ui/table.mjs.map +1 -0
- package/dist/ui/theme.mjs +64 -0
- package/dist/ui/theme.mjs.map +1 -0
- package/dist/ui/thumbnail.mjs +51 -0
- package/dist/ui/thumbnail.mjs.map +1 -0
- package/dist/ui/time.mjs +50 -0
- package/dist/ui/time.mjs.map +1 -0
- package/package.json +92 -0
package/dist/notify.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { credentialKeys } from "./credential-keys.mjs";
|
|
5
|
+
import { getStore } from "./store.mjs";
|
|
6
|
+
import { NotificationChannelModel, NotificationSubscriptionModel, UserModel } from "@storyshelf/core/models";
|
|
7
|
+
import { emitNotifications, emitSystemNotification } from "@storyshelf/core/adapter/notifier";
|
|
8
|
+
//#region src/notify.ts
|
|
9
|
+
/** Review URL from an explicit base URL (background use). */
|
|
10
|
+
function reviewUrlForWith(publicBaseUrl, slug, suffix) {
|
|
11
|
+
const base = publicBaseUrl?.replace(/\/+$/u, "");
|
|
12
|
+
if (!base) return;
|
|
13
|
+
return `${base}/projects/${slug}${suffix}`;
|
|
14
|
+
}
|
|
15
|
+
/** Brand inputs from UI config plus server notification defaults. */
|
|
16
|
+
function brandFor(ui, config, reviewUrl) {
|
|
17
|
+
return {
|
|
18
|
+
name: ui.name,
|
|
19
|
+
logo: ui.logo,
|
|
20
|
+
reviewUrl,
|
|
21
|
+
fromEmail: config.notifications?.fromEmail,
|
|
22
|
+
fromName: config.notifications?.fromName ?? ui.name,
|
|
23
|
+
footerText: config.notifications?.footerText
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Convert a stored row to a send-ready view (null when undecryptable). */
|
|
27
|
+
function toView(row, model) {
|
|
28
|
+
let secret;
|
|
29
|
+
try {
|
|
30
|
+
secret = row.secretEncrypted ? model.decryptSecret(row) : void 0;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
id: row.id,
|
|
36
|
+
projectId: row.projectId,
|
|
37
|
+
provider: row.provider,
|
|
38
|
+
config: NotificationChannelModel.configOf(row),
|
|
39
|
+
secret,
|
|
40
|
+
events: NotificationChannelModel.eventsOf(row),
|
|
41
|
+
enabled: row.enabled
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Load stored channels as send-ready views (skips undecryptable secrets). */
|
|
45
|
+
async function loadChannels(deps, projectId) {
|
|
46
|
+
const model = new NotificationChannelModel(deps.db, void 0, credentialKeys(deps.config));
|
|
47
|
+
return (projectId === null ? await model.listSystem() : await model.list(projectId)).filter((row) => row.enabled).flatMap((row) => toView(row, model) ?? []);
|
|
48
|
+
}
|
|
49
|
+
/** Subscriptions matching an event (enabled + allowlist). */
|
|
50
|
+
function matchingSubs(rows, event) {
|
|
51
|
+
return rows.filter((row) => {
|
|
52
|
+
if (!row.enabled) return false;
|
|
53
|
+
const events = NotificationSubscriptionModel.eventsOf(row);
|
|
54
|
+
return events.length === 0 || events.includes(event);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Per-subscriber targets for one subscription (wired kinds only). */
|
|
58
|
+
async function targetsForSub(db, sub, wired) {
|
|
59
|
+
const user = await new UserModel(db).get(sub.userId);
|
|
60
|
+
if (!user || user.disabled) return [];
|
|
61
|
+
const kinds = NotificationSubscriptionModel.viaOf(sub);
|
|
62
|
+
return (kinds.length > 0 ? kinds : ["email"]).filter((kind) => wired.has(kind)).map((kind) => ({
|
|
63
|
+
id: `sub:${sub.id}:${kind}`,
|
|
64
|
+
projectId: sub.projectId,
|
|
65
|
+
provider: kind,
|
|
66
|
+
config: { to: user.email },
|
|
67
|
+
events: [],
|
|
68
|
+
enabled: true
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
/** Per-subscriber targets for an event (only kinds that are wired). */
|
|
72
|
+
async function subscriberTargets(db, projectId, event, wired) {
|
|
73
|
+
const rows = await new NotificationSubscriptionModel(db).list(projectId);
|
|
74
|
+
return (await Promise.all(matchingSubs(rows, event).map(async (sub) => await targetsForSub(db, sub, wired)))).flat();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fan out a project event to stored channels plus opted-in subscribers.
|
|
78
|
+
* Runs alongside (never instead of) webhook delivery.
|
|
79
|
+
*/
|
|
80
|
+
async function notifyProjectWith(deps, project, event, data, reviewSuffix) {
|
|
81
|
+
if (deps.notifiers.length === 0) return;
|
|
82
|
+
const reviewUrl = reviewSuffix ? reviewUrlForWith(deps.config.publicBaseUrl, project.slug, reviewSuffix) : void 0;
|
|
83
|
+
const wired = new Set(deps.notifiers.map((candidate) => candidate.metadata.kind));
|
|
84
|
+
const channels = await loadChannels(deps, project.id);
|
|
85
|
+
const targets = await subscriberTargets(deps.db, project.id, event, wired);
|
|
86
|
+
await emitNotifications({
|
|
87
|
+
event,
|
|
88
|
+
projectId: project.id,
|
|
89
|
+
projectSlug: project.slug,
|
|
90
|
+
data,
|
|
91
|
+
timestamp: now()
|
|
92
|
+
}, {
|
|
93
|
+
notifiers: deps.notifiers,
|
|
94
|
+
channels: [...channels, ...targets],
|
|
95
|
+
brand: brandFor(deps.ui, deps.config, reviewUrl),
|
|
96
|
+
logger: deps.logger
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Fan out a project event to stored channels plus opted-in subscribers.
|
|
101
|
+
* Runs alongside (never instead of) webhook delivery.
|
|
102
|
+
*/
|
|
103
|
+
async function notifyProject(project, event, data, reviewSuffix) {
|
|
104
|
+
await notifyProjectWith(getStore(), project, event, data, reviewSuffix);
|
|
105
|
+
}
|
|
106
|
+
/** Fan out a site-wide admin alert to project-less channels. */
|
|
107
|
+
async function notifySystemWith(deps, event, data) {
|
|
108
|
+
if (deps.notifiers.length === 0) return;
|
|
109
|
+
await emitSystemNotification({
|
|
110
|
+
event,
|
|
111
|
+
data,
|
|
112
|
+
timestamp: now()
|
|
113
|
+
}, {
|
|
114
|
+
notifiers: deps.notifiers,
|
|
115
|
+
channels: await loadChannels(deps, null),
|
|
116
|
+
brand: brandFor(deps.ui, deps.config),
|
|
117
|
+
logger: deps.logger
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Fan out a site-wide admin alert to project-less channels. */
|
|
121
|
+
async function notifySystem(event, data) {
|
|
122
|
+
await notifySystemWith(getStore(), event, data);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Host hook for the auth engine (`onAuthSystemEvent`): fans `sys:*` auth
|
|
126
|
+
* events out to admin channels. Safe to call anywhere — outside a request
|
|
127
|
+
* scope (e.g. boot) it resolves to a no-op instead of throwing.
|
|
128
|
+
*/
|
|
129
|
+
function createAuthSystemHook() {
|
|
130
|
+
return async (event, data) => {
|
|
131
|
+
await notifySystem(event, data).catch(() => {});
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Current timestamp for notification envelopes. */
|
|
135
|
+
function now() {
|
|
136
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
export { createAuthSystemHook, notifyProject, notifyProjectWith, notifySystem, notifySystemWith };
|
|
140
|
+
|
|
141
|
+
//# sourceMappingURL=notify.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notify.mjs","names":[],"sources":["../src/notify.ts"],"sourcesContent":["/**\n * Notification fan-out for routers: loads stored channels and subscriber\n * emails, then delegates to the wired notifier providers. Best-effort and\n * non-fatal — failures are logged inside the emitters, never thrown here.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport {\n emitNotifications,\n emitSystemNotification,\n type NotificationBrand,\n type NotificationChannel,\n type SystemEventName,\n} from \"@storyshelf/core/adapter/notifier\";\nimport type { ShelfConfig, UIConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n NotificationChannelModel,\n NotificationSubscriptionModel,\n UserModel,\n} from \"@storyshelf/core/models\";\nimport type { NotificationSubscriptionRow } from \"@storyshelf/core/schema\";\nimport type { NotificationChannelRow } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { getStore } from \"./store.ts\";\n\n/** Explicit dependencies for background fan-out (no request scope). */\nexport interface NotifyDeps {\n db: DatabaseAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n notifiers: NotifierProvider[];\n}\n\n/** Review URL for a project page (absolute when `publicBaseUrl` is set). */\nexport function reviewUrlFor(slug: string, suffix: string): string | undefined {\n return reviewUrlForWith(getStore().config.publicBaseUrl, slug, suffix);\n}\n\n/** Review URL from an explicit base URL (background use). */\nfunction reviewUrlForWith(\n publicBaseUrl: string | undefined,\n slug: string,\n suffix: string,\n): string | undefined {\n const base = publicBaseUrl?.replace(/\\/+$/u, \"\");\n if (!base) {\n return undefined;\n }\n return `${base}/projects/${slug}${suffix}`;\n}\n\n/** Brand inputs from UI config plus server notification defaults. */\nfunction brandFor(ui: UIConfig, config: ShelfConfig, reviewUrl?: string): NotificationBrand {\n return {\n name: ui.name,\n logo: ui.logo,\n reviewUrl,\n fromEmail: config.notifications?.fromEmail,\n fromName: config.notifications?.fromName ?? ui.name,\n footerText: config.notifications?.footerText,\n };\n}\n\n/** Convert a stored row to a send-ready view (null when undecryptable). */\nfunction toView(\n row: NotificationChannelRow,\n model: NotificationChannelModel,\n): NotificationChannel | null {\n let secret: string | undefined;\n try {\n secret = row.secretEncrypted ? model.decryptSecret(row) : undefined;\n } catch {\n return null;\n }\n return {\n id: row.id,\n projectId: row.projectId,\n provider: row.provider,\n config: NotificationChannelModel.configOf(row),\n secret,\n events: NotificationChannelModel.eventsOf(row),\n enabled: row.enabled,\n };\n}\n\n/** Load stored channels as send-ready views (skips undecryptable secrets). */\nasync function loadChannels(\n deps: Pick<NotifyDeps, \"db\" | \"config\">,\n projectId: string | null,\n): Promise<NotificationChannel[]> {\n const model = new NotificationChannelModel(deps.db, undefined, credentialKeys(deps.config));\n const rows = projectId === null ? await model.listSystem() : await model.list(projectId);\n return rows.filter((row) => row.enabled).flatMap((row) => toView(row, model) ?? []);\n}\n\n/** Subscriptions matching an event (enabled + allowlist). */\nfunction matchingSubs(\n rows: NotificationSubscriptionRow[],\n event: string,\n): NotificationSubscriptionRow[] {\n return rows.filter((row) => {\n if (!row.enabled) {\n return false;\n }\n const events = NotificationSubscriptionModel.eventsOf(row);\n return events.length === 0 || events.includes(event);\n });\n}\n\n/** Per-subscriber targets for one subscription (wired kinds only). */\nasync function targetsForSub(\n db: DatabaseAdapter,\n sub: NotificationSubscriptionRow,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const user = await new UserModel(db).get(sub.userId);\n if (!user || user.disabled) {\n return [];\n }\n const kinds = NotificationSubscriptionModel.viaOf(sub);\n const wants = kinds.length > 0 ? kinds : [\"email\"];\n return wants\n .filter((kind) => wired.has(kind))\n .map((kind) => ({\n id: `sub:${sub.id}:${kind}`,\n projectId: sub.projectId,\n provider: kind,\n config: { to: user.email },\n events: [],\n enabled: true,\n }));\n}\n\n/** Per-subscriber targets for an event (only kinds that are wired). */\nasync function subscriberTargets(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const rows = await new NotificationSubscriptionModel(db).list(projectId);\n const nested = await Promise.all(\n matchingSubs(rows, event).map(async (sub) => await targetsForSub(db, sub, wired)),\n );\n return nested.flat();\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProjectWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n const reviewUrl = reviewSuffix\n ? reviewUrlForWith(deps.config.publicBaseUrl, project.slug, reviewSuffix)\n : undefined;\n const wired = new Set(deps.notifiers.map((candidate) => candidate.metadata.kind));\n const channels = await loadChannels(deps, project.id);\n const targets = await subscriberTargets(deps.db, project.id, event, wired);\n await emitNotifications(\n { event, projectId: project.id, projectSlug: project.slug, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: [...channels, ...targets],\n brand: brandFor(deps.ui, deps.config, reviewUrl),\n logger: deps.logger,\n },\n );\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProject(\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n await notifyProjectWith(getStore(), project, event, data, reviewSuffix);\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystemWith(\n deps: NotifyDeps,\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n await emitSystemNotification(\n { event, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: await loadChannels(deps, null),\n brand: brandFor(deps.ui, deps.config),\n logger: deps.logger,\n },\n );\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystem(\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n await notifySystemWith(getStore(), event, data);\n}\n\n/**\n * Host hook for the auth engine (`onAuthSystemEvent`): fans `sys:*` auth\n * events out to admin channels. Safe to call anywhere — outside a request\n * scope (e.g. boot) it resolves to a no-op instead of throwing.\n */\nexport function createAuthSystemHook(): (\n event: \"sys:user-created\" | \"sys:invite-issued\",\n data: Record<string, unknown>,\n) => Promise<void> {\n return async (event, data): Promise<void> => {\n await notifySystem(event, data).catch(() => {\n // Intentionally empty — best-effort fan-out must never break auth\n });\n };\n}\n\n/** Current timestamp for notification envelopes. */\nfunction now(): string {\n return new Date().toISOString();\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { Button } from "../ui/buttons.mjs";
|
|
5
|
+
import { Alert, Badge, EmptyState, Meta, Stat } from "../ui/feedback.mjs";
|
|
6
|
+
import { Card, PageHeader, SectionTitle } from "../ui/layout.mjs";
|
|
7
|
+
import { HStack } from "../ui/stacks.mjs";
|
|
8
|
+
import { Table } from "../ui/table.mjs";
|
|
9
|
+
import { csrfField } from "../ui/csrf-field.mjs";
|
|
10
|
+
import { DocumentLayout } from "../ui/document.mjs";
|
|
11
|
+
import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
|
|
12
|
+
//#region src/pages/admin-system.tsx
|
|
13
|
+
function healthTone(state) {
|
|
14
|
+
if (state === "ok") return "success";
|
|
15
|
+
if (state === "starting") return "info";
|
|
16
|
+
if (state === "failed" || state === "degraded") return "danger";
|
|
17
|
+
return "neutral";
|
|
18
|
+
}
|
|
19
|
+
function formatUptime(uptimeSecs) {
|
|
20
|
+
if (uptimeSecs < 60) return `${uptimeSecs}s`;
|
|
21
|
+
const minutes = Math.floor(uptimeSecs / 60);
|
|
22
|
+
if (minutes < 60) return `${minutes}m ${uptimeSecs % 60}s`;
|
|
23
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
24
|
+
}
|
|
25
|
+
function formatLatency(latencyMs) {
|
|
26
|
+
return latencyMs === void 0 ? "—" : `${latencyMs} ms`;
|
|
27
|
+
}
|
|
28
|
+
function formatOptional(value) {
|
|
29
|
+
if (value === void 0 || value === null || value === "") return "—";
|
|
30
|
+
return String(value);
|
|
31
|
+
}
|
|
32
|
+
/** One adapter row: identity, version, live probe state, latency, detail. */
|
|
33
|
+
function AdapterRow(props) {
|
|
34
|
+
const { adapter } = props;
|
|
35
|
+
return /* @__PURE__ */ jsxs("tr", { children: [
|
|
36
|
+
/* @__PURE__ */ jsxs("td", { children: [
|
|
37
|
+
/* @__PURE__ */ jsx("strong", { children: adapter.name }),
|
|
38
|
+
adapter.description ? /* @__PURE__ */ jsx(Meta, {
|
|
39
|
+
as: "div",
|
|
40
|
+
children: adapter.description
|
|
41
|
+
}) : null,
|
|
42
|
+
adapter.hasLifecycle ? null : /* @__PURE__ */ jsx(Meta, {
|
|
43
|
+
as: "div",
|
|
44
|
+
children: "No lifecycle hooks"
|
|
45
|
+
})
|
|
46
|
+
] }),
|
|
47
|
+
/* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Meta, {
|
|
48
|
+
as: "span",
|
|
49
|
+
children: adapter.category
|
|
50
|
+
}) }),
|
|
51
|
+
/* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Meta, {
|
|
52
|
+
as: "span",
|
|
53
|
+
mono: true,
|
|
54
|
+
children: adapter.kind
|
|
55
|
+
}) }),
|
|
56
|
+
/* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Meta, {
|
|
57
|
+
as: "span",
|
|
58
|
+
mono: true,
|
|
59
|
+
children: adapter.version
|
|
60
|
+
}) }),
|
|
61
|
+
/* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Badge, {
|
|
62
|
+
tone: healthTone(adapter.state),
|
|
63
|
+
icon: true,
|
|
64
|
+
children: adapter.state
|
|
65
|
+
}) }),
|
|
66
|
+
/* @__PURE__ */ jsx("td", {
|
|
67
|
+
class: "nowrap",
|
|
68
|
+
children: /* @__PURE__ */ jsx(Meta, {
|
|
69
|
+
as: "span",
|
|
70
|
+
children: formatLatency(adapter.latencyMs)
|
|
71
|
+
})
|
|
72
|
+
}),
|
|
73
|
+
/* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Meta, {
|
|
74
|
+
as: "span",
|
|
75
|
+
children: adapter.detail ?? "—"
|
|
76
|
+
}) })
|
|
77
|
+
] }, `${adapter.category}/${adapter.kind}`);
|
|
78
|
+
}
|
|
79
|
+
/** Adapter inventory with live health states. */
|
|
80
|
+
function AdapterTable(props) {
|
|
81
|
+
const { report } = props;
|
|
82
|
+
if (report.adapters.length === 0) return /* @__PURE__ */ jsx(EmptyState, { description: "No adapters wired." });
|
|
83
|
+
return /* @__PURE__ */ jsx(Table, { children: /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
84
|
+
/* @__PURE__ */ jsx("th", { children: "Adapter" }),
|
|
85
|
+
/* @__PURE__ */ jsx("th", { children: "Category" }),
|
|
86
|
+
/* @__PURE__ */ jsx("th", { children: "Kind" }),
|
|
87
|
+
/* @__PURE__ */ jsx("th", { children: "Version" }),
|
|
88
|
+
/* @__PURE__ */ jsx("th", { children: "Health" }),
|
|
89
|
+
/* @__PURE__ */ jsx("th", { children: "Latency" }),
|
|
90
|
+
/* @__PURE__ */ jsx("th", { children: "Detail" })
|
|
91
|
+
] }) }), /* @__PURE__ */ jsx("tbody", { children: report.adapters.map((adapter) => /* @__PURE__ */ jsx(AdapterRow, { adapter }, `${adapter.category}/${adapter.kind}`)) })] }) });
|
|
92
|
+
}
|
|
93
|
+
/** Non-secret server facts (never secret, admin token, or credentials). */
|
|
94
|
+
function ServerFacts(props) {
|
|
95
|
+
const { report, authEnabled, config, authMethods } = props;
|
|
96
|
+
return /* @__PURE__ */ jsxs(Card, { children: [
|
|
97
|
+
/* @__PURE__ */ jsx(SectionTitle, { children: "Server" }),
|
|
98
|
+
/* @__PURE__ */ jsxs("div", {
|
|
99
|
+
class: "grid grid--3",
|
|
100
|
+
children: [
|
|
101
|
+
/* @__PURE__ */ jsx(Stat, {
|
|
102
|
+
label: "Status",
|
|
103
|
+
value: report.status
|
|
104
|
+
}),
|
|
105
|
+
/* @__PURE__ */ jsx(Stat, {
|
|
106
|
+
label: "Uptime",
|
|
107
|
+
value: formatUptime(report.uptimeSecs)
|
|
108
|
+
}),
|
|
109
|
+
/* @__PURE__ */ jsx(Stat, {
|
|
110
|
+
label: "Version",
|
|
111
|
+
value: report.version
|
|
112
|
+
})
|
|
113
|
+
]
|
|
114
|
+
}),
|
|
115
|
+
/* @__PURE__ */ jsxs(Meta, { children: [
|
|
116
|
+
"Auth ",
|
|
117
|
+
authEnabled ? "enabled" : "disabled — all operations permitted",
|
|
118
|
+
" · Capture concurrency",
|
|
119
|
+
" ",
|
|
120
|
+
formatOptional(config.captureConcurrency),
|
|
121
|
+
" · Purge TTL",
|
|
122
|
+
" ",
|
|
123
|
+
formatOptional(config.purgeTtlDays),
|
|
124
|
+
" days · Branch TTL",
|
|
125
|
+
" ",
|
|
126
|
+
config.branchTtlDays === null ? "disabled" : `${formatOptional(config.branchTtlDays)} days`
|
|
127
|
+
] }),
|
|
128
|
+
authMethods && authMethods.length > 0 ? /* @__PURE__ */ jsx(HStack, { children: authMethods.map((method) => /* @__PURE__ */ jsx(Badge, { children: method }, method)) }) : null,
|
|
129
|
+
/* @__PURE__ */ jsxs(Meta, { children: [
|
|
130
|
+
"Scratch dir ",
|
|
131
|
+
formatOptional(config.scratchDir),
|
|
132
|
+
" · Published domain",
|
|
133
|
+
" ",
|
|
134
|
+
formatOptional(config.publishedBaseDomain),
|
|
135
|
+
" · Max upload",
|
|
136
|
+
" ",
|
|
137
|
+
formatOptional(config.maxUploadBytes),
|
|
138
|
+
" bytes"
|
|
139
|
+
] })
|
|
140
|
+
] });
|
|
141
|
+
}
|
|
142
|
+
/** Credential encryption state with the admin-only re-encrypt action. */
|
|
143
|
+
function CredentialsCard(props) {
|
|
144
|
+
const { probe, config } = props;
|
|
145
|
+
const rotating = Boolean(config.previousSecret);
|
|
146
|
+
return /* @__PURE__ */ jsxs(Card, { children: [
|
|
147
|
+
/* @__PURE__ */ jsx(SectionTitle, { children: "Stored credentials" }),
|
|
148
|
+
/* @__PURE__ */ jsx(Meta, { children: "Webhook secrets, git tokens and notification secrets are encrypted with the server secret." }),
|
|
149
|
+
/* @__PURE__ */ jsxs("div", {
|
|
150
|
+
class: "grid grid--3",
|
|
151
|
+
children: [
|
|
152
|
+
/* @__PURE__ */ jsx(Stat, {
|
|
153
|
+
label: "Current secret",
|
|
154
|
+
value: String(probe.current)
|
|
155
|
+
}),
|
|
156
|
+
/* @__PURE__ */ jsx(Stat, {
|
|
157
|
+
label: "Previous secret",
|
|
158
|
+
value: String(probe.previous)
|
|
159
|
+
}),
|
|
160
|
+
/* @__PURE__ */ jsx(Stat, {
|
|
161
|
+
label: "Unreadable",
|
|
162
|
+
value: String(probe.unreadable.length)
|
|
163
|
+
})
|
|
164
|
+
]
|
|
165
|
+
}),
|
|
166
|
+
probe.unreadable.length > 0 ? /* @__PURE__ */ jsxs(Alert, {
|
|
167
|
+
tone: "danger",
|
|
168
|
+
title: "Some credentials cannot be decrypted",
|
|
169
|
+
children: [
|
|
170
|
+
"No configured secret matches ",
|
|
171
|
+
probe.unreadable.length,
|
|
172
|
+
" stored credential(s) (",
|
|
173
|
+
[...new Set(probe.unreadable.map((row) => row.source))].join(", "),
|
|
174
|
+
"). Check that SECRET and SECRET_PREVIOUS are correct, or recreate them."
|
|
175
|
+
]
|
|
176
|
+
}) : null,
|
|
177
|
+
rotating && probe.previous > 0 ? /* @__PURE__ */ jsxs("form", {
|
|
178
|
+
method: "post",
|
|
179
|
+
action: "/admin/credentials/reencrypt",
|
|
180
|
+
"hx-post": "/admin/credentials/reencrypt",
|
|
181
|
+
"hx-target": "body",
|
|
182
|
+
children: [csrfField(), /* @__PURE__ */ jsx(Button, {
|
|
183
|
+
type: "submit",
|
|
184
|
+
variant: "secondary",
|
|
185
|
+
size: "sm",
|
|
186
|
+
children: "Re-encrypt with current secret"
|
|
187
|
+
})]
|
|
188
|
+
}) : /* @__PURE__ */ jsx(Meta, { children: rotating ? "All credentials use the current secret. Remove SECRET_PREVIOUS." : "To rotate, set SECRET to the new value and SECRET_PREVIOUS to the old one." })
|
|
189
|
+
] });
|
|
190
|
+
}
|
|
191
|
+
/** Site-admin System page: adapter inventory with in-depth health. */
|
|
192
|
+
function renderAdminSystemPage(data) {
|
|
193
|
+
const { report, authEnabled, config, authMethods, credentials } = data;
|
|
194
|
+
return /* @__PURE__ */ jsxs(DocumentLayout, {
|
|
195
|
+
title: "System",
|
|
196
|
+
nav: { active: "admin" },
|
|
197
|
+
children: [
|
|
198
|
+
/* @__PURE__ */ jsx(PageHeader, {
|
|
199
|
+
title: "System",
|
|
200
|
+
description: "Adapter inventory with in-depth health. Visible to site admins only.",
|
|
201
|
+
meta: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
202
|
+
report.adapters.length,
|
|
203
|
+
" adapters · ",
|
|
204
|
+
report.status,
|
|
205
|
+
" · up",
|
|
206
|
+
" ",
|
|
207
|
+
formatUptime(report.uptimeSecs)
|
|
208
|
+
] }),
|
|
209
|
+
actions: /* @__PURE__ */ jsx(Button, {
|
|
210
|
+
variant: "secondary",
|
|
211
|
+
size: "sm",
|
|
212
|
+
href: "/admin",
|
|
213
|
+
children: "Recheck"
|
|
214
|
+
})
|
|
215
|
+
}),
|
|
216
|
+
/* @__PURE__ */ jsx(ServerFacts, {
|
|
217
|
+
report,
|
|
218
|
+
authEnabled,
|
|
219
|
+
config,
|
|
220
|
+
authMethods
|
|
221
|
+
}),
|
|
222
|
+
credentials ? /* @__PURE__ */ jsx("div", {
|
|
223
|
+
class: "mt-1",
|
|
224
|
+
children: /* @__PURE__ */ jsx(CredentialsCard, {
|
|
225
|
+
probe: credentials,
|
|
226
|
+
config
|
|
227
|
+
})
|
|
228
|
+
}) : null,
|
|
229
|
+
/* @__PURE__ */ jsx("div", {
|
|
230
|
+
class: "mt-1",
|
|
231
|
+
children: /* @__PURE__ */ jsxs(Card, { children: [
|
|
232
|
+
/* @__PURE__ */ jsx(SectionTitle, { children: "Adapters" }),
|
|
233
|
+
/* @__PURE__ */ jsx(Meta, { children: "Live probe per adapter (2s timeout, parallel). Setup failures surface as failed rows; adapters without lifecycle hooks report their wired state." }),
|
|
234
|
+
/* @__PURE__ */ jsx(AdapterTable, { report })
|
|
235
|
+
] })
|
|
236
|
+
})
|
|
237
|
+
]
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
export { renderAdminSystemPage };
|
|
242
|
+
|
|
243
|
+
//# sourceMappingURL=admin-system.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-system.mjs","names":[],"sources":["../../src/pages/admin-system.tsx"],"sourcesContent":["import type { ShelfConfig } from \"@storyshelf/core/config\";\nimport type { CredentialProbe } from \"@storyshelf/core/models\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport type { HealthReport } from \"../routers/health-report.ts\";\nimport {\n Alert,\n Badge,\n Button,\n Card,\n EmptyState,\n HStack,\n Meta,\n PageHeader,\n SectionTitle,\n Stat,\n Table,\n} from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/** Data for the site-admin System page (adapters, health, safe config). */\nexport interface AdminSystemData {\n report: HealthReport;\n authEnabled: boolean;\n config: ShelfConfig;\n /** Active login method labels (read-only badges; empty when auth is off). */\n authMethods?: string[];\n /** Stored-credential state; null/undefined when no secret is configured. */\n credentials?: CredentialProbe | null;\n}\n\nfunction healthTone(state: string): \"success\" | \"info\" | \"danger\" | \"neutral\" {\n if (state === \"ok\") {\n return \"success\";\n }\n if (state === \"starting\") {\n return \"info\";\n }\n if (state === \"failed\" || state === \"degraded\") {\n return \"danger\";\n }\n return \"neutral\";\n}\n\nfunction formatUptime(uptimeSecs: number): string {\n if (uptimeSecs < 60) {\n return `${uptimeSecs}s`;\n }\n const minutes = Math.floor(uptimeSecs / 60);\n if (minutes < 60) {\n return `${minutes}m ${uptimeSecs % 60}s`;\n }\n return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;\n}\n\nfunction formatLatency(latencyMs: number | undefined): string {\n return latencyMs === undefined ? \"—\" : `${latencyMs} ms`;\n}\n\nfunction formatOptional(value: string | number | null | undefined): string {\n if (value === undefined || value === null || value === \"\") {\n return \"—\";\n }\n return String(value);\n}\n\n/** One adapter row: identity, version, live probe state, latency, detail. */\nfunction AdapterRow(props: {\n adapter: HealthReport[\"adapters\"][number];\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { adapter } = props;\n return (\n <tr key={`${adapter.category}/${adapter.kind}`}>\n <td>\n <strong>{adapter.name}</strong>\n {adapter.description ? <Meta as=\"div\">{adapter.description}</Meta> : null}\n {adapter.hasLifecycle ? null : <Meta as=\"div\">No lifecycle hooks</Meta>}\n </td>\n <td>\n <Meta as=\"span\">{adapter.category}</Meta>\n </td>\n <td>\n <Meta as=\"span\" mono>\n {adapter.kind}\n </Meta>\n </td>\n <td>\n <Meta as=\"span\" mono>\n {adapter.version}\n </Meta>\n </td>\n <td>\n <Badge tone={healthTone(adapter.state)} icon>\n {adapter.state}\n </Badge>\n </td>\n <td class=\"nowrap\">\n <Meta as=\"span\">{formatLatency(adapter.latencyMs)}</Meta>\n </td>\n <td>\n <Meta as=\"span\">{adapter.detail ?? \"—\"}</Meta>\n </td>\n </tr>\n );\n}\n\ninterface AdapterTableProps {\n report: HealthReport;\n}\n\n/** Adapter inventory with live health states. */\nfunction AdapterTable(props: AdapterTableProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { report } = props;\n if (report.adapters.length === 0) {\n return <EmptyState description=\"No adapters wired.\" />;\n }\n return (\n <Table>\n <table>\n <thead>\n <tr>\n <th>Adapter</th>\n <th>Category</th>\n <th>Kind</th>\n <th>Version</th>\n <th>Health</th>\n <th>Latency</th>\n <th>Detail</th>\n </tr>\n </thead>\n <tbody>\n {report.adapters.map((adapter) => (\n <AdapterRow key={`${adapter.category}/${adapter.kind}`} adapter={adapter} />\n ))}\n </tbody>\n </table>\n </Table>\n );\n}\n\ninterface ServerFactsProps {\n report: HealthReport;\n authEnabled: boolean;\n config: ShelfConfig;\n authMethods?: string[];\n}\n\n/** Non-secret server facts (never secret, admin token, or credentials). */\nfunction ServerFacts(props: ServerFactsProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { report, authEnabled, config, authMethods } = props;\n return (\n <Card>\n <SectionTitle>Server</SectionTitle>\n <div class=\"grid grid--3\">\n <Stat label=\"Status\" value={report.status} />\n <Stat label=\"Uptime\" value={formatUptime(report.uptimeSecs)} />\n <Stat label=\"Version\" value={report.version} />\n </div>\n <Meta>\n Auth {authEnabled ? \"enabled\" : \"disabled — all operations permitted\"} · Capture concurrency{\" \"}\n {formatOptional(config.captureConcurrency)} · Purge TTL{\" \"}\n {formatOptional(config.purgeTtlDays)} days · Branch TTL{\" \"}\n {config.branchTtlDays === null\n ? \"disabled\"\n : `${formatOptional(config.branchTtlDays)} days`}\n </Meta>\n {authMethods && authMethods.length > 0 ? (\n <HStack>\n {authMethods.map((method) => (\n <Badge key={method}>{method}</Badge>\n ))}\n </HStack>\n ) : null}\n <Meta>\n Scratch dir {formatOptional(config.scratchDir)} · Published domain{\" \"}\n {formatOptional(config.publishedBaseDomain)} · Max upload{\" \"}\n {formatOptional(config.maxUploadBytes)} bytes\n </Meta>\n </Card>\n );\n}\n\n/** Credential encryption state with the admin-only re-encrypt action. */\nfunction CredentialsCard(props: {\n probe: CredentialProbe;\n config: ShelfConfig;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { probe, config } = props;\n const rotating = Boolean(config.previousSecret);\n return (\n <Card>\n <SectionTitle>Stored credentials</SectionTitle>\n <Meta>\n Webhook secrets, git tokens and notification secrets are encrypted with the server secret.\n </Meta>\n <div class=\"grid grid--3\">\n <Stat label=\"Current secret\" value={String(probe.current)} />\n <Stat label=\"Previous secret\" value={String(probe.previous)} />\n <Stat label=\"Unreadable\" value={String(probe.unreadable.length)} />\n </div>\n {probe.unreadable.length > 0 ? (\n <Alert tone=\"danger\" title=\"Some credentials cannot be decrypted\">\n No configured secret matches {probe.unreadable.length} stored credential(s) (\n {[...new Set(probe.unreadable.map((row) => row.source))].join(\", \")}). Check that SECRET\n and SECRET_PREVIOUS are correct, or recreate them.\n </Alert>\n ) : null}\n {rotating && probe.previous > 0 ? (\n <form\n method=\"post\"\n action=\"/admin/credentials/reencrypt\"\n hx-post=\"/admin/credentials/reencrypt\"\n hx-target=\"body\"\n >\n {csrfField()}\n <Button type=\"submit\" variant=\"secondary\" size=\"sm\">\n Re-encrypt with current secret\n </Button>\n </form>\n ) : (\n <Meta>\n {rotating\n ? \"All credentials use the current secret. Remove SECRET_PREVIOUS.\"\n : \"To rotate, set SECRET to the new value and SECRET_PREVIOUS to the old one.\"}\n </Meta>\n )}\n </Card>\n );\n}\n\n/** Site-admin System page: adapter inventory with in-depth health. */\nexport function renderAdminSystemPage(data: AdminSystemData): RenderedContent {\n const { report, authEnabled, config, authMethods, credentials } = data;\n return (\n <DocumentLayout title=\"System\" nav={{ active: \"admin\" }}>\n <PageHeader\n title=\"System\"\n description=\"Adapter inventory with in-depth health. Visible to site admins only.\"\n meta={\n <>\n {report.adapters.length} adapters · {report.status} · up{\" \"}\n {formatUptime(report.uptimeSecs)}\n </>\n }\n actions={\n <Button variant=\"secondary\" size=\"sm\" href=\"/admin\">\n Recheck\n </Button>\n }\n />\n <ServerFacts\n report={report}\n authEnabled={authEnabled}\n config={config}\n authMethods={authMethods}\n />\n {credentials ? (\n <div class=\"mt-1\">\n <CredentialsCard probe={credentials} config={config} />\n </div>\n ) : null}\n <div class=\"mt-1\">\n <Card>\n <SectionTitle>Adapters</SectionTitle>\n <Meta>\n Live probe per adapter (2s timeout, parallel). Setup failures surface as failed rows;\n adapters without lifecycle hooks report their wired state.\n </Meta>\n <AdapterTable report={report} />\n </Card>\n </div>\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
|