@ainyc/canonry 1.25.3 → 1.26.2
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/assets/assets/{index-DRL4RV8N.js → index-CE8TwvzO.js} +82 -82
- package/assets/assets/{index-CoA39nr6.css → index-D786SQZN.css} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-G5TMCVS4.js → chunk-NVCAUQ33.js} +304 -29
- package/dist/cli.js +150 -44
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/package.json +7 -7
package/assets/index.html
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
<link rel="icon" type="image/png" sizes="32x32" href="./favicon-32.png" />
|
|
13
13
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
|
14
14
|
<title>Canonry</title>
|
|
15
|
-
<script type="module" crossorigin src="./assets/index-
|
|
16
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
15
|
+
<script type="module" crossorigin src="./assets/index-CE8TwvzO.js"></script>
|
|
16
|
+
<link rel="stylesheet" crossorigin href="./assets/index-D786SQZN.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
19
19
|
<div id="root"></div>
|
|
@@ -72,6 +72,10 @@ Do not write config.yaml by hand; use "canonry init", "canonry settings", or "ca
|
|
|
72
72
|
} catch {
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
if ("CANONRY_BASE_PATH" in process.env) {
|
|
76
|
+
const val = process.env.CANONRY_BASE_PATH.trim();
|
|
77
|
+
parsed.basePath = val || void 0;
|
|
78
|
+
}
|
|
75
79
|
if (parsed.basePath) {
|
|
76
80
|
const normalizedBase = "/" + parsed.basePath.replace(/^\/|\/$/g, "");
|
|
77
81
|
try {
|
|
@@ -174,6 +178,7 @@ import crypto21 from "crypto";
|
|
|
174
178
|
import fs5 from "fs";
|
|
175
179
|
import path6 from "path";
|
|
176
180
|
import { fileURLToPath } from "url";
|
|
181
|
+
import { eq as eq22 } from "drizzle-orm";
|
|
177
182
|
import Fastify from "fastify";
|
|
178
183
|
|
|
179
184
|
// ../contracts/src/config-schema.ts
|
|
@@ -220,6 +225,8 @@ var notificationDtoSchema = z2.object({
|
|
|
220
225
|
projectId: z2.string(),
|
|
221
226
|
channel: z2.literal("webhook"),
|
|
222
227
|
url: z2.string().url(),
|
|
228
|
+
urlDisplay: z2.string(),
|
|
229
|
+
urlHost: z2.string(),
|
|
223
230
|
events: z2.array(notificationEventSchema),
|
|
224
231
|
enabled: z2.boolean().default(true),
|
|
225
232
|
webhookSecret: z2.string().optional(),
|
|
@@ -1390,29 +1397,60 @@ function shouldSkipAuth(url) {
|
|
|
1390
1397
|
if (SKIP_PATHS.includes(url)) return true;
|
|
1391
1398
|
if (url.endsWith("/openapi.json")) return true;
|
|
1392
1399
|
if (url.includes("/google/callback")) return true;
|
|
1400
|
+
if (url.endsWith("/session") || url.endsWith("/session/setup")) return true;
|
|
1393
1401
|
return false;
|
|
1394
1402
|
}
|
|
1395
|
-
|
|
1403
|
+
function parseCookies(header) {
|
|
1404
|
+
if (!header) return {};
|
|
1405
|
+
return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
|
|
1406
|
+
const eqIdx = part.indexOf("=");
|
|
1407
|
+
if (eqIdx <= 0) return cookies;
|
|
1408
|
+
const name = part.slice(0, eqIdx).trim();
|
|
1409
|
+
const value = part.slice(eqIdx + 1).trim();
|
|
1410
|
+
if (!name) return cookies;
|
|
1411
|
+
try {
|
|
1412
|
+
cookies[name] = decodeURIComponent(value);
|
|
1413
|
+
} catch {
|
|
1414
|
+
cookies[name] = value;
|
|
1415
|
+
}
|
|
1416
|
+
return cookies;
|
|
1417
|
+
}, {});
|
|
1418
|
+
}
|
|
1419
|
+
async function authPlugin(app, opts = {}) {
|
|
1396
1420
|
app.addHook("onRequest", async (request, reply) => {
|
|
1397
1421
|
const url = request.url.split("?")[0];
|
|
1398
1422
|
if (shouldSkipAuth(url)) return;
|
|
1399
1423
|
const header = request.headers.authorization;
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1424
|
+
let key;
|
|
1425
|
+
if (header) {
|
|
1426
|
+
const parts = header.split(" ");
|
|
1427
|
+
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
|
1428
|
+
const err = authRequired();
|
|
1429
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
1430
|
+
}
|
|
1431
|
+
const token = parts[1];
|
|
1432
|
+
const hash = hashKey(token);
|
|
1433
|
+
key = app.db.select().from(apiKeys).where(eq(apiKeys.keyHash, hash)).get();
|
|
1434
|
+
if (!key || key.revokedAt) {
|
|
1435
|
+
const err = authInvalid();
|
|
1436
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
1437
|
+
}
|
|
1438
|
+
} else if (opts.resolveSessionApiKeyId && opts.sessionCookieName) {
|
|
1439
|
+
const sessionId = parseCookies(request.headers.cookie)[opts.sessionCookieName];
|
|
1440
|
+
if (sessionId) {
|
|
1441
|
+
const apiKeyId = await opts.resolveSessionApiKeyId(sessionId);
|
|
1442
|
+
if (apiKeyId) {
|
|
1443
|
+
key = app.db.select().from(apiKeys).where(eq(apiKeys.id, apiKeyId)).get();
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (!key || key.revokedAt) {
|
|
1447
|
+
const err = authRequired();
|
|
1448
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
1449
|
+
}
|
|
1450
|
+
} else {
|
|
1406
1451
|
const err = authRequired();
|
|
1407
1452
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
1408
1453
|
}
|
|
1409
|
-
const token = parts[1];
|
|
1410
|
-
const hash = hashKey(token);
|
|
1411
|
-
const key = app.db.select().from(apiKeys).where(eq(apiKeys.keyHash, hash)).get();
|
|
1412
|
-
if (!key || key.revokedAt) {
|
|
1413
|
-
const err = authInvalid();
|
|
1414
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1415
|
-
}
|
|
1416
1454
|
app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(apiKeys.id, key.id)).run();
|
|
1417
1455
|
});
|
|
1418
1456
|
}
|
|
@@ -2801,6 +2839,48 @@ async function applyRoutes(app, opts) {
|
|
|
2801
2839
|
|
|
2802
2840
|
// ../api-routes/src/history.ts
|
|
2803
2841
|
import { eq as eq9, desc as desc2, inArray } from "drizzle-orm";
|
|
2842
|
+
|
|
2843
|
+
// ../api-routes/src/notification-redaction.ts
|
|
2844
|
+
var REDACTED_URL = {
|
|
2845
|
+
url: "https://redacted.invalid/redacted",
|
|
2846
|
+
urlDisplay: "invalid-url/redacted",
|
|
2847
|
+
urlHost: "invalid-url"
|
|
2848
|
+
};
|
|
2849
|
+
function redactNotificationUrl(rawUrl) {
|
|
2850
|
+
try {
|
|
2851
|
+
const parsed = new URL(rawUrl);
|
|
2852
|
+
const host = parsed.host || parsed.hostname;
|
|
2853
|
+
return {
|
|
2854
|
+
url: `${parsed.protocol}//${host}/redacted`,
|
|
2855
|
+
urlDisplay: `${host}/redacted`,
|
|
2856
|
+
urlHost: host
|
|
2857
|
+
};
|
|
2858
|
+
} catch {
|
|
2859
|
+
return REDACTED_URL;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
function redactNotificationDiff(value) {
|
|
2863
|
+
if (Array.isArray(value)) {
|
|
2864
|
+
return value.map(redactNotificationDiff);
|
|
2865
|
+
}
|
|
2866
|
+
if (!value || typeof value !== "object") {
|
|
2867
|
+
return value;
|
|
2868
|
+
}
|
|
2869
|
+
const output = {};
|
|
2870
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
2871
|
+
if (key === "url" && typeof entry === "string") {
|
|
2872
|
+
const redacted = redactNotificationUrl(entry);
|
|
2873
|
+
output.url = redacted.url;
|
|
2874
|
+
output.urlDisplay = redacted.urlDisplay;
|
|
2875
|
+
output.urlHost = redacted.urlHost;
|
|
2876
|
+
continue;
|
|
2877
|
+
}
|
|
2878
|
+
output[key] = redactNotificationDiff(entry);
|
|
2879
|
+
}
|
|
2880
|
+
return output;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
// ../api-routes/src/history.ts
|
|
2804
2884
|
async function historyRoutes(app) {
|
|
2805
2885
|
app.get("/projects/:name/history", async (request, reply) => {
|
|
2806
2886
|
const project = resolveProjectSafe4(app, request.params.name, reply);
|
|
@@ -2990,7 +3070,7 @@ function formatAuditEntry(row) {
|
|
|
2990
3070
|
action: row.action,
|
|
2991
3071
|
entityType: row.entityType,
|
|
2992
3072
|
entityId: row.entityId,
|
|
2993
|
-
diff: row.diff ? tryParseJson2(row.diff, null) : null,
|
|
3073
|
+
diff: row.diff ? row.entityType === "notification" ? redactNotificationDiff(tryParseJson2(row.diff, null)) : tryParseJson2(row.diff, null) : null,
|
|
2994
3074
|
createdAt: row.createdAt
|
|
2995
3075
|
};
|
|
2996
3076
|
}
|
|
@@ -5285,7 +5365,7 @@ async function notificationRoutes(app) {
|
|
|
5285
5365
|
action: "notification.created",
|
|
5286
5366
|
entityType: "notification",
|
|
5287
5367
|
entityId: id,
|
|
5288
|
-
diff: { channel, url, events }
|
|
5368
|
+
diff: { channel, ...redactNotificationUrl(url), events }
|
|
5289
5369
|
});
|
|
5290
5370
|
return reply.status(201).send({
|
|
5291
5371
|
...formatNotification(app.db.select().from(notifications).where(eq12(notifications.id, id)).get()),
|
|
@@ -5343,9 +5423,10 @@ async function notificationRoutes(app) {
|
|
|
5343
5423
|
],
|
|
5344
5424
|
dashboardUrl: `/projects/${project.name}`
|
|
5345
5425
|
};
|
|
5346
|
-
|
|
5426
|
+
const targetLabel = redactNotificationUrl(config.url).urlDisplay;
|
|
5427
|
+
request.log.info(`[Notification test] POST ${targetLabel}`);
|
|
5347
5428
|
const { status, error } = await deliverWebhook(urlCheck.target, payload, notification.webhookSecret ?? null);
|
|
5348
|
-
request.log.info(`[Notification test] Response: HTTP ${status} from ${
|
|
5429
|
+
request.log.info(`[Notification test] Response: HTTP ${status} from ${targetLabel}`);
|
|
5349
5430
|
writeAuditLog(app.db, {
|
|
5350
5431
|
projectId: project.id,
|
|
5351
5432
|
actor: "api",
|
|
@@ -5362,11 +5443,14 @@ async function notificationRoutes(app) {
|
|
|
5362
5443
|
}
|
|
5363
5444
|
function formatNotification(row) {
|
|
5364
5445
|
const config = JSON.parse(row.config);
|
|
5446
|
+
const redacted = redactNotificationUrl(config.url);
|
|
5365
5447
|
return {
|
|
5366
5448
|
id: row.id,
|
|
5367
5449
|
projectId: row.projectId,
|
|
5368
5450
|
channel: "webhook",
|
|
5369
|
-
url:
|
|
5451
|
+
url: redacted.url,
|
|
5452
|
+
urlDisplay: redacted.urlDisplay,
|
|
5453
|
+
urlHost: redacted.urlHost,
|
|
5370
5454
|
events: config.events,
|
|
5371
5455
|
enabled: row.enabled === 1,
|
|
5372
5456
|
createdAt: row.createdAt,
|
|
@@ -7472,10 +7556,13 @@ async function apiRoutes(app, opts) {
|
|
|
7472
7556
|
}
|
|
7473
7557
|
});
|
|
7474
7558
|
});
|
|
7475
|
-
if (!opts.skipAuth) {
|
|
7476
|
-
await app.register(authPlugin);
|
|
7477
|
-
}
|
|
7478
7559
|
await app.register(async (api) => {
|
|
7560
|
+
if (!opts.skipAuth) {
|
|
7561
|
+
await authPlugin(api, {
|
|
7562
|
+
sessionCookieName: opts.sessionCookieName,
|
|
7563
|
+
resolveSessionApiKeyId: opts.resolveSessionApiKeyId
|
|
7564
|
+
});
|
|
7565
|
+
}
|
|
7479
7566
|
await api.register(openApiRoutes, opts.openApiInfo ?? {});
|
|
7480
7567
|
await api.register(projectRoutes, {
|
|
7481
7568
|
onProjectDeleted: opts.onProjectDeleted,
|
|
@@ -10534,25 +10621,26 @@ var Notifier = class {
|
|
|
10534
10621
|
return transitions;
|
|
10535
10622
|
}
|
|
10536
10623
|
async sendWebhook(url, payload, notificationId, projectId, webhookSecret) {
|
|
10624
|
+
const targetLabel = redactNotificationUrl(url).urlDisplay;
|
|
10537
10625
|
const targetCheck = await resolveWebhookTarget(url);
|
|
10538
10626
|
if (!targetCheck.ok) {
|
|
10539
|
-
log5.error("webhook.ssrf-blocked", { url, reason: targetCheck.message });
|
|
10627
|
+
log5.error("webhook.ssrf-blocked", { url: targetLabel, reason: targetCheck.message });
|
|
10540
10628
|
this.logDelivery(projectId, notificationId, payload.event, "failed", `SSRF: ${targetCheck.message}`);
|
|
10541
10629
|
return;
|
|
10542
10630
|
}
|
|
10543
|
-
log5.info("webhook.send", { event: payload.event, url });
|
|
10631
|
+
log5.info("webhook.send", { event: payload.event, url: targetLabel });
|
|
10544
10632
|
const maxRetries = 3;
|
|
10545
10633
|
const delays = [1e3, 4e3, 16e3];
|
|
10546
10634
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
10547
10635
|
try {
|
|
10548
10636
|
const response = await deliverWebhook(targetCheck.target, payload, webhookSecret);
|
|
10549
10637
|
if (response.status >= 200 && response.status < 300) {
|
|
10550
|
-
log5.info("webhook.delivered", { event: payload.event, url, httpStatus: response.status });
|
|
10638
|
+
log5.info("webhook.delivered", { event: payload.event, url: targetLabel, httpStatus: response.status });
|
|
10551
10639
|
this.logDelivery(projectId, notificationId, payload.event, "sent", null);
|
|
10552
10640
|
return;
|
|
10553
10641
|
}
|
|
10554
10642
|
const errorDetail = response.error ?? `HTTP ${response.status}`;
|
|
10555
|
-
log5.warn("webhook.attempt-failed", { event: payload.event, url, attempt: attempt + 1, maxRetries, httpStatus: response.status, error: errorDetail });
|
|
10643
|
+
log5.warn("webhook.attempt-failed", { event: payload.event, url: targetLabel, attempt: attempt + 1, maxRetries, httpStatus: response.status, error: errorDetail });
|
|
10556
10644
|
if (attempt === maxRetries - 1) {
|
|
10557
10645
|
this.logDelivery(projectId, notificationId, payload.event, "failed", errorDetail);
|
|
10558
10646
|
}
|
|
@@ -10560,7 +10648,7 @@ var Notifier = class {
|
|
|
10560
10648
|
const errorDetail = err instanceof Error ? err.message : String(err);
|
|
10561
10649
|
if (attempt === maxRetries - 1) {
|
|
10562
10650
|
this.logDelivery(projectId, notificationId, payload.event, "failed", errorDetail);
|
|
10563
|
-
log5.error("webhook.exhausted", { event: payload.event, url, maxRetries, error: errorDetail });
|
|
10651
|
+
log5.error("webhook.exhausted", { event: payload.event, url: targetLabel, maxRetries, error: errorDetail });
|
|
10564
10652
|
}
|
|
10565
10653
|
}
|
|
10566
10654
|
if (attempt < maxRetries - 1) {
|
|
@@ -10702,6 +10790,8 @@ var DEFAULT_QUOTA = {
|
|
|
10702
10790
|
maxRequestsPerMinute: 10,
|
|
10703
10791
|
maxRequestsPerDay: 1e3
|
|
10704
10792
|
};
|
|
10793
|
+
var SESSION_COOKIE_NAME = "canonry_session";
|
|
10794
|
+
var SESSION_TTL_MS = 12 * 60 * 60 * 1e3;
|
|
10705
10795
|
var API_ADAPTERS = [
|
|
10706
10796
|
geminiAdapter,
|
|
10707
10797
|
openaiAdapter,
|
|
@@ -10724,6 +10814,42 @@ function summarizeProviderConfig(provider, config) {
|
|
|
10724
10814
|
quota: { ...config?.quota ?? DEFAULT_QUOTA }
|
|
10725
10815
|
};
|
|
10726
10816
|
}
|
|
10817
|
+
function hashApiKey(key) {
|
|
10818
|
+
return crypto21.createHash("sha256").update(key).digest("hex");
|
|
10819
|
+
}
|
|
10820
|
+
function parseCookies2(header) {
|
|
10821
|
+
if (!header) return {};
|
|
10822
|
+
return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
|
|
10823
|
+
const eqIdx = part.indexOf("=");
|
|
10824
|
+
if (eqIdx <= 0) return cookies;
|
|
10825
|
+
const name = part.slice(0, eqIdx).trim();
|
|
10826
|
+
const value = part.slice(eqIdx + 1).trim();
|
|
10827
|
+
if (!name) return cookies;
|
|
10828
|
+
try {
|
|
10829
|
+
cookies[name] = decodeURIComponent(value);
|
|
10830
|
+
} catch {
|
|
10831
|
+
cookies[name] = value;
|
|
10832
|
+
}
|
|
10833
|
+
return cookies;
|
|
10834
|
+
}, {});
|
|
10835
|
+
}
|
|
10836
|
+
function serializeSessionCookie(opts) {
|
|
10837
|
+
const parts = [
|
|
10838
|
+
`${opts.name}=${opts.value ? encodeURIComponent(opts.value) : ""}`,
|
|
10839
|
+
`Path=${opts.path}`,
|
|
10840
|
+
"HttpOnly",
|
|
10841
|
+
"SameSite=Lax"
|
|
10842
|
+
];
|
|
10843
|
+
if (opts.value) {
|
|
10844
|
+
parts.push(`Max-Age=${Math.floor(opts.ttlMs / 1e3)}`);
|
|
10845
|
+
} else {
|
|
10846
|
+
parts.push("Max-Age=0");
|
|
10847
|
+
}
|
|
10848
|
+
if (opts.secure) {
|
|
10849
|
+
parts.push("Secure");
|
|
10850
|
+
}
|
|
10851
|
+
return parts.join("; ");
|
|
10852
|
+
}
|
|
10727
10853
|
async function createServer(opts) {
|
|
10728
10854
|
const logger = opts.logger === false ? false : process.stdout.isTTY ? {
|
|
10729
10855
|
transport: {
|
|
@@ -10880,10 +11006,154 @@ async function createServer(opts) {
|
|
|
10880
11006
|
const normalizedBasePath = rawBasePath ? "/" + rawBasePath.replace(/^\//, "").replace(/\/?$/, "/") : void 0;
|
|
10881
11007
|
const basePath = normalizedBasePath === "/" ? void 0 : normalizedBasePath;
|
|
10882
11008
|
const apiPrefix = basePath ? `${basePath}api/v1` : "/api/v1";
|
|
11009
|
+
if (opts.config.apiKey) {
|
|
11010
|
+
const keyHash = hashApiKey(opts.config.apiKey);
|
|
11011
|
+
const existing = opts.db.select().from(apiKeys).where(eq22(apiKeys.keyHash, keyHash)).get();
|
|
11012
|
+
if (!existing) {
|
|
11013
|
+
const prefix = opts.config.apiKey.slice(0, 12);
|
|
11014
|
+
opts.db.insert(apiKeys).values({
|
|
11015
|
+
id: `key_${crypto21.randomBytes(8).toString("hex")}`,
|
|
11016
|
+
name: "default",
|
|
11017
|
+
keyHash,
|
|
11018
|
+
keyPrefix: prefix,
|
|
11019
|
+
scopes: JSON.stringify(["*"]),
|
|
11020
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
11021
|
+
}).run();
|
|
11022
|
+
}
|
|
11023
|
+
}
|
|
11024
|
+
const sessionCookiePath = basePath ?? "/";
|
|
11025
|
+
const sessionCookieSecure = Boolean(
|
|
11026
|
+
opts.config.publicUrl?.startsWith("https://") || opts.config.apiUrl?.startsWith("https://")
|
|
11027
|
+
);
|
|
11028
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
11029
|
+
const pruneExpiredSessions = () => {
|
|
11030
|
+
const now = Date.now();
|
|
11031
|
+
for (const [sessionId, session] of sessions.entries()) {
|
|
11032
|
+
if (session.expiresAt <= now) {
|
|
11033
|
+
sessions.delete(sessionId);
|
|
11034
|
+
}
|
|
11035
|
+
}
|
|
11036
|
+
};
|
|
11037
|
+
const createSession = (apiKeyId) => {
|
|
11038
|
+
pruneExpiredSessions();
|
|
11039
|
+
const sessionId = crypto21.randomBytes(32).toString("hex");
|
|
11040
|
+
sessions.set(sessionId, {
|
|
11041
|
+
apiKeyId,
|
|
11042
|
+
expiresAt: Date.now() + SESSION_TTL_MS
|
|
11043
|
+
});
|
|
11044
|
+
return sessionId;
|
|
11045
|
+
};
|
|
11046
|
+
const resolveSessionApiKeyId = (sessionId) => {
|
|
11047
|
+
pruneExpiredSessions();
|
|
11048
|
+
const session = sessions.get(sessionId);
|
|
11049
|
+
if (!session) return null;
|
|
11050
|
+
if (session.expiresAt <= Date.now()) {
|
|
11051
|
+
sessions.delete(sessionId);
|
|
11052
|
+
return null;
|
|
11053
|
+
}
|
|
11054
|
+
return session.apiKeyId;
|
|
11055
|
+
};
|
|
11056
|
+
const clearSession = (sessionId) => {
|
|
11057
|
+
if (sessionId) {
|
|
11058
|
+
sessions.delete(sessionId);
|
|
11059
|
+
}
|
|
11060
|
+
};
|
|
11061
|
+
const getDefaultApiKey = () => {
|
|
11062
|
+
if (!opts.config.apiKey) return void 0;
|
|
11063
|
+
return opts.db.select().from(apiKeys).where(eq22(apiKeys.keyHash, hashApiKey(opts.config.apiKey))).get();
|
|
11064
|
+
};
|
|
11065
|
+
const createPasswordSession = (reply) => {
|
|
11066
|
+
const key = getDefaultApiKey();
|
|
11067
|
+
if (!key || key.revokedAt) return false;
|
|
11068
|
+
const sessionId = createSession(key.id);
|
|
11069
|
+
reply.header("set-cookie", serializeSessionCookie({
|
|
11070
|
+
name: SESSION_COOKIE_NAME,
|
|
11071
|
+
value: sessionId,
|
|
11072
|
+
path: sessionCookiePath,
|
|
11073
|
+
secure: sessionCookieSecure,
|
|
11074
|
+
ttlMs: SESSION_TTL_MS
|
|
11075
|
+
}));
|
|
11076
|
+
return true;
|
|
11077
|
+
};
|
|
11078
|
+
app.get(apiPrefix + "/session", async (request, reply) => {
|
|
11079
|
+
const sessionId = parseCookies2(request.headers.cookie)[SESSION_COOKIE_NAME];
|
|
11080
|
+
return reply.send({
|
|
11081
|
+
authenticated: Boolean(sessionId && resolveSessionApiKeyId(sessionId)),
|
|
11082
|
+
setupRequired: !opts.config.dashboardPasswordHash
|
|
11083
|
+
});
|
|
11084
|
+
});
|
|
11085
|
+
app.post(apiPrefix + "/session/setup", async (request, reply) => {
|
|
11086
|
+
if (opts.config.dashboardPasswordHash) {
|
|
11087
|
+
const err = validationError("Dashboard password is already configured");
|
|
11088
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
11089
|
+
}
|
|
11090
|
+
const password = request.body?.password?.trim();
|
|
11091
|
+
if (!password || password.length < 8) {
|
|
11092
|
+
const err = validationError("Password must be at least 8 characters");
|
|
11093
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
11094
|
+
}
|
|
11095
|
+
opts.config.dashboardPasswordHash = hashApiKey(password);
|
|
11096
|
+
saveConfig(opts.config);
|
|
11097
|
+
if (!createPasswordSession(reply)) {
|
|
11098
|
+
const err = authInvalid();
|
|
11099
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
11100
|
+
}
|
|
11101
|
+
return reply.send({ authenticated: true });
|
|
11102
|
+
});
|
|
11103
|
+
app.post(apiPrefix + "/session", async (request, reply) => {
|
|
11104
|
+
const password = request.body?.password?.trim();
|
|
11105
|
+
const apiKey = request.body?.apiKey?.trim();
|
|
11106
|
+
if (password) {
|
|
11107
|
+
if (!opts.config.dashboardPasswordHash) {
|
|
11108
|
+
const err2 = validationError("No dashboard password configured \u2014 use /session/setup first");
|
|
11109
|
+
return reply.status(err2.statusCode).send(err2.toJSON());
|
|
11110
|
+
}
|
|
11111
|
+
if (hashApiKey(password) !== opts.config.dashboardPasswordHash) {
|
|
11112
|
+
return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Incorrect password" } });
|
|
11113
|
+
}
|
|
11114
|
+
if (!createPasswordSession(reply)) {
|
|
11115
|
+
return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Server API key not found \u2014 re-run canonry init" } });
|
|
11116
|
+
}
|
|
11117
|
+
return reply.send({ authenticated: true });
|
|
11118
|
+
}
|
|
11119
|
+
if (apiKey) {
|
|
11120
|
+
const key = opts.db.select().from(apiKeys).where(eq22(apiKeys.keyHash, hashApiKey(apiKey))).get();
|
|
11121
|
+
if (!key || key.revokedAt) {
|
|
11122
|
+
const err2 = authInvalid();
|
|
11123
|
+
return reply.status(err2.statusCode).send(err2.toJSON());
|
|
11124
|
+
}
|
|
11125
|
+
opts.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq22(apiKeys.id, key.id)).run();
|
|
11126
|
+
const sessionId = createSession(key.id);
|
|
11127
|
+
reply.header("set-cookie", serializeSessionCookie({
|
|
11128
|
+
name: SESSION_COOKIE_NAME,
|
|
11129
|
+
value: sessionId,
|
|
11130
|
+
path: sessionCookiePath,
|
|
11131
|
+
secure: sessionCookieSecure,
|
|
11132
|
+
ttlMs: SESSION_TTL_MS
|
|
11133
|
+
}));
|
|
11134
|
+
return reply.send({ authenticated: true });
|
|
11135
|
+
}
|
|
11136
|
+
const err = validationError("Either password or apiKey is required");
|
|
11137
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
11138
|
+
});
|
|
11139
|
+
app.delete(apiPrefix + "/session", async (request, reply) => {
|
|
11140
|
+
const sessionId = parseCookies2(request.headers.cookie)[SESSION_COOKIE_NAME];
|
|
11141
|
+
clearSession(sessionId);
|
|
11142
|
+
reply.header("set-cookie", serializeSessionCookie({
|
|
11143
|
+
name: SESSION_COOKIE_NAME,
|
|
11144
|
+
value: null,
|
|
11145
|
+
path: sessionCookiePath,
|
|
11146
|
+
secure: sessionCookieSecure,
|
|
11147
|
+
ttlMs: SESSION_TTL_MS
|
|
11148
|
+
}));
|
|
11149
|
+
return reply.status(204).send();
|
|
11150
|
+
});
|
|
10883
11151
|
await app.register(apiRoutes, {
|
|
10884
11152
|
db: opts.db,
|
|
10885
11153
|
routePrefix: apiPrefix,
|
|
10886
11154
|
skipAuth: false,
|
|
11155
|
+
sessionCookieName: SESSION_COOKIE_NAME,
|
|
11156
|
+
resolveSessionApiKeyId,
|
|
10887
11157
|
getGoogleAuthConfig: () => getGoogleAuthConfig(opts.config),
|
|
10888
11158
|
googleConnectionStore,
|
|
10889
11159
|
googleStateSecret,
|
|
@@ -11118,7 +11388,7 @@ async function createServer(opts) {
|
|
|
11118
11388
|
if (fs5.existsSync(assetsDir)) {
|
|
11119
11389
|
const indexPath = path6.join(assetsDir, "index.html");
|
|
11120
11390
|
const injectConfig = (html) => {
|
|
11121
|
-
const clientConfig = {
|
|
11391
|
+
const clientConfig = {};
|
|
11122
11392
|
if (basePath) clientConfig.basePath = basePath;
|
|
11123
11393
|
const configScript = `<script>window.__CANONRY_CONFIG__=${JSON.stringify(clientConfig)}</script>`;
|
|
11124
11394
|
const baseTag = basePath ? `<base href="${basePath}">` : "";
|
|
@@ -11162,7 +11432,12 @@ async function createServer(opts) {
|
|
|
11162
11432
|
return reply.status(404).send({ error: "Not found" });
|
|
11163
11433
|
});
|
|
11164
11434
|
}
|
|
11165
|
-
const healthHandler = async () => ({
|
|
11435
|
+
const healthHandler = async () => ({
|
|
11436
|
+
status: "ok",
|
|
11437
|
+
service: "canonry",
|
|
11438
|
+
version: PKG_VERSION,
|
|
11439
|
+
...basePath ? { basePath: basePath.replace(/\/$/, "") } : {}
|
|
11440
|
+
});
|
|
11166
11441
|
app.get("/health", healthHandler);
|
|
11167
11442
|
if (basePath) {
|
|
11168
11443
|
app.get(`${basePath}health`, healthHandler);
|