@storyshelf/core 0.1.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/README.md +127 -0
- package/dist/adapter/auth.d.mts +50 -0
- package/dist/adapter/auth.mjs +10 -0
- package/dist/adapter/auth.mjs.map +1 -0
- package/dist/adapter/capture-runner.d.mts +70 -0
- package/dist/adapter/capture-runner.mjs +4 -0
- package/dist/adapter/database.d.mts +43 -0
- package/dist/adapter/database.mjs +4 -0
- package/dist/adapter/git-host/helpers.d.mts +12 -0
- package/dist/adapter/git-host/helpers.mjs +29 -0
- package/dist/adapter/git-host/helpers.mjs.map +1 -0
- package/dist/adapter/git-host.d.mts +46 -0
- package/dist/adapter/git-host.mjs +4 -0
- package/dist/adapter/storage.d.mts +22 -0
- package/dist/adapter/storage.mjs +4 -0
- package/dist/adapters/capture-queue.d.mts +49 -0
- package/dist/adapters/metadata.d.mts +24 -0
- package/dist/adapters/webhook-events.mjs +39 -0
- package/dist/adapters/webhook-events.mjs.map +1 -0
- package/dist/capture/adapter.d.mts +29 -0
- package/dist/capture/adapter.mjs +13 -0
- package/dist/capture/adapter.mjs.map +1 -0
- package/dist/capture/orchestrator.d.mts +23 -0
- package/dist/capture/orchestrator.mjs +103 -0
- package/dist/capture/orchestrator.mjs.map +1 -0
- package/dist/capture/pipeline.d.mts +41 -0
- package/dist/capture/pipeline.mjs +119 -0
- package/dist/capture/pipeline.mjs.map +1 -0
- package/dist/capture/queue.d.mts +35 -0
- package/dist/capture/queue.mjs +96 -0
- package/dist/capture/queue.mjs.map +1 -0
- package/dist/capture/status-fanout.mjs +66 -0
- package/dist/capture/status-fanout.mjs.map +1 -0
- package/dist/capture/storybook.d.mts +14 -0
- package/dist/capture/storybook.mjs +40 -0
- package/dist/capture/storybook.mjs.map +1 -0
- package/dist/config.d.mts +68 -0
- package/dist/config.mjs +69 -0
- package/dist/config.mjs.map +1 -0
- package/dist/ddl.d.mts +7 -0
- package/dist/ddl.mjs +155 -0
- package/dist/ddl.mjs.map +1 -0
- package/dist/diff/engine.d.mts +8 -0
- package/dist/diff/engine.mjs +79 -0
- package/dist/diff/engine.mjs.map +1 -0
- package/dist/diff/options.d.mts +40 -0
- package/dist/diff/options.mjs +15 -0
- package/dist/diff/options.mjs.map +1 -0
- package/dist/index.d.mts +48 -0
- package/dist/index.mjs +327 -0
- package/dist/index.mjs.map +1 -0
- package/dist/logger.d.mts +39 -0
- package/dist/logger.mjs +35 -0
- package/dist/logger.mjs.map +1 -0
- package/dist/middleware/csrf.mjs +39 -0
- package/dist/middleware/csrf.mjs.map +1 -0
- package/dist/middleware/rate-limit.mjs +38 -0
- package/dist/middleware/rate-limit.mjs.map +1 -0
- package/dist/models/baseline.d.mts +20 -0
- package/dist/models/baseline.mjs +89 -0
- package/dist/models/baseline.mjs.map +1 -0
- package/dist/models/build.d.mts +35 -0
- package/dist/models/build.mjs +77 -0
- package/dist/models/build.mjs.map +1 -0
- package/dist/models/comment.d.mts +20 -0
- package/dist/models/comment.mjs +42 -0
- package/dist/models/comment.mjs.map +1 -0
- package/dist/models/label.d.mts +43 -0
- package/dist/models/label.mjs +107 -0
- package/dist/models/label.mjs.map +1 -0
- package/dist/models/member.d.mts +27 -0
- package/dist/models/member.mjs +54 -0
- package/dist/models/member.mjs.map +1 -0
- package/dist/models/project.d.mts +42 -0
- package/dist/models/project.mjs +78 -0
- package/dist/models/project.mjs.map +1 -0
- package/dist/models/snapshot.d.mts +46 -0
- package/dist/models/snapshot.mjs +75 -0
- package/dist/models/snapshot.mjs.map +1 -0
- package/dist/models/status-config.mjs +61 -0
- package/dist/models/status-config.mjs.map +1 -0
- package/dist/models/token.d.mts +33 -0
- package/dist/models/token.mjs +60 -0
- package/dist/models/token.mjs.map +1 -0
- package/dist/models/webhook.d.mts +37 -0
- package/dist/models/webhook.mjs +67 -0
- package/dist/models/webhook.mjs.map +1 -0
- package/dist/pages/build-detail.mjs +333 -0
- package/dist/pages/build-detail.mjs.map +1 -0
- package/dist/pages/build-diff.mjs +437 -0
- package/dist/pages/build-diff.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +196 -0
- package/dist/pages/compute-jobs.mjs.map +1 -0
- package/dist/pages/login.mjs +77 -0
- package/dist/pages/login.mjs.map +1 -0
- package/dist/pages/project-builds.mjs +228 -0
- package/dist/pages/project-builds.mjs.map +1 -0
- package/dist/pages/project-create.mjs +168 -0
- package/dist/pages/project-create.mjs.map +1 -0
- package/dist/pages/project-settings.mjs +111 -0
- package/dist/pages/project-settings.mjs.map +1 -0
- package/dist/pages/projects.mjs +178 -0
- package/dist/pages/projects.mjs.map +1 -0
- package/dist/pages/root.mjs +90 -0
- package/dist/pages/root.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +191 -0
- package/dist/pages/settings-general.mjs.map +1 -0
- package/dist/pages/settings-labels.mjs +149 -0
- package/dist/pages/settings-labels.mjs.map +1 -0
- package/dist/pages/settings-members.mjs +124 -0
- package/dist/pages/settings-members.mjs.map +1 -0
- package/dist/pages/settings-status.mjs +150 -0
- package/dist/pages/settings-status.mjs.map +1 -0
- package/dist/pages/settings-tokens.mjs +95 -0
- package/dist/pages/settings-tokens.mjs.map +1 -0
- package/dist/pages/settings-webhooks.mjs +149 -0
- package/dist/pages/settings-webhooks.mjs.map +1 -0
- package/dist/retention/purge.d.mts +28 -0
- package/dist/retention/purge.mjs +66 -0
- package/dist/retention/purge.mjs.map +1 -0
- package/dist/routers/admin.mjs +41 -0
- package/dist/routers/admin.mjs.map +1 -0
- package/dist/routers/assets.mjs +24 -0
- package/dist/routers/assets.mjs.map +1 -0
- package/dist/routers/auth.mjs +77 -0
- package/dist/routers/auth.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +87 -0
- package/dist/routers/builds.handlers.mjs.map +1 -0
- package/dist/routers/builds.mjs +162 -0
- package/dist/routers/builds.mjs.map +1 -0
- package/dist/routers/comments.mjs +89 -0
- package/dist/routers/comments.mjs.map +1 -0
- package/dist/routers/helpers.mjs +76 -0
- package/dist/routers/helpers.mjs.map +1 -0
- package/dist/routers/htmx.mjs +18 -0
- package/dist/routers/htmx.mjs.map +1 -0
- package/dist/routers/labels.mjs +77 -0
- package/dist/routers/labels.mjs.map +1 -0
- package/dist/routers/media.mjs +54 -0
- package/dist/routers/media.mjs.map +1 -0
- package/dist/routers/members.mjs +101 -0
- package/dist/routers/members.mjs.map +1 -0
- package/dist/routers/projects.mjs +121 -0
- package/dist/routers/projects.mjs.map +1 -0
- package/dist/routers/schemas.mjs +188 -0
- package/dist/routers/schemas.mjs.map +1 -0
- package/dist/routers/settings.mjs +235 -0
- package/dist/routers/settings.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +143 -0
- package/dist/routers/snapshots.mjs.map +1 -0
- package/dist/routers/status-configs.mjs +100 -0
- package/dist/routers/status-configs.mjs.map +1 -0
- package/dist/routers/tokens.mjs +80 -0
- package/dist/routers/tokens.mjs.map +1 -0
- package/dist/routers/ui.mjs +142 -0
- package/dist/routers/ui.mjs.map +1 -0
- package/dist/routers/webhooks.mjs +89 -0
- package/dist/routers/webhooks.mjs.map +1 -0
- package/dist/schema.d.mts +4372 -0
- package/dist/schema.mjs +158 -0
- package/dist/schema.mjs.map +1 -0
- package/dist/store.mjs +18 -0
- package/dist/store.mjs.map +1 -0
- package/dist/types.d.mts +18 -0
- package/dist/types.mjs +49 -0
- package/dist/types.mjs.map +1 -0
- package/dist/ui/components.mjs +127 -0
- package/dist/ui/components.mjs.map +1 -0
- package/dist/ui/document.d.mts +8 -0
- package/dist/ui/document.mjs +338 -0
- package/dist/ui/document.mjs.map +1 -0
- package/dist/ui/styles.mjs +173 -0
- package/dist/ui/styles.mjs.map +1 -0
- package/dist/ui/theme.mjs +42 -0
- package/dist/ui/theme.mjs.map +1 -0
- package/dist/urls.d.mts +19 -0
- package/dist/urls.mjs +23 -0
- package/dist/urls.mjs.map +1 -0
- package/dist/utils/encrypt.mjs +49 -0
- package/dist/utils/encrypt.mjs.map +1 -0
- package/dist/utils/hash.mjs +22 -0
- package/dist/utils/hash.mjs.map +1 -0
- package/dist/utils/paths.d.mts +11 -0
- package/dist/utils/paths.mjs +23 -0
- package/dist/utils/paths.mjs.map +1 -0
- package/dist/utils/ulid.d.mts +8 -0
- package/dist/utils/ulid.mjs +41 -0
- package/dist/utils/ulid.mjs.map +1 -0
- package/package.json +162 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { validateConfig, validateUiConfig } from "./config.mjs";
|
|
5
|
+
import { createShelfLogger } from "./logger.mjs";
|
|
6
|
+
import { rateLimit } from "./middleware/rate-limit.mjs";
|
|
7
|
+
import { csrf } from "./middleware/csrf.mjs";
|
|
8
|
+
import { baselines, buildLabels, builds, comments, labelTypes, projectMembers, projectStatusConfigs, projects, schema, snapshots, tokens, users, webhooks } from "./schema.mjs";
|
|
9
|
+
import { slugify, ulid } from "./utils/ulid.mjs";
|
|
10
|
+
import { BuildModel } from "./models/build.mjs";
|
|
11
|
+
import { ProjectModel } from "./models/project.mjs";
|
|
12
|
+
import { BUILD_STATUSES, PERSISTENT_LABEL_KEY, PROJECT_ROLES, RESERVED_LABEL_KEYS, SEEDED_LABEL_KEYS, SITE_ROLES, SNAPSHOT_STATUSES, TERMINAL_BUILD_STATUSES } from "./types.mjs";
|
|
13
|
+
import { Retention } from "./retention/purge.mjs";
|
|
14
|
+
import { getStore, runWithStore } from "./store.mjs";
|
|
15
|
+
import { registerAdmin } from "./routers/admin.mjs";
|
|
16
|
+
import { registerAssets } from "./routers/assets.mjs";
|
|
17
|
+
import { registerAuth } from "./routers/auth.mjs";
|
|
18
|
+
import { baselinePath, diffPath, screenshotPath, storybookDir, storybookZipPath } from "./utils/paths.mjs";
|
|
19
|
+
import { registerBuilds } from "./routers/builds.mjs";
|
|
20
|
+
import { registerLabels } from "./routers/labels.mjs";
|
|
21
|
+
import { registerMedia } from "./routers/media.mjs";
|
|
22
|
+
import { registerMembers } from "./routers/members.mjs";
|
|
23
|
+
import { registerProjects } from "./routers/projects.mjs";
|
|
24
|
+
import { registerStatusConfigs } from "./routers/status-configs.mjs";
|
|
25
|
+
import { registerTokens } from "./routers/tokens.mjs";
|
|
26
|
+
import { registerUiPages } from "./routers/ui.mjs";
|
|
27
|
+
import { registerWebhooks } from "./routers/webhooks.mjs";
|
|
28
|
+
import { diffImages } from "./diff/engine.mjs";
|
|
29
|
+
import { persistCapture } from "./capture/pipeline.mjs";
|
|
30
|
+
import { StorybookAdapter } from "./capture/storybook.mjs";
|
|
31
|
+
import { DEFAULT_VIEWPORTS } from "./capture/adapter.mjs";
|
|
32
|
+
import { executeCaptureJob } from "./capture/orchestrator.mjs";
|
|
33
|
+
import { InMemoryCaptureQueue } from "./capture/queue.mjs";
|
|
34
|
+
import { buildCommentMarkdown, commentMarker, describeStatus } from "./adapter/git-host/helpers.mjs";
|
|
35
|
+
import { postStatusesForBuild } from "./capture/status-fanout.mjs";
|
|
36
|
+
import { createUrlBuilder } from "./urls.mjs";
|
|
37
|
+
import { swaggerUI } from "@hono/swagger-ui";
|
|
38
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
39
|
+
import { requestId } from "hono/request-id";
|
|
40
|
+
//#region src/index.tsx
|
|
41
|
+
function buildAdapterSnapshot(options) {
|
|
42
|
+
const snap = {};
|
|
43
|
+
if (options.database.metadata) snap["database"] = options.database.metadata;
|
|
44
|
+
if (options.storage.metadata) snap["storage"] = options.storage.metadata;
|
|
45
|
+
if (options.captureRunner?.metadata) snap["captureRunner"] = options.captureRunner.metadata;
|
|
46
|
+
if (options.captureQueue?.metadata) snap["captureQueue"] = options.captureQueue.metadata;
|
|
47
|
+
if (options.auth?.metadata) snap["auth"] = options.auth.metadata;
|
|
48
|
+
for (const p of options.gitHosts ?? []) snap[`git:${p.metadata.kind}`] = p.metadata;
|
|
49
|
+
return snap;
|
|
50
|
+
}
|
|
51
|
+
async function hasApprovedBuildForSha(db, projectId, sha, excludeBuildId) {
|
|
52
|
+
return (await new BuildModel(db).list(projectId)).some((b) => b.gitSha === sha && b.id !== excludeBuildId && b.status === "approved");
|
|
53
|
+
}
|
|
54
|
+
async function isAlreadyMerged(opts) {
|
|
55
|
+
if (opts.providers.length === 0) return false;
|
|
56
|
+
const { StatusConfigModel } = await import("./models/status-config.mjs");
|
|
57
|
+
const model = new StatusConfigModel(opts.db, opts.secret);
|
|
58
|
+
const rows = await model.list(opts.projectId);
|
|
59
|
+
for (const row of rows) {
|
|
60
|
+
const provider = opts.providers.find((p) => p.metadata.kind === row.provider);
|
|
61
|
+
if (!provider) continue;
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(row.config);
|
|
65
|
+
provider.metadata.schema.parse(parsed);
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
let token;
|
|
70
|
+
try {
|
|
71
|
+
token = model.decryptToken(row);
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const adapter = provider.create({
|
|
76
|
+
config: parsed,
|
|
77
|
+
token,
|
|
78
|
+
logger: opts.logger
|
|
79
|
+
});
|
|
80
|
+
if (!adapter.isMerged) continue;
|
|
81
|
+
try {
|
|
82
|
+
if (await adapter.isMerged({
|
|
83
|
+
sha: opts.sha,
|
|
84
|
+
branch: opts.branch
|
|
85
|
+
})) return true;
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
async function resolveUser(c, options) {
|
|
91
|
+
if (!options.auth) return null;
|
|
92
|
+
if (c.req.header("authorization")) return null;
|
|
93
|
+
return await options.auth.check(c.req.raw);
|
|
94
|
+
}
|
|
95
|
+
function createShelfRouter(options) {
|
|
96
|
+
const app = new OpenAPIHono();
|
|
97
|
+
const rawConfig = options.config ? validateConfig(options.config) : {};
|
|
98
|
+
const ui = options.ui ? validateUiConfig(options.ui) : {};
|
|
99
|
+
const logger = options.logger ?? createShelfLogger();
|
|
100
|
+
const authEnabled = options.auth !== void 0;
|
|
101
|
+
const gitHosts = options.gitHosts ?? [];
|
|
102
|
+
const config = rawConfig.adapters ? rawConfig : {
|
|
103
|
+
...rawConfig,
|
|
104
|
+
adapters: buildAdapterSnapshot(options)
|
|
105
|
+
};
|
|
106
|
+
let queue = null;
|
|
107
|
+
let enqueueCapture;
|
|
108
|
+
if (options.captureRunner) {
|
|
109
|
+
if (!config.scratchDir) throw new Error("captureRunner is enabled but ShelfConfig.scratchDir is not set");
|
|
110
|
+
const jobOptions = {
|
|
111
|
+
db: options.database,
|
|
112
|
+
storage: options.storage,
|
|
113
|
+
runner: options.captureRunner,
|
|
114
|
+
scratchDir: config.scratchDir,
|
|
115
|
+
viewports: config.viewports,
|
|
116
|
+
logger
|
|
117
|
+
};
|
|
118
|
+
const captureQueue = options.captureQueue ?? new InMemoryCaptureQueue({
|
|
119
|
+
concurrency: config.captureConcurrency ?? 2,
|
|
120
|
+
logger,
|
|
121
|
+
runJob: async (job) => {
|
|
122
|
+
const builds = new BuildModel(options.database);
|
|
123
|
+
const build = await builds.get(job.buildId);
|
|
124
|
+
if (!build) {
|
|
125
|
+
await executeCaptureJob({
|
|
126
|
+
buildId: job.buildId,
|
|
127
|
+
reqId: job.reqId
|
|
128
|
+
}, jobOptions);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const project = await new ProjectModel(options.database).get(build.projectId);
|
|
132
|
+
if (!project) {
|
|
133
|
+
await executeCaptureJob({
|
|
134
|
+
buildId: job.buildId,
|
|
135
|
+
reqId: job.reqId
|
|
136
|
+
}, jobOptions);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const pendingUrl = `/projects/${project.slug}/builds/${job.buildId}`;
|
|
140
|
+
await postStatusesForBuild({
|
|
141
|
+
db: options.database,
|
|
142
|
+
project,
|
|
143
|
+
sha: build.gitSha,
|
|
144
|
+
status: "pending",
|
|
145
|
+
url: pendingUrl,
|
|
146
|
+
providers: gitHosts,
|
|
147
|
+
secret: config.secret,
|
|
148
|
+
logger
|
|
149
|
+
}).catch((error) => {
|
|
150
|
+
logger.error({ err: error }, "failed to post pending status");
|
|
151
|
+
});
|
|
152
|
+
if (!build.isDefault) {
|
|
153
|
+
if (await isAlreadyMerged({
|
|
154
|
+
providers: gitHosts,
|
|
155
|
+
sha: build.gitSha,
|
|
156
|
+
branch: build.gitBranch,
|
|
157
|
+
secret: config.secret,
|
|
158
|
+
db: options.database,
|
|
159
|
+
projectId: project.id,
|
|
160
|
+
logger
|
|
161
|
+
}).catch(() => false)) {
|
|
162
|
+
logger.info({
|
|
163
|
+
buildId: build.id,
|
|
164
|
+
sha: build.gitSha,
|
|
165
|
+
branch: build.gitBranch
|
|
166
|
+
}, "skipping capture — already merged");
|
|
167
|
+
await builds.setStatus(build.id, "approved").catch(() => {});
|
|
168
|
+
await postStatusesForBuild({
|
|
169
|
+
db: options.database,
|
|
170
|
+
project,
|
|
171
|
+
sha: build.gitSha,
|
|
172
|
+
status: "success",
|
|
173
|
+
url: pendingUrl,
|
|
174
|
+
providers: gitHosts,
|
|
175
|
+
secret: config.secret,
|
|
176
|
+
logger
|
|
177
|
+
}).catch(() => {});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (await hasApprovedBuildForSha(options.database, project.id, build.gitSha, build.id)) {
|
|
182
|
+
logger.info({
|
|
183
|
+
buildId: build.id,
|
|
184
|
+
sha: build.gitSha
|
|
185
|
+
}, "skipping capture — duplicate sha already approved");
|
|
186
|
+
await builds.setStatus(build.id, "approved").catch(() => {});
|
|
187
|
+
await postStatusesForBuild({
|
|
188
|
+
db: options.database,
|
|
189
|
+
project,
|
|
190
|
+
sha: build.gitSha,
|
|
191
|
+
status: "success",
|
|
192
|
+
url: pendingUrl,
|
|
193
|
+
providers: gitHosts,
|
|
194
|
+
secret: config.secret,
|
|
195
|
+
logger
|
|
196
|
+
}).catch(() => {});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await executeCaptureJob({
|
|
201
|
+
buildId: job.buildId,
|
|
202
|
+
reqId: job.reqId
|
|
203
|
+
}, jobOptions);
|
|
204
|
+
const updated = await builds.get(job.buildId);
|
|
205
|
+
if (updated) {
|
|
206
|
+
const terminal = updated.status === "approved" ? "success" : updated.status === "rejected" || updated.status === "failed" ? "failure" : null;
|
|
207
|
+
if (terminal) await postStatusesForBuild({
|
|
208
|
+
db: options.database,
|
|
209
|
+
project,
|
|
210
|
+
sha: updated.gitSha,
|
|
211
|
+
status: terminal,
|
|
212
|
+
url: pendingUrl,
|
|
213
|
+
providers: gitHosts,
|
|
214
|
+
secret: config.secret,
|
|
215
|
+
logger
|
|
216
|
+
}).catch((error) => {
|
|
217
|
+
logger.error({ err: error }, "failed to post terminal status");
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
await postStatusesForBuild({
|
|
222
|
+
db: options.database,
|
|
223
|
+
project,
|
|
224
|
+
sha: build.gitSha,
|
|
225
|
+
status: "failure",
|
|
226
|
+
url: pendingUrl,
|
|
227
|
+
providers: gitHosts,
|
|
228
|
+
secret: config.secret,
|
|
229
|
+
logger
|
|
230
|
+
}).catch(() => {});
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
queue = captureQueue;
|
|
236
|
+
enqueueCapture = async (buildId, reqId) => {
|
|
237
|
+
await captureQueue.enqueue({
|
|
238
|
+
buildId,
|
|
239
|
+
reqId
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
app.use("*", requestId());
|
|
244
|
+
app.use("*", async (c, next) => {
|
|
245
|
+
const started = performance.now();
|
|
246
|
+
const id = c.get("requestId");
|
|
247
|
+
logger.info({
|
|
248
|
+
reqId: id,
|
|
249
|
+
method: c.req.method,
|
|
250
|
+
url: c.req.path
|
|
251
|
+
}, "request start");
|
|
252
|
+
await next();
|
|
253
|
+
logger.info({
|
|
254
|
+
reqId: id,
|
|
255
|
+
method: c.req.method,
|
|
256
|
+
url: c.req.path,
|
|
257
|
+
status: c.res.status,
|
|
258
|
+
durationMs: Math.round(performance.now() - started)
|
|
259
|
+
}, "request end");
|
|
260
|
+
});
|
|
261
|
+
app.use("/api/v1/*", rateLimit({
|
|
262
|
+
windowMs: 6e4,
|
|
263
|
+
max: 100
|
|
264
|
+
}));
|
|
265
|
+
app.use("/api/v1/tokens/*", rateLimit({
|
|
266
|
+
windowMs: 6e4,
|
|
267
|
+
max: 10
|
|
268
|
+
}));
|
|
269
|
+
app.use("/api/v1/webhooks/*", rateLimit({
|
|
270
|
+
windowMs: 6e4,
|
|
271
|
+
max: 20
|
|
272
|
+
}));
|
|
273
|
+
app.use("/projects/:slug/settings/*", csrf());
|
|
274
|
+
app.use("*", async (c, next) => {
|
|
275
|
+
const user = await resolveUser(c, options);
|
|
276
|
+
return runWithStore({
|
|
277
|
+
db: options.database,
|
|
278
|
+
storage: options.storage,
|
|
279
|
+
config,
|
|
280
|
+
ui,
|
|
281
|
+
logger,
|
|
282
|
+
user,
|
|
283
|
+
authEnabled,
|
|
284
|
+
enqueueCapture,
|
|
285
|
+
captureQueue: queue,
|
|
286
|
+
gitHosts
|
|
287
|
+
}, async () => {
|
|
288
|
+
await next();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
app.use("*", async (c, next) => {
|
|
292
|
+
const { user, authEnabled: isAuthEnabled } = getStore();
|
|
293
|
+
const path = c.req.path;
|
|
294
|
+
if (!isAuthEnabled || user || path.startsWith("/api/") || path.startsWith("/auth/") || path.startsWith("/assets/")) return next();
|
|
295
|
+
if (c.req.header("HX-Request") === "true") {
|
|
296
|
+
c.header("HX-Redirect", "/auth/login");
|
|
297
|
+
return c.body(null, 204);
|
|
298
|
+
}
|
|
299
|
+
return c.redirect("/auth/login", 302);
|
|
300
|
+
});
|
|
301
|
+
registerProjects(app);
|
|
302
|
+
registerBuilds(app);
|
|
303
|
+
registerLabels(app);
|
|
304
|
+
registerMedia(app);
|
|
305
|
+
registerMembers(app);
|
|
306
|
+
registerTokens(app);
|
|
307
|
+
registerWebhooks(app);
|
|
308
|
+
registerStatusConfigs(app);
|
|
309
|
+
registerAdmin(app);
|
|
310
|
+
if (options.auth) registerAuth(app, options.auth);
|
|
311
|
+
registerAssets(app);
|
|
312
|
+
registerUiPages(app);
|
|
313
|
+
app.doc("/api/v1/openapi.json", {
|
|
314
|
+
openapi: "3.0.0",
|
|
315
|
+
info: {
|
|
316
|
+
title: "StoryShelf API",
|
|
317
|
+
description: "REST API for the StoryShelf visual testing platform. JSON endpoints live under /api/v1; HTML pages are served at /.",
|
|
318
|
+
version: "1.0.0"
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
app.get("/api/v1/docs", swaggerUI({ url: "/api/v1/openapi.json" }));
|
|
322
|
+
return app;
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
export { BUILD_STATUSES, DEFAULT_VIEWPORTS, InMemoryCaptureQueue, PERSISTENT_LABEL_KEY, PROJECT_ROLES, RESERVED_LABEL_KEYS, Retention, SEEDED_LABEL_KEYS, SITE_ROLES, SNAPSHOT_STATUSES, StorybookAdapter, TERMINAL_BUILD_STATUSES, baselinePath, baselines, buildCommentMarkdown, buildLabels, builds, commentMarker, comments, createShelfLogger, createShelfRouter, createUrlBuilder, describeStatus, diffImages, diffPath, executeCaptureJob, labelTypes, persistCapture, projectMembers, projectStatusConfigs, projects, schema, screenshotPath, slugify, snapshots, storybookDir, storybookZipPath, tokens, ulid, users, webhooks };
|
|
326
|
+
|
|
327
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.tsx"],"sourcesContent":["import { swaggerUI } from \"@hono/swagger-ui\";\nimport { OpenAPIHono } from \"@hono/zod-openapi\";\nimport { requestId } from \"hono/request-id\";\nimport type { AuthUser } from \"./adapters/auth.ts\";\nimport type { CaptureQueue } from \"./adapters/capture-queue.ts\";\nimport type { ShelfOptions } from \"./config.ts\";\nimport type { AdapterMetadata, GitAdapterMetadata } from \"./adapters/metadata.ts\";\nimport { validateConfig, validateUiConfig } from \"./config.ts\";\nimport { createShelfLogger } from \"./logger.ts\";\nimport { csrf, rateLimit } from \"./middleware/index.ts\";\nimport { BuildModel } from \"./models/build.ts\";\nimport { ProjectModel } from \"./models/project.ts\";\nimport { registerAdmin } from \"./routers/admin.ts\";\nimport { registerAssets } from \"./routers/assets.ts\";\nimport { registerAuth } from \"./routers/auth.ts\";\nimport { registerBuilds } from \"./routers/builds.ts\";\nimport { registerLabels } from \"./routers/labels.ts\";\nimport { registerMedia } from \"./routers/media.ts\";\nimport { registerMembers } from \"./routers/members.ts\";\nimport { registerProjects } from \"./routers/projects.ts\";\nimport { registerStatusConfigs } from \"./routers/status-configs.ts\";\nimport { registerTokens } from \"./routers/tokens.ts\";\nimport { registerUiPages } from \"./routers/ui.ts\";\nimport { registerWebhooks } from \"./routers/webhooks.ts\";\nimport { getStore, runWithStore } from \"./store.ts\";\nimport { executeCaptureJob, type CaptureJobOptions } from \"./capture/orchestrator.ts\";\nimport { InMemoryCaptureQueue } from \"./capture/queue.ts\";\nimport { postStatusesForBuild } from \"./capture/status-fanout.ts\";\n\nexport type ShelfContext = {\n requestId?: string;\n db: import(\"./adapters/database.ts\").DatabaseAdapter;\n storage: import(\"./adapters/storage.ts\").StorageAdapter;\n config: import(\"./config.ts\").ShelfConfig;\n ui: import(\"./config.ts\").UIConfig;\n logger: ReturnType<typeof createShelfLogger>;\n user: import(\"./adapters/auth.ts\").AuthUser | null;\n authEnabled: boolean;\n enqueueCapture?: (buildId: string, reqId?: string) => Promise<void>;\n captureQueue: import(\"./adapters/capture-queue.ts\").CaptureQueue | null;\n gitHosts: import(\"./adapters/git-host/index.ts\").GitHostProvider[];\n};\n\nexport type ShelfApp = OpenAPIHono<{ Variables: ShelfContext }>;\n\nfunction buildAdapterSnapshot(options: ShelfOptions): Record<string, AdapterMetadata | GitAdapterMetadata> {\n const snap: Record<string, AdapterMetadata | GitAdapterMetadata> = {};\n if (options.database.metadata) snap[\"database\"] = options.database.metadata;\n if (options.storage.metadata) snap[\"storage\"] = options.storage.metadata;\n if (options.captureRunner?.metadata) snap[\"captureRunner\"] = options.captureRunner.metadata;\n if (options.captureQueue?.metadata) snap[\"captureQueue\"] = options.captureQueue.metadata;\n if (options.auth?.metadata) snap[\"auth\"] = options.auth.metadata;\n for (const p of options.gitHosts ?? []) {\n snap[`git:${p.metadata.kind}`] = p.metadata;\n }\n return snap;\n}\n\nasync function hasApprovedBuildForSha(\n db: import(\"./adapters/database.ts\").DatabaseAdapter,\n projectId: string,\n sha: string,\n excludeBuildId: string,\n): Promise<boolean> {\n const builds = await new BuildModel(db).list(projectId);\n return builds.some((b) => b.gitSha === sha && b.id !== excludeBuildId && b.status === \"approved\");\n}\n\n// eslint-disable-next-line max-statements, complexity\nasync function isAlreadyMerged(opts: {\n providers: import(\"./adapters/git-host/index.ts\").GitHostProvider[];\n sha: string;\n branch: string;\n secret: string | undefined;\n db: import(\"./adapters/database.ts\").DatabaseAdapter;\n projectId: string;\n logger?: import(\"pino\").Logger;\n}): Promise<boolean> {\n if (opts.providers.length === 0) return false;\n const { StatusConfigModel } = await import(\"./models/status-config.ts\");\n const model = new StatusConfigModel(opts.db, opts.secret);\n const rows = await model.list(opts.projectId);\n for (const row of rows) {\n const provider = opts.providers.find((p) => p.metadata.kind === row.provider);\n if (!provider) continue;\n let parsed: unknown;\n try {\n parsed = JSON.parse(row.config);\n provider.metadata.schema.parse(parsed);\n } catch {\n continue;\n }\n let token: string;\n try {\n token = model.decryptToken(row);\n } catch {\n continue;\n }\n const adapter = provider.create({ config: parsed, token, logger: opts.logger });\n if (!adapter.isMerged) continue;\n try {\n // eslint-disable-next-line no-await-in-loop -- short-circuit on first merged status\n const merged = await adapter.isMerged({ sha: opts.sha, branch: opts.branch });\n if (merged) return true;\n } catch {\n // ignore provider errors — do not skip on failure\n }\n }\n return false;\n}\n\nasync function resolveUser(\n c: { req: { raw: Request; header: (name: string) => string | undefined } },\n options: ShelfOptions,\n): Promise<AuthUser | null> {\n if (!options.auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await options.auth.check(c.req.raw);\n}\n\n\nexport function createShelfRouter(options: ShelfOptions): ShelfApp {\n const app = new OpenAPIHono<{ Variables: ShelfContext }>();\n // eslint-disable-next-line typescript/no-unnecessary-type-assertion -- ShelfConfig lacks index signature\n const rawConfig = options.config ? validateConfig(options.config as unknown as Record<string, unknown>) : {};\n // eslint-disable-next-line typescript/no-unnecessary-type-assertion -- UIConfig lacks index signature\n const ui = options.ui ? validateUiConfig(options.ui as unknown as Record<string, unknown>) : {};\n const logger = options.logger ?? createShelfLogger();\n const authEnabled = options.auth !== undefined;\n\n const gitHosts = options.gitHosts ?? [];\n\n // Adapter introspection — auto-populate config.adapters if not supplied\n const config: import(\"./config.ts\").ShelfConfig = rawConfig.adapters\n ? rawConfig\n : {\n ...rawConfig,\n adapters: buildAdapterSnapshot(options),\n };\n\n let queue: CaptureQueue | null = null;\n let enqueueCapture: ((buildId: string, reqId?: string) => Promise<void>) | undefined;\n if (options.captureRunner) {\n if (!config.scratchDir) {\n throw new Error(\"captureRunner is enabled but ShelfConfig.scratchDir is not set\");\n }\n const jobOptions: CaptureJobOptions = {\n db: options.database,\n storage: options.storage,\n runner: options.captureRunner,\n scratchDir: config.scratchDir,\n viewports: config.viewports,\n logger,\n };\n const captureQueue =\n options.captureQueue ??\n new InMemoryCaptureQueue({\n concurrency: config.captureConcurrency ?? 2,\n logger,\n runJob: async (job): Promise<void> => {\n const builds = new BuildModel(options.database);\n const build = await builds.get(job.buildId);\n if (!build) {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, jobOptions);\n return;\n }\n const project = await new ProjectModel(options.database).get(build.projectId);\n if (!project) {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, jobOptions);\n return;\n }\n const pendingUrl = `/projects/${project.slug}/builds/${job.buildId}`;\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"pending\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch((error: unknown) => {\n logger.error({ err: error }, \"failed to post pending status\");\n });\n // Skip capture if already merged (PR closed) or locally deduped\n if (!build.isDefault) {\n const merged = await isAlreadyMerged({\n providers: gitHosts,\n sha: build.gitSha,\n branch: build.gitBranch,\n secret: config.secret,\n db: options.database,\n projectId: project.id,\n logger,\n }).catch(() => false);\n if (merged) {\n logger.info({ buildId: build.id, sha: build.gitSha, branch: build.gitBranch }, \"skipping capture — already merged\");\n await builds.setStatus(build.id, \"approved\").catch(() => {}); // intentionally empty — fire-and-forget\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"success\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch(() => {}); // intentionally empty — fire-and-forget\n return;\n }\n }\n // Local dedupe: if another approved build for same sha exists, skip render\n const dup = await hasApprovedBuildForSha(options.database, project.id, build.gitSha, build.id);\n if (dup) {\n logger.info({ buildId: build.id, sha: build.gitSha }, \"skipping capture — duplicate sha already approved\");\n await builds.setStatus(build.id, \"approved\").catch(() => {}); // intentionally empty — fire-and-forget\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"success\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch(() => {}); // intentionally empty — fire-and-forget\n return;\n }\n try {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, jobOptions);\n const updated = await builds.get(job.buildId);\n if (updated) {\n const terminal =\n updated.status === \"approved\"\n ? \"success\"\n : updated.status === \"rejected\" || updated.status === \"failed\"\n ? \"failure\"\n : null;\n if (terminal) {\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: updated.gitSha,\n status: terminal,\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch((error: unknown) => {\n logger.error({ err: error }, \"failed to post terminal status\");\n });\n }\n }\n } catch (error: unknown) {\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"failure\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch(() => {\n // ignore: status post failure already logged\n });\n throw error;\n }\n },\n });\n queue = captureQueue;\n enqueueCapture = async (buildId: string, reqId?: string): Promise<void> => {\n await captureQueue.enqueue({ buildId, reqId });\n };\n }\n\n app.use(\"*\", requestId());\n\n // Structured request logging. Uses a Hono-native middleware rather than\n // pino-http, which expects a Node server response (`res.on`) incompatible\n // with Hono's Web `Request`/`Response` model.\n app.use(\"*\", async (c, next) => {\n const started = performance.now();\n const id = c.get(\"requestId\");\n logger.info({ reqId: id, method: c.req.method, url: c.req.path }, \"request start\");\n await next();\n logger.info(\n {\n reqId: id,\n method: c.req.method,\n url: c.req.path,\n status: c.res.status,\n durationMs: Math.round(performance.now() - started),\n },\n \"request end\",\n );\n });\n\n app.use(\"/api/v1/*\", rateLimit({ windowMs: 60_000, max: 100 }));\n app.use(\"/api/v1/tokens/*\", rateLimit({ windowMs: 60_000, max: 10 }));\n app.use(\"/api/v1/webhooks/*\", rateLimit({ windowMs: 60_000, max: 20 }));\n app.use(\"/projects/:slug/settings/*\", csrf());\n\n app.use(\"*\", async (c, next) => {\n const user = await resolveUser(c, options);\n return runWithStore(\n {\n db: options.database,\n storage: options.storage,\n config,\n ui,\n logger,\n user,\n authEnabled,\n enqueueCapture,\n captureQueue: queue,\n gitHosts,\n },\n async () => {\n await next();\n },\n );\n });\n\n // Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n // requests are redirected to the login page. API and auth routes are handled\n // by their own routers (401/403 vs the login flow).\n // eslint-disable-next-line require-await -- async is required to match Hono's middleware signature\n app.use(\"*\", async (c, next) => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const path = c.req.path;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\")\n ) {\n return next();\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n });\n\n registerProjects(app);\n registerBuilds(app);\n registerLabels(app);\n registerMedia(app);\n registerMembers(app);\n registerTokens(app);\n registerWebhooks(app);\n registerStatusConfigs(app);\n registerAdmin(app);\n if (options.auth) {\n registerAuth(app, options.auth);\n }\n registerAssets(app);\n registerUiPages(app);\n\n app.doc(\"/api/v1/openapi.json\", {\n openapi: \"3.0.0\",\n info: {\n title: \"StoryShelf API\",\n description:\n \"REST API for the StoryShelf visual testing platform. JSON endpoints live under /api/v1; HTML pages are served at /.\",\n version: \"1.0.0\",\n },\n });\n app.get(\"/api/v1/docs\", swaggerUI({ url: \"/api/v1/openapi.json\" }));\n\n return app;\n}\n\nexport type { ShelfOptions, ShelfConfig, UIConfig, BrandTheme } from \"./config.ts\";\nexport type { DatabaseAdapter, ListOptions } from \"./adapters/database.ts\";\nexport type { StorageAdapter } from \"./adapters/storage.ts\";\nexport type {\n CaptureRunner,\n JobStatus,\n RenderedSnapshot,\n RenderResult,\n RenderFailure,\n} from \"./adapters/capture-runner.ts\";\nexport type { CaptureQueue, CaptureJob, QueueEntry } from \"./adapters/capture-queue.ts\";\nexport type { AuthAdapter, AuthUser, AuthCallback } from \"./adapters/auth.ts\";\nexport type { GitHostProvider, GitHostAdapter, CheckStatus } from \"./adapters/git-host/index.ts\";\nexport type { AdapterMetadata, GitAdapterMetadata } from \"./adapters/metadata.ts\";\nexport { describeStatus, buildCommentMarkdown, commentMarker } from \"./adapters/git-host/helpers.ts\";\nexport type { DiffOptions, DiffResult } from \"./diff/options.ts\";\nexport type { Viewport, StoryEntry, StorySourceAdapter } from \"./capture/adapter.ts\";\nexport { createShelfLogger, type LoggerOptions, type PinoTransport } from \"./logger.ts\";\nexport { diffImages } from \"./diff/engine.ts\";\nexport type { RenderedContent } from \"./ui/document.tsx\";\nexport { StorybookAdapter } from \"./capture/storybook.ts\";\nexport { persistCapture, type CaptureContext } from \"./capture/pipeline.ts\";\nexport { executeCaptureJob, type CaptureJobOptions } from \"./capture/orchestrator.ts\";\nexport { DEFAULT_VIEWPORTS } from \"./capture/adapter.ts\";\nexport { InMemoryCaptureQueue, type InMemoryCaptureQueueOptions } from \"./capture/queue.ts\";\nexport { Retention } from \"./retention/purge.ts\";\nexport { createUrlBuilder, type UrlBuilder } from \"./urls.ts\";\nexport { ulid, slugify } from \"./utils/ulid.ts\";\nexport {\n baselinePath,\n diffPath,\n screenshotPath,\n storybookDir,\n storybookZipPath,\n} from \"./utils/paths.ts\";\nexport * from \"./schema.ts\";\nexport * from \"./types.ts\";\n"],"mappings":"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"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import __tsdown_shims_path from 'node:path';
|
|
2
|
+
import __tsdown_shims_url from 'node:url';
|
|
3
|
+
import { Logger } from "pino";
|
|
4
|
+
//#region src/logger.d.ts
|
|
5
|
+
/** A pino worker transport to attach to the logger output. */
|
|
6
|
+
interface PinoTransport {
|
|
7
|
+
/** Package name or absolute path of the transport module. */
|
|
8
|
+
target: string;
|
|
9
|
+
/** Options passed to the transport worker. */
|
|
10
|
+
options?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
/** Configuration for constructing the shelf logger. */
|
|
13
|
+
interface LoggerOptions {
|
|
14
|
+
/** Minimum level to emit. Defaults to `"info"`. */
|
|
15
|
+
level?: string;
|
|
16
|
+
/** Extra pino worker transports appended to the default stdout sink. */
|
|
17
|
+
transports?: PinoTransport[];
|
|
18
|
+
/** Deployment environment recorded in the `env` base field. Defaults to `NODE_ENV`. */
|
|
19
|
+
env?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Create the structured JSON logger used across StoryShelf.
|
|
23
|
+
*
|
|
24
|
+
* Logging conventions:
|
|
25
|
+
* - Always log structured objects, never interpolate into the message:
|
|
26
|
+
* `logger.info({ projectId, buildId }, "build started")`.
|
|
27
|
+
* - Attach errors as an `err` child field rather than stringifying into the message:
|
|
28
|
+
* `logger.error({ err }, "capture failed")`.
|
|
29
|
+
* - Derive scoped child loggers for background work:
|
|
30
|
+
* `const captureLogger = logger.child({ buildId })`.
|
|
31
|
+
*
|
|
32
|
+
* The default sink is stdout. Hosted observability platforms (Sentry, PostHog,
|
|
33
|
+
* Datadog, GCP, OTEL collector, etc.) are added as pino worker `transports` —
|
|
34
|
+
* they are sinks for this logger, not standalone loggers.
|
|
35
|
+
*/
|
|
36
|
+
declare function createShelfLogger(options?: LoggerOptions): Logger;
|
|
37
|
+
//#endregion
|
|
38
|
+
export { LoggerOptions, PinoTransport, createShelfLogger };
|
|
39
|
+
//# sourceMappingURL=logger.d.mts.map
|
package/dist/logger.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import pino from "pino";
|
|
5
|
+
//#region src/logger.ts
|
|
6
|
+
/**
|
|
7
|
+
* Create the structured JSON logger used across StoryShelf.
|
|
8
|
+
*
|
|
9
|
+
* Logging conventions:
|
|
10
|
+
* - Always log structured objects, never interpolate into the message:
|
|
11
|
+
* `logger.info({ projectId, buildId }, "build started")`.
|
|
12
|
+
* - Attach errors as an `err` child field rather than stringifying into the message:
|
|
13
|
+
* `logger.error({ err }, "capture failed")`.
|
|
14
|
+
* - Derive scoped child loggers for background work:
|
|
15
|
+
* `const captureLogger = logger.child({ buildId })`.
|
|
16
|
+
*
|
|
17
|
+
* The default sink is stdout. Hosted observability platforms (Sentry, PostHog,
|
|
18
|
+
* Datadog, GCP, OTEL collector, etc.) are added as pino worker `transports` —
|
|
19
|
+
* they are sinks for this logger, not standalone loggers.
|
|
20
|
+
*/
|
|
21
|
+
function createShelfLogger(options = {}) {
|
|
22
|
+
const targets = [{
|
|
23
|
+
target: "pino/file",
|
|
24
|
+
options: { destination: 1 }
|
|
25
|
+
}, ...options.transports ?? []];
|
|
26
|
+
const transport = pino.transport({ targets });
|
|
27
|
+
return pino({
|
|
28
|
+
level: options.level ?? "info",
|
|
29
|
+
base: { env: options.env ?? process.env["NODE_ENV"] }
|
|
30
|
+
}, transport);
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
export { createShelfLogger };
|
|
34
|
+
|
|
35
|
+
//# sourceMappingURL=logger.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.mjs","names":[],"sources":["../src/logger.ts"],"sourcesContent":["import pino, { type Logger } from \"pino\";\n\n/** A pino worker transport to attach to the logger output. */\nexport interface PinoTransport {\n /** Package name or absolute path of the transport module. */\n target: string;\n /** Options passed to the transport worker. */\n options?: Record<string, unknown>;\n}\n\n/** Configuration for constructing the shelf logger. */\nexport interface LoggerOptions {\n /** Minimum level to emit. Defaults to `\"info\"`. */\n level?: string;\n /** Extra pino worker transports appended to the default stdout sink. */\n transports?: PinoTransport[];\n /** Deployment environment recorded in the `env` base field. Defaults to `NODE_ENV`. */\n env?: string;\n}\n\n/**\n * Create the structured JSON logger used across StoryShelf.\n *\n * Logging conventions:\n * - Always log structured objects, never interpolate into the message:\n * `logger.info({ projectId, buildId }, \"build started\")`.\n * - Attach errors as an `err` child field rather than stringifying into the message:\n * `logger.error({ err }, \"capture failed\")`.\n * - Derive scoped child loggers for background work:\n * `const captureLogger = logger.child({ buildId })`.\n *\n * The default sink is stdout. Hosted observability platforms (Sentry, PostHog,\n * Datadog, GCP, OTEL collector, etc.) are added as pino worker `transports` —\n * they are sinks for this logger, not standalone loggers.\n */\nexport function createShelfLogger(options: LoggerOptions = {}): Logger {\n const targets = [{ target: \"pino/file\", options: { destination: 1 } }, ...(options.transports ?? [])];\n const transport = pino.transport({ targets });\n return pino(\n {\n level: options.level ?? \"info\",\n base: { env: options.env ?? process.env[\"NODE_ENV\"] },\n },\n transport,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAmCA,SAAgB,kBAAkB,UAAyB,CAAC,GAAW;CACrE,MAAM,UAAU,CAAC;EAAE,QAAQ;EAAa,SAAS,EAAE,aAAa,EAAE;CAAE,GAAG,GAAI,QAAQ,cAAc,CAAC,CAAE;CACpG,MAAM,YAAY,KAAK,UAAU,EAAE,QAAQ,CAAC;CAC5C,OAAO,KACL;EACE,OAAO,QAAQ,SAAS;EACxB,MAAM,EAAE,KAAK,QAAQ,OAAO,QAAQ,IAAI,YAAY;CACtD,GACA,SACF;AACF"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
//#region src/middleware/csrf.ts
|
|
6
|
+
const CSRF_SECRET = process.env["CSRF_SECRET"] ?? randomBytes(32).toString("hex");
|
|
7
|
+
function generateToken(sessionId) {
|
|
8
|
+
const payload = `${sessionId}:${Date.now().toString(36)}`;
|
|
9
|
+
return `${payload}:${createHash("sha256").update(`${CSRF_SECRET}:${payload}`).digest("hex").slice(0, 16)}`;
|
|
10
|
+
}
|
|
11
|
+
function verifyToken(token, sessionId) {
|
|
12
|
+
const parts = token.split(":");
|
|
13
|
+
if (parts.length !== 3) return false;
|
|
14
|
+
const [payloadTimestamp, signature] = parts.slice(1);
|
|
15
|
+
if (!payloadTimestamp || !signature) return false;
|
|
16
|
+
if (signature !== createHash("sha256").update(`${CSRF_SECRET}:${sessionId}:${payloadTimestamp}`).digest("hex").slice(0, 16)) return false;
|
|
17
|
+
const timestamp = Number.parseInt(payloadTimestamp, 36);
|
|
18
|
+
const age = Date.now() - timestamp;
|
|
19
|
+
return age > 0 && age < 864e5;
|
|
20
|
+
}
|
|
21
|
+
function csrf() {
|
|
22
|
+
return async (c, next) => {
|
|
23
|
+
const method = c.req.method;
|
|
24
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
|
|
25
|
+
const token = generateToken(c.req.header("session-id") ?? "default");
|
|
26
|
+
c.header("X-CSRF-Token", token);
|
|
27
|
+
await next();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const sessionId = c.req.header("session-id") ?? "default";
|
|
31
|
+
const token = c.req.header("x-csrf-token") ?? c.req.query("csrf_token");
|
|
32
|
+
if (!token || !verifyToken(token, sessionId)) return c.json({ error: "Invalid CSRF token" }, 403);
|
|
33
|
+
await next();
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { csrf };
|
|
38
|
+
|
|
39
|
+
//# sourceMappingURL=csrf.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"csrf.mjs","names":[],"sources":["../../src/middleware/csrf.ts"],"sourcesContent":["import { createHash, randomBytes } from \"node:crypto\";\nimport type { Context, Next } from \"hono\";\n\nconst CSRF_SECRET = process.env[\"CSRF_SECRET\"] ?? randomBytes(32).toString(\"hex\");\n\nfunction generateToken(sessionId: string): string {\n const timestamp = Date.now().toString(36);\n const payload = `${sessionId}:${timestamp}`;\n const signature = createHash(\"sha256\").update(`${CSRF_SECRET}:${payload}`).digest(\"hex\").slice(0, 16);\n return `${payload}:${signature}`;\n}\n\nfunction verifyToken(token: string, sessionId: string): boolean {\n const parts = token.split(\":\");\n if (parts.length !== 3) {\n return false;\n }\n const [payloadTimestamp, signature] = parts.slice(1);\n if (!payloadTimestamp || !signature) {\n return false;\n }\n const expectedSignature = createHash(\"sha256\").update(`${CSRF_SECRET}:${sessionId}:${payloadTimestamp}`).digest(\"hex\").slice(0, 16);\n if (signature !== expectedSignature) {\n return false;\n }\n const timestamp = Number.parseInt(payloadTimestamp, 36);\n const age = Date.now() - timestamp;\n return age > 0 && age < 24 * 60 * 60 * 1000;\n}\n\nexport function csrf() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const method = c.req.method;\n if (method === \"GET\" || method === \"HEAD\" || method === \"OPTIONS\") {\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = generateToken(sessionId);\n c.header(\"X-CSRF-Token\", token);\n await next();\n return;\n }\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = c.req.header(\"x-csrf-token\") ?? c.req.query(\"csrf_token\");\n if (!token || !verifyToken(token, sessionId)) {\n return c.json({ error: \"Invalid CSRF token\" }, 403);\n }\n await next();\n };\n}\n\nexport function getCsrfToken(sessionId: string): string {\n return generateToken(sessionId);\n}\n\n"],"mappings":";;;;;AAGA,MAAM,cAAc,QAAQ,IAAI,kBAAkB,YAAY,EAAE,CAAC,CAAC,SAAS,KAAK;AAEhF,SAAS,cAAc,WAA2B;CAEhD,MAAM,UAAU,GAAG,UAAU,GADX,KAAK,IAAI,CAAC,CAAC,SAAS,EACE;CAExC,OAAO,GAAG,QAAQ,GADA,WAAW,QAAQ,CAAC,CAAC,OAAO,GAAG,YAAY,GAAG,SAAS,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,EACrE;AAC/B;AAEA,SAAS,YAAY,OAAe,WAA4B;CAC9D,MAAM,QAAQ,MAAM,MAAM,GAAG;CAC7B,IAAI,MAAM,WAAW,GACnB,OAAO;CAET,MAAM,CAAC,kBAAkB,aAAa,MAAM,MAAM,CAAC;CACnD,IAAI,CAAC,oBAAoB,CAAC,WACxB,OAAO;CAGT,IAAI,cADsB,WAAW,QAAQ,CAAC,CAAC,OAAO,GAAG,YAAY,GAAG,UAAU,GAAG,kBAAkB,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,EAC9F,GAChC,OAAO;CAET,MAAM,YAAY,OAAO,SAAS,kBAAkB,EAAE;CACtD,MAAM,MAAM,KAAK,IAAI,IAAI;CACzB,OAAO,MAAM,KAAK,MAAM;AAC1B;AAEA,SAAgB,OAAO;CAErB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,SAAS,EAAE,IAAI;EACrB,IAAI,WAAW,SAAS,WAAW,UAAU,WAAW,WAAW;GAEjE,MAAM,QAAQ,cADI,EAAE,IAAI,OAAO,YAAY,KAAK,SACX;GACrC,EAAE,OAAO,gBAAgB,KAAK;GAC9B,MAAM,KAAK;GACX;EACF;EACA,MAAM,YAAY,EAAE,IAAI,OAAO,YAAY,KAAK;EAChD,MAAM,QAAQ,EAAE,IAAI,OAAO,cAAc,KAAK,EAAE,IAAI,MAAM,YAAY;EACtE,IAAI,CAAC,SAAS,CAAC,YAAY,OAAO,SAAS,GACzC,OAAO,EAAE,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;EAEpD,MAAM,KAAK;CACb;AACF"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
//#region src/middleware/rate-limit.ts
|
|
5
|
+
const stores = /* @__PURE__ */ new Map();
|
|
6
|
+
function cleanup() {
|
|
7
|
+
const now = Date.now();
|
|
8
|
+
for (const [key, entry] of stores) if (now > entry.resetAt) stores.delete(key);
|
|
9
|
+
}
|
|
10
|
+
setInterval(cleanup, 6e4).unref();
|
|
11
|
+
function rateLimit(options) {
|
|
12
|
+
const { windowMs, max, keyGenerator } = options;
|
|
13
|
+
return async (c, next) => {
|
|
14
|
+
const key = keyGenerator ? keyGenerator(c) : c.req.header("x-forwarded-for") ?? "anonymous";
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
let entry = stores.get(key);
|
|
17
|
+
if (!entry || now > entry.resetAt) {
|
|
18
|
+
entry = {
|
|
19
|
+
count: 0,
|
|
20
|
+
resetAt: now + windowMs
|
|
21
|
+
};
|
|
22
|
+
stores.set(key, entry);
|
|
23
|
+
}
|
|
24
|
+
entry.count += 1;
|
|
25
|
+
if (entry.count > max) {
|
|
26
|
+
const retryAfter = Math.ceil((entry.resetAt - now) / 1e3);
|
|
27
|
+
c.header("Retry-After", String(retryAfter));
|
|
28
|
+
return c.json({ error: "Too many requests" }, 429);
|
|
29
|
+
}
|
|
30
|
+
c.header("X-RateLimit-Limit", String(max));
|
|
31
|
+
c.header("X-RateLimit-Remaining", String(Math.max(0, max - entry.count)));
|
|
32
|
+
await next();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { rateLimit };
|
|
37
|
+
|
|
38
|
+
//# sourceMappingURL=rate-limit.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.mjs","names":[],"sources":["../../src/middleware/rate-limit.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\n\ninterface RateLimitEntry {\n count: number;\n resetAt: number;\n}\n\ninterface RateLimitOptions {\n windowMs: number;\n max: number;\n keyGenerator?: (c: Context) => string;\n}\n\nconst stores = new Map<string, RateLimitEntry>();\n\nfunction cleanup(): void {\n const now = Date.now();\n for (const [key, entry] of stores) {\n if (now > entry.resetAt) {\n stores.delete(key);\n }\n }\n}\n\nsetInterval(cleanup, 60_000).unref();\n\nexport function rateLimit(options: RateLimitOptions) {\n const { windowMs, max, keyGenerator } = options;\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const key = keyGenerator ? keyGenerator(c) : (c.req.header(\"x-forwarded-for\") ?? \"anonymous\");\n const now = Date.now();\n let entry = stores.get(key);\n if (!entry || now > entry.resetAt) {\n entry = { count: 0, resetAt: now + windowMs };\n stores.set(key, entry);\n }\n entry.count += 1;\n if (entry.count > max) {\n const retryAfter = Math.ceil((entry.resetAt - now) / 1000);\n c.header(\"Retry-After\", String(retryAfter));\n return c.json({ error: \"Too many requests\" }, 429);\n }\n c.header(\"X-RateLimit-Limit\", String(max));\n c.header(\"X-RateLimit-Remaining\", String(Math.max(0, max - entry.count)));\n await next();\n };\n}\n"],"mappings":";;;;AAaA,MAAM,yBAAS,IAAI,IAA4B;AAE/C,SAAS,UAAgB;CACvB,MAAM,MAAM,KAAK,IAAI;CACrB,KAAK,MAAM,CAAC,KAAK,UAAU,QACzB,IAAI,MAAM,MAAM,SACd,OAAO,OAAO,GAAG;AAGvB;AAEA,YAAY,SAAS,GAAM,CAAC,CAAC,MAAM;AAEnC,SAAgB,UAAU,SAA2B;CACnD,MAAM,EAAE,UAAU,KAAK,iBAAiB;CAExC,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,MAAM,eAAe,aAAa,CAAC,IAAK,EAAE,IAAI,OAAO,iBAAiB,KAAK;EACjF,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,QAAQ,OAAO,IAAI,GAAG;EAC1B,IAAI,CAAC,SAAS,MAAM,MAAM,SAAS;GACjC,QAAQ;IAAE,OAAO;IAAG,SAAS,MAAM;GAAS;GAC5C,OAAO,IAAI,KAAK,KAAK;EACvB;EACA,MAAM,SAAS;EACf,IAAI,MAAM,QAAQ,KAAK;GACrB,MAAM,aAAa,KAAK,MAAM,MAAM,UAAU,OAAO,GAAI;GACzD,EAAE,OAAO,eAAe,OAAO,UAAU,CAAC;GAC1C,OAAO,EAAE,KAAK,EAAE,OAAO,oBAAoB,GAAG,GAAG;EACnD;EACA,EAAE,OAAO,qBAAqB,OAAO,GAAG,CAAC;EACzC,EAAE,OAAO,yBAAyB,OAAO,KAAK,IAAI,GAAG,MAAM,MAAM,KAAK,CAAC,CAAC;EACxE,MAAM,KAAK;CACb;AACF"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import __tsdown_shims_path from 'node:path';
|
|
2
|
+
import __tsdown_shims_url from 'node:url';
|
|
3
|
+
import { DatabaseAdapter } from "../adapter/database.mjs";
|
|
4
|
+
import { StorageAdapter } from "../adapter/storage.mjs";
|
|
5
|
+
import { Baseline } from "../schema.mjs";
|
|
6
|
+
//#region src/models/baseline.d.ts
|
|
7
|
+
declare class BaselineModel {
|
|
8
|
+
private readonly db;
|
|
9
|
+
private readonly storage;
|
|
10
|
+
constructor(db: DatabaseAdapter, storage: StorageAdapter);
|
|
11
|
+
getFor(projectId: string, storyId: string, viewport: string, branch: string): Promise<Baseline | null>;
|
|
12
|
+
resolve(projectId: string, storyId: string, viewport: string, branch: string, defaultBranch: string): Promise<Baseline | null>;
|
|
13
|
+
read(baseline: Baseline): Promise<Buffer>;
|
|
14
|
+
upsert(projectId: string, storyId: string, viewport: string, branch: string, snapshotId: string, sourcePath: string): Promise<Baseline>;
|
|
15
|
+
list(projectId: string): Promise<Baseline[]>;
|
|
16
|
+
removeOrphans(projectId: string, validStoryIds: Set<string>): Promise<number>;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { type Baseline, BaselineModel };
|
|
20
|
+
//# sourceMappingURL=baseline.d.mts.map
|