@yanolja-next/noya-sdk 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.
@@ -0,0 +1,125 @@
1
+ import crypto from "node:crypto";
2
+ export async function fetchSentryEventContext(project, sentry) {
3
+ const integration = sentryIntegration(project);
4
+ const eventId = sentry?.event_id;
5
+ if (!eventId || Object.keys(integration).length === 0)
6
+ return {};
7
+ const apiBase = String(integration.api_base ?? "https://sentry.io").replace(/\/$/, "");
8
+ const orgSlug = stringSetting(integration.org_slug) ?? project.telemetry?.org_slug ?? project.sentry?.org_slug;
9
+ const projectSlug = stringSetting(integration.project_slug) ?? project.telemetry?.project_slug ?? project.sentry?.project_slug;
10
+ const tokenEnv = stringSetting(integration.token_env);
11
+ if (!orgSlug || !projectSlug || !tokenEnv)
12
+ return {};
13
+ const token = process.env[tokenEnv];
14
+ if (!token)
15
+ throw new Error(`Missing Sentry token env var: ${tokenEnv}`);
16
+ const response = await fetch(`${apiBase}/api/0/projects/${encodeURIComponent(orgSlug)}/${encodeURIComponent(projectSlug)}/events/${encodeURIComponent(eventId)}/`, {
17
+ headers: {
18
+ authorization: `Bearer ${token}`,
19
+ accept: "application/json",
20
+ },
21
+ });
22
+ if (!response.ok)
23
+ throw new Error(`Sentry event lookup failed: ${response.status}`);
24
+ return sentryEventToReport((await response.json()));
25
+ }
26
+ export function verifySentryWebhookSignature({ project, rawBody, signature, }) {
27
+ const integration = sentryIntegration(project);
28
+ const secretEnv = stringSetting(integration.webhook_secret_env);
29
+ if (!secretEnv)
30
+ return;
31
+ const secret = process.env[secretEnv];
32
+ if (!secret)
33
+ throw Object.assign(new Error(`Missing Sentry webhook secret env var: ${secretEnv}`), { status: 500 });
34
+ if (!signature)
35
+ throw Object.assign(new Error("Missing Sentry webhook signature"), { status: 401 });
36
+ const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
37
+ if (!constantTimeEqual(signature.replace(/^sha256=/, ""), expected)) {
38
+ throw Object.assign(new Error("Invalid Sentry webhook signature"), { status: 401 });
39
+ }
40
+ }
41
+ function sentryEventToReport(event) {
42
+ const tags = tagsToObject(event.tags);
43
+ const request = objectValue(event.request);
44
+ const user = objectValue(event.user);
45
+ const contexts = objectValue(event.contexts);
46
+ const replay = objectValue(contexts?.replay);
47
+ const entries = Array.isArray(event.entries) ? event.entries : [];
48
+ const breadcrumbs = entries.find((entry) => {
49
+ const candidate = objectValue(entry);
50
+ return candidate?.type === "breadcrumbs";
51
+ });
52
+ return {
53
+ message: stringValue(event.title) ?? stringValue(event.message),
54
+ release: stringValue(event.release) ?? stringValue(tags.release),
55
+ environment: stringValue(event.environment),
56
+ route: stringValue(request?.url) ?? stringValue(tags.route),
57
+ request_id: stringValue(tags.request_id) ?? stringValue(event.request_id),
58
+ screenshot_url: stringValue(event.screenshot_url) ??
59
+ stringValue(event.screenshotUrl) ??
60
+ stringValue(tags.screenshot_url),
61
+ sentry: {
62
+ event_id: stringValue(event.eventID) ?? stringValue(event.event_id) ?? stringValue(event.id),
63
+ replay_id: stringValue(replay?.replay_id) ?? stringValue(tags.replay_id),
64
+ feedback_id: stringValue(tags.feedback_id),
65
+ event_url: stringValue(event.web_url),
66
+ },
67
+ telemetry: {
68
+ provider: "sentry",
69
+ event_id: stringValue(event.eventID) ?? stringValue(event.event_id) ?? stringValue(event.id),
70
+ replay_id: stringValue(replay?.replay_id) ?? stringValue(tags.replay_id),
71
+ feedback_id: stringValue(tags.feedback_id),
72
+ event_url: stringValue(event.web_url),
73
+ },
74
+ context: {
75
+ user_id: stringValue(user?.id),
76
+ email: stringValue(user?.email),
77
+ tenant_id: stringValue(tags.tenant_id),
78
+ trace_id: stringValue(tags.trace_id),
79
+ sentry_breadcrumbs: objectValue(breadcrumbs)?.data ?? null,
80
+ },
81
+ };
82
+ }
83
+ function sentryIntegration(project) {
84
+ const integrations = project.integrations ?? {};
85
+ const telemetry = objectValue(integrations.telemetry);
86
+ if (!telemetry || telemetry.provider === "sentry") {
87
+ return ((integrations.sentry ?? telemetry) ?? {});
88
+ }
89
+ return {};
90
+ }
91
+ function tagsToObject(value) {
92
+ if (!value)
93
+ return {};
94
+ if (!Array.isArray(value) && typeof value === "object")
95
+ return value;
96
+ if (!Array.isArray(value))
97
+ return {};
98
+ return Object.fromEntries(value
99
+ .map((entry) => {
100
+ const object = objectValue(entry);
101
+ const key = stringValue(object?.key) ?? stringValue(object?.name);
102
+ if (!key)
103
+ return null;
104
+ return [key, object?.value];
105
+ })
106
+ .filter((entry) => entry != null));
107
+ }
108
+ function objectValue(value) {
109
+ if (!value || typeof value !== "object" || Array.isArray(value))
110
+ return undefined;
111
+ return value;
112
+ }
113
+ function stringValue(value) {
114
+ return typeof value === "string" && value.length > 0 ? value : undefined;
115
+ }
116
+ function stringSetting(value) {
117
+ return stringValue(value);
118
+ }
119
+ function constantTimeEqual(left, right) {
120
+ const leftBuffer = Buffer.from(left, "hex");
121
+ const rightBuffer = Buffer.from(right, "hex");
122
+ if (leftBuffer.length !== rightBuffer.length)
123
+ return false;
124
+ return crypto.timingSafeEqual(leftBuffer, rightBuffer);
125
+ }
@@ -0,0 +1,10 @@
1
+ import type { Server } from "node:http";
2
+ import { JsonStore } from "./store.js";
3
+ type NoyaServer = Server & {
4
+ store: JsonStore;
5
+ };
6
+ export declare function createServer({ storePath, seed, }?: {
7
+ storePath?: string;
8
+ seed?: boolean;
9
+ }): NoyaServer;
10
+ export {};
@@ -0,0 +1,498 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { verifySentryWebhookSignature } from "./sentry.js";
6
+ import { JsonStore } from "./store.js";
7
+ import { publicState } from "./store.js";
8
+ import { approveFix, completeJob, failJob, heartbeatJob, nextRunnerJob, receiveReport, receiveSentryWebhook, upsertProject, } from "./orchestrator.js";
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const compiledRoot = path.resolve(__dirname, "..");
11
+ const rootDir = fs.existsSync(path.join(compiledRoot, "public"))
12
+ ? compiledRoot
13
+ : path.resolve(compiledRoot, "..");
14
+ export function createServer({ storePath = ".noya/state.json", seed = true, } = {}) {
15
+ const store = new JsonStore(storePath);
16
+ if (seed && !store.state.projects["agency-platform"]) {
17
+ upsertProject(store, defaultAgencyProject());
18
+ }
19
+ const server = http.createServer(async (req, res) => {
20
+ try {
21
+ await route(req, res, store);
22
+ }
23
+ catch (error) {
24
+ const status = typeof error === "object" && error && "status" in error ? Number(error.status) : 500;
25
+ sendJson(res, status, {
26
+ error: error instanceof Error ? error.message : String(error),
27
+ });
28
+ }
29
+ });
30
+ const noyaServer = server;
31
+ noyaServer.store = store;
32
+ return noyaServer;
33
+ }
34
+ async function route(req, res, store) {
35
+ const url = new URL(req.url ?? "/", "http://localhost");
36
+ if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/api/healthz")) {
37
+ return sendJson(res, 200, { ok: true });
38
+ }
39
+ if (req.method === "GET" && url.pathname === "/api/dashboard/state") {
40
+ requireAdminAuth(req);
41
+ return sendJson(res, 200, dashboardState(store));
42
+ }
43
+ if (req.method === "POST" && url.pathname === "/api/projects") {
44
+ requireAdminAuth(req);
45
+ return sendJson(res, 200, upsertProject(store, (await readJson(req))));
46
+ }
47
+ const retention = url.pathname.match(/^\/api\/projects\/([^/]+)\/retention\/cleanup$/);
48
+ if (req.method === "POST" && retention) {
49
+ requireAdminAuth(req);
50
+ return sendJson(res, 200, cleanupProjectRetention(store, decodeURIComponent(retention[1])));
51
+ }
52
+ if (req.method === "POST" && url.pathname === "/api/reports") {
53
+ return sendJson(res, 202, await receiveReport(store, (await readJson(req))));
54
+ }
55
+ if (req.method === "POST" && url.pathname === "/api/webhooks/sentry") {
56
+ const rawBody = await readRaw(req);
57
+ const payload = parseJson(rawBody);
58
+ verifyWebhookAuth(store, payload, rawBody, req);
59
+ return sendJson(res, 202, await receiveSentryWebhook(store, payload));
60
+ }
61
+ if (req.method === "POST" && url.pathname === "/api/webhooks/telemetry") {
62
+ const rawBody = await readRaw(req);
63
+ const payload = parseJson(rawBody);
64
+ verifyWebhookAuth(store, payload, rawBody, req);
65
+ return sendJson(res, 202, await receiveSentryWebhook(store, payload));
66
+ }
67
+ if (req.method === "POST" && url.pathname === "/api/dev/sample/agency-platform-report") {
68
+ requireAdminAuth(req);
69
+ return sendJson(res, 202, await receiveReport(store, sampleAgencyReport()));
70
+ }
71
+ if (req.method === "GET" && url.pathname === "/runner/jobs/next") {
72
+ requireRunnerAuth(store, url.searchParams.get("project_key"), req);
73
+ const capabilities = parseJsonParam(url.searchParams.get("capabilities")) ?? {
74
+ collect: { enabled: true },
75
+ fix: { enabled: true },
76
+ };
77
+ const job = nextRunnerJob(store, {
78
+ projectKey: url.searchParams.get("project_key"),
79
+ runnerId: url.searchParams.get("runner_id") ?? "runner-local",
80
+ capabilities: capabilities,
81
+ });
82
+ return sendJson(res, 200, { job });
83
+ }
84
+ const heartbeat = url.pathname.match(/^\/runner\/jobs\/([^/]+)\/heartbeat$/);
85
+ if (req.method === "POST" && heartbeat) {
86
+ requireRunnerAuth(store, projectKeyForJob(store, heartbeat[1]), req);
87
+ return sendJson(res, 200, heartbeatJob(store, heartbeat[1]));
88
+ }
89
+ const complete = url.pathname.match(/^\/runner\/jobs\/([^/]+)\/complete$/);
90
+ if (req.method === "POST" && complete) {
91
+ requireRunnerAuth(store, projectKeyForJob(store, complete[1]), req);
92
+ return sendJson(res, 200, await completeJob(store, complete[1], await readJson(req)));
93
+ }
94
+ const fail = url.pathname.match(/^\/runner\/jobs\/([^/]+)\/fail$/);
95
+ if (req.method === "POST" && fail) {
96
+ requireRunnerAuth(store, projectKeyForJob(store, fail[1]), req);
97
+ return sendJson(res, 200, failJob(store, fail[1], await readJson(req)));
98
+ }
99
+ const approve = url.pathname.match(/^\/api\/issues\/([^/]+)\/approve-fix$/);
100
+ if (req.method === "POST" && approve) {
101
+ requireAdminAuth(req);
102
+ return sendJson(res, 202, approveFix(store, approve[1]));
103
+ }
104
+ const artifact = url.pathname.match(/^\/artifacts\/(issues|prs)\/([^/]+)$/);
105
+ if (req.method === "GET" && artifact) {
106
+ requireAdminAuth(req);
107
+ return serveArtifact(res, store, artifact[1], artifact[2]);
108
+ }
109
+ if (req.method === "GET")
110
+ return serveStatic(res, url.pathname);
111
+ sendJson(res, 404, { error: "not found" });
112
+ }
113
+ function serveArtifact(res, store, kind, id) {
114
+ const artifact = artifactPathFor(store, kind, id);
115
+ const artifactPath = artifact?.path;
116
+ if (!artifactPath)
117
+ return sendJson(res, 404, { error: "artifact not found" });
118
+ const resolved = path.resolve(artifactPath);
119
+ const artifactRoot = path.resolve(artifact.project.artifact_dir);
120
+ if (!isPathInside(resolved, artifactRoot)) {
121
+ return sendJson(res, 403, { error: "artifact path outside project artifact_dir" });
122
+ }
123
+ if (!fs.existsSync(resolved) || fs.statSync(resolved).isDirectory()) {
124
+ return sendJson(res, 404, { error: "artifact not found" });
125
+ }
126
+ res.writeHead(200, {
127
+ "content-type": "text/markdown; charset=utf-8",
128
+ "x-content-type-options": "nosniff",
129
+ });
130
+ fs.createReadStream(resolved).pipe(res);
131
+ }
132
+ function dashboardState(store) {
133
+ return {
134
+ ...publicState(store.state),
135
+ readiness: Object.values(store.state.projects).map((project) => ({
136
+ project_key: project.project_key,
137
+ checks: readinessChecks(project),
138
+ })),
139
+ };
140
+ }
141
+ function readinessChecks(project) {
142
+ return [
143
+ artifactDirCheck(project),
144
+ retentionCheck(project),
145
+ runnerRepoCheck(project),
146
+ envCheck("Runner token", project.runner?.token_env),
147
+ providerCheck(project),
148
+ envCheck("Telemetry lookup token", stringAt(telemetryIntegration(project), "token_env")),
149
+ envCheck("Telemetry webhook secret", stringAt(telemetryIntegration(project), "webhook_secret_env")),
150
+ ];
151
+ }
152
+ function retentionCheck(project) {
153
+ if (project.retention_days == null) {
154
+ return { name: "Artifact retention", status: "warn", detail: "not configured" };
155
+ }
156
+ return {
157
+ name: "Artifact retention",
158
+ status: "pass",
159
+ detail: `${project.retention_days} day(s)`,
160
+ };
161
+ }
162
+ function artifactDirCheck(project) {
163
+ try {
164
+ fs.mkdirSync(project.artifact_dir, { recursive: true });
165
+ fs.accessSync(project.artifact_dir, fs.constants.W_OK);
166
+ return { name: "Artifact directory", status: "pass", detail: project.artifact_dir };
167
+ }
168
+ catch (error) {
169
+ return {
170
+ name: "Artifact directory",
171
+ status: "fail",
172
+ detail: error instanceof Error ? error.message : String(error),
173
+ };
174
+ }
175
+ }
176
+ function runnerRepoCheck(project) {
177
+ const repoPath = project.runner?.repo_path;
178
+ if (!repoPath)
179
+ return { name: "Runner repo", status: "warn", detail: "No runner repo_path configured" };
180
+ if (process.env.NOYA_VALIDATE_RUNNER_REPO === "false") {
181
+ return { name: "Runner repo", status: "pass", detail: `configured for runner: ${repoPath}` };
182
+ }
183
+ const packageJson = path.join(repoPath, "package.json");
184
+ const gitDir = path.join(repoPath, ".git");
185
+ if (!fs.existsSync(repoPath))
186
+ return { name: "Runner repo", status: "fail", detail: repoPath };
187
+ if (!fs.existsSync(packageJson) && !fs.existsSync(gitDir)) {
188
+ return { name: "Runner repo", status: "warn", detail: `${repoPath} exists without package.json or .git` };
189
+ }
190
+ return { name: "Runner repo", status: "pass", detail: repoPath };
191
+ }
192
+ function providerCheck(project) {
193
+ if (project.issue_provider === "local")
194
+ return { name: "Issue provider", status: "pass", detail: "local" };
195
+ if (project.issue_provider === "github") {
196
+ const github = project.integrations?.github;
197
+ const missing = ["owner", "repo", "token_env"].filter((key) => !stringAt(github, key));
198
+ if (missing.length > 0) {
199
+ return { name: "Issue provider", status: "fail", detail: `github missing ${missing.join(", ")}` };
200
+ }
201
+ return envCheck("Issue provider", stringAt(github, "token_env"), "github");
202
+ }
203
+ if (project.issue_provider === "jira") {
204
+ const jira = project.integrations?.jira;
205
+ const missing = ["api_base", "project_key", "email", "token_env"].filter((key) => !stringAt(jira, key));
206
+ if (missing.length > 0) {
207
+ return { name: "Issue provider", status: "fail", detail: `jira missing ${missing.join(", ")}` };
208
+ }
209
+ return envCheck("Issue provider", stringAt(jira, "token_env"), "jira");
210
+ }
211
+ return { name: "Issue provider", status: "fail", detail: `unsupported provider ${project.issue_provider}` };
212
+ }
213
+ function telemetryIntegration(project) {
214
+ const integrations = project.integrations ?? {};
215
+ const telemetry = integrations.telemetry;
216
+ if (telemetry && typeof telemetry === "object" && !Array.isArray(telemetry)) {
217
+ return telemetry;
218
+ }
219
+ const sentry = integrations.sentry;
220
+ if (sentry && typeof sentry === "object" && !Array.isArray(sentry)) {
221
+ return sentry;
222
+ }
223
+ return undefined;
224
+ }
225
+ function envCheck(name, envName, label = "optional") {
226
+ if (!envName)
227
+ return { name, status: "pass", detail: `${label}: not configured` };
228
+ return process.env[envName]
229
+ ? { name, status: "pass", detail: envName }
230
+ : { name, status: "fail", detail: `${envName} is not set` };
231
+ }
232
+ function stringAt(value, key) {
233
+ if (!value || typeof value !== "object" || Array.isArray(value))
234
+ return undefined;
235
+ const found = value[key];
236
+ return typeof found === "string" && found.length > 0 ? found : undefined;
237
+ }
238
+ function artifactPathFor(store, kind, id) {
239
+ if (kind === "issues") {
240
+ const issue = store.state.issues[id];
241
+ const pathValue = issue?.integration?.path;
242
+ const project = issue ? store.state.projects[issue.project_key] : undefined;
243
+ return project && typeof pathValue === "string" ? { project, path: pathValue } : undefined;
244
+ }
245
+ if (kind === "prs") {
246
+ const issue = Object.values(store.state.issues).find((candidate) => {
247
+ return candidate.fix?.draft_pr && candidate.fix.draft_pr.external_id === id;
248
+ });
249
+ const pathValue = issue?.fix?.draft_pr?.path;
250
+ const project = issue ? store.state.projects[issue.project_key] : undefined;
251
+ return project && typeof pathValue === "string" ? { project, path: pathValue } : undefined;
252
+ }
253
+ return undefined;
254
+ }
255
+ function isPathInside(candidate, root) {
256
+ const relative = path.relative(root, candidate);
257
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
258
+ }
259
+ function cleanupProjectRetention(store, projectKey) {
260
+ const project = store.state.projects[projectKey];
261
+ if (!project)
262
+ throw Object.assign(new Error(`Unknown project: ${projectKey}`), { status: 404 });
263
+ if (project.retention_days == null) {
264
+ return { project_key: project.project_key, retention_days: null, deleted: [] };
265
+ }
266
+ const root = path.resolve(project.artifact_dir);
267
+ const cutoff = Date.now() - project.retention_days * 24 * 60 * 60 * 1000;
268
+ const deleted = [];
269
+ if (fs.existsSync(root)) {
270
+ for (const filePath of listFiles(root)) {
271
+ const resolved = path.resolve(filePath);
272
+ if (!isPathInside(resolved, root))
273
+ continue;
274
+ const stat = fs.statSync(resolved);
275
+ if (stat.mtimeMs > cutoff)
276
+ continue;
277
+ fs.rmSync(resolved, { force: true });
278
+ deleted.push(resolved);
279
+ }
280
+ pruneEmptyDirs(root, root);
281
+ }
282
+ store.audit("artifacts.retention.cleaned", {
283
+ project_key: project.project_key,
284
+ retention_days: project.retention_days,
285
+ deleted_count: deleted.length,
286
+ });
287
+ return { project_key: project.project_key, retention_days: project.retention_days, deleted };
288
+ }
289
+ function listFiles(root) {
290
+ const output = [];
291
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
292
+ const fullPath = path.join(root, entry.name);
293
+ if (entry.isDirectory())
294
+ output.push(...listFiles(fullPath));
295
+ else if (entry.isFile())
296
+ output.push(fullPath);
297
+ }
298
+ return output;
299
+ }
300
+ function pruneEmptyDirs(root, preserveRoot) {
301
+ if (!fs.existsSync(root))
302
+ return;
303
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
304
+ if (!entry.isDirectory())
305
+ continue;
306
+ pruneEmptyDirs(path.join(root, entry.name), preserveRoot);
307
+ }
308
+ if (root === preserveRoot)
309
+ return;
310
+ try {
311
+ if (fs.readdirSync(root).length === 0)
312
+ fs.rmdirSync(root);
313
+ }
314
+ catch {
315
+ // Directory may have been removed or refilled by another process.
316
+ }
317
+ }
318
+ function serveStatic(res, pathname) {
319
+ const filePath = pathname === "/" ? "public/index.html" : `public${pathname}`;
320
+ const resolved = path.resolve(rootDir, filePath);
321
+ if (!resolved.startsWith(path.resolve(rootDir, "public"))) {
322
+ return sendJson(res, 403, { error: "forbidden" });
323
+ }
324
+ if (!fs.existsSync(resolved) || fs.statSync(resolved).isDirectory()) {
325
+ return sendJson(res, 404, { error: "not found" });
326
+ }
327
+ const ext = path.extname(resolved);
328
+ const type = ext === ".html" ? "text/html" : ext === ".css" ? "text/css" : "application/javascript";
329
+ res.writeHead(200, { "content-type": `${type}; charset=utf-8` });
330
+ fs.createReadStream(resolved).pipe(res);
331
+ }
332
+ function sendJson(res, status, payload) {
333
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
334
+ res.end(JSON.stringify(payload, null, 2));
335
+ }
336
+ async function readJson(req) {
337
+ const raw = await readRaw(req);
338
+ if (!raw)
339
+ return {};
340
+ return parseJson(raw);
341
+ }
342
+ function parseJson(raw) {
343
+ try {
344
+ return JSON.parse(raw);
345
+ }
346
+ catch {
347
+ throw Object.assign(new Error("Invalid JSON body"), { status: 400 });
348
+ }
349
+ }
350
+ async function readRaw(req) {
351
+ const chunks = [];
352
+ for await (const chunk of req)
353
+ chunks.push(chunk);
354
+ if (chunks.length === 0)
355
+ return "";
356
+ return Buffer.concat(chunks).toString("utf8");
357
+ }
358
+ function parseJsonParam(value) {
359
+ if (!value)
360
+ return null;
361
+ try {
362
+ return JSON.parse(value);
363
+ }
364
+ catch {
365
+ return null;
366
+ }
367
+ }
368
+ function projectKeyForJob(store, jobId) {
369
+ return store.state.jobs[jobId]?.project_key ?? null;
370
+ }
371
+ function requireRunnerAuth(store, projectKey, req) {
372
+ if (!projectKey) {
373
+ throw Object.assign(new Error("Missing runner project"), { status: 404 });
374
+ }
375
+ const project = store.state.projects[projectKey];
376
+ if (!project) {
377
+ throw Object.assign(new Error(`Unknown project: ${projectKey}`), { status: 404 });
378
+ }
379
+ const tokenEnv = project.runner?.token_env;
380
+ if (!tokenEnv)
381
+ return;
382
+ const expected = process.env[tokenEnv];
383
+ if (!expected) {
384
+ throw Object.assign(new Error(`Missing runner token env var: ${tokenEnv}`), { status: 500 });
385
+ }
386
+ if (req.headers.authorization !== `Bearer ${expected}`) {
387
+ throw Object.assign(new Error("Runner authorization failed"), { status: 401 });
388
+ }
389
+ }
390
+ function requireAdminAuth(req) {
391
+ const expected = process.env.NOYA_ADMIN_TOKEN;
392
+ if (!expected)
393
+ return;
394
+ if (req.headers.authorization !== `Bearer ${expected}`) {
395
+ throw Object.assign(new Error("Admin authorization failed"), { status: 401 });
396
+ }
397
+ }
398
+ function verifyWebhookAuth(store, payload, rawBody, req) {
399
+ const projectKey = webhookProjectKey(payload);
400
+ const project = projectKey ? store.state.projects[projectKey] : undefined;
401
+ if (!project)
402
+ return;
403
+ const signature = headerValue(req.headers["sentry-hook-signature"]) ??
404
+ headerValue(req.headers["x-noya-signature"]) ??
405
+ headerValue(req.headers["x-sentry-signature"]);
406
+ verifySentryWebhookSignature({ project, rawBody, signature });
407
+ }
408
+ function webhookProjectKey(payload) {
409
+ const direct = payload.project_key ?? payload.projectKey;
410
+ if (typeof direct === "string")
411
+ return direct;
412
+ const project = payload.project;
413
+ if (project && typeof project === "object" && !Array.isArray(project)) {
414
+ const slug = project.slug;
415
+ if (typeof slug === "string")
416
+ return slug;
417
+ }
418
+ return undefined;
419
+ }
420
+ function headerValue(value) {
421
+ if (Array.isArray(value))
422
+ return value[0];
423
+ return value;
424
+ }
425
+ function defaultAgencyProject() {
426
+ return {
427
+ project_key: "agency-platform",
428
+ name: "Agency Platform",
429
+ environment: "local",
430
+ issue_provider: "local",
431
+ artifact_dir: defaultArtifactDir("agency-platform"),
432
+ retention_days: 30,
433
+ created_at: new Date().toISOString(),
434
+ telemetry: {
435
+ provider: "sentry",
436
+ org_slug: "yanolja",
437
+ project_slug: "agency-user-web",
438
+ },
439
+ runner: {
440
+ enabled: true,
441
+ repo_path: "/workspace/agency-platform",
442
+ collect: { enabled: true, auto: true },
443
+ fix: { enabled: true, auto: false, require_approval: true },
444
+ sources: [
445
+ { name: "api_logs_by_request", type: "fixture_logs", when: { has: "request_id" } },
446
+ { name: "trip_snapshot", type: "fixture_domain", when: { has: "trip_id" } },
447
+ ],
448
+ },
449
+ };
450
+ }
451
+ function sampleAgencyReport() {
452
+ return {
453
+ report_id: `agency-sample-${Date.now()}`,
454
+ project_key: "agency-platform",
455
+ environment: "local",
456
+ release: "agency-platform-local",
457
+ route: "/trips/TRIP-4242/proposal",
458
+ message: "Reservation button stays disabled after selecting booker nationality",
459
+ impact: "CS operator cannot complete booking from the proposal flow.",
460
+ request_id: "req_agency_4242",
461
+ telemetry: {
462
+ provider: "sentry",
463
+ event_id: "sentry-event-agency-4242",
464
+ replay_id: "replay-agency-4242",
465
+ feedback_id: "feedback-agency-4242",
466
+ },
467
+ context: {
468
+ tenant_id: "tenant_demo",
469
+ user_id: "user_demo",
470
+ trip_id: "TRIP-4242",
471
+ booking_id: "BOOK-4242",
472
+ customer_email: "traveler@example.com",
473
+ },
474
+ reproduction_steps: [
475
+ "Open the proposal detail page.",
476
+ "Select booker nationality.",
477
+ "Try to click Reserve.",
478
+ ],
479
+ expected_behavior: "Reserve becomes available once required booker fields are complete.",
480
+ actual_behavior: "Reserve remains disabled.",
481
+ suspected_area: "agency_user_web proposal reservation flow",
482
+ suspected_files: [
483
+ "product/agency_user_web/src/app/**/proposal*",
484
+ "product/agency_user_e2e/booker-nationality-reserve-voucher.spec.ts",
485
+ ],
486
+ };
487
+ }
488
+ if (import.meta.url === `file://${process.argv[1]}`) {
489
+ const port = Number(process.env.NOYA_PORT ?? process.env.PORT ?? 3456);
490
+ const server = createServer({ storePath: process.env.NOYA_STATE_PATH ?? ".noya/state.json" });
491
+ server.listen(port, () => {
492
+ console.log(`Noya listening on http://localhost:${port}`);
493
+ });
494
+ }
495
+ function defaultArtifactDir(projectKey) {
496
+ const root = process.env.NOYA_ARTIFACT_ROOT;
497
+ return root ? `${root.replace(/\/$/, "")}/${projectKey}` : `.noya/artifacts/${projectKey}`;
498
+ }
@@ -0,0 +1,18 @@
1
+ import type { AuditEntry, JsonObject, State } from "./types.js";
2
+ export declare function createInitialState(): State;
3
+ export declare class JsonStore {
4
+ filePath?: string;
5
+ state: State;
6
+ constructor(filePath?: string);
7
+ load(): void;
8
+ save(): void;
9
+ id(prefix: string): string;
10
+ audit(action: string, details?: JsonObject): AuditEntry;
11
+ }
12
+ export declare function publicState(state: State): {
13
+ projects: import("./types.js").Project[];
14
+ reports: import("./types.js").Report[];
15
+ issues: import("./types.js").Issue[];
16
+ jobs: import("./types.js").Job[];
17
+ audit: AuditEntry[];
18
+ };